Domine testes de integração para tRPC: database testing com containers, API testing end-to-end e automação completa para SaaS enterprise-grade.
Confiança Total: Validam que todos os componentes funcionam corretamente em conjunto no ambiente real.
Detecção Precoce: Identificam problemas de compatibilidade entre serviços antes do deploy em produção.
// 📁 docker-compose.test.yml
version: '3.8'
services:
# 🗄️ PostgreSQL para testes
postgres-test:
image: postgres:15-alpine
environment:
POSTGRES_DB: trpc_test
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
ports:
- "5433:5432"
volumes:
- postgres_test_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test_user -d trpc_test"]
interval: 5s
timeout: 5s
retries: 5
# 🔴 Redis para cache e sessions
redis-test:
image: redis:7-alpine
ports:
- "6380:6379"
command: redis-server --appendonly yes
volumes:
- redis_test_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
# 📧 MailHog para testes de email
mailhog-test:
image: mailhog/mailhog:latest
ports:
- "1026:1025" # SMTP
- "8026:8025" # Web UI
volumes:
postgres_test_data:
redis_test_data:
// 📁 src/test/setup-integration.ts
import { beforeAll, afterAll } from 'vitest';
import { GenericContainer, StartedTestContainer } from 'testcontainers';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
let postgresContainer: StartedTestContainer;
let redisContainer: StartedTestContainer;
// 🚀 Setup de containers para testes
beforeAll(async () => {
console.log('🐳 Iniciando containers de teste...');
// 🗄️ PostgreSQL container
postgresContainer = await new GenericContainer('postgres:15-alpine')
.withEnvironment({
POSTGRES_DB: 'trpc_test',
POSTGRES_USER: 'test_user',
POSTGRES_PASSWORD: 'test_password',
})
.withExposedPorts(5432)
.withHealthCheck({
test: ['CMD-SHELL', 'pg_isready -U test_user -d trpc_test'],
interval: 5000,
timeout: 5000,
retries: 5,
})
.start();
// 🔴 Redis container
redisContainer = await new GenericContainer('redis:7-alpine')
.withExposedPorts(6379)
.withHealthCheck({
test: ['CMD', 'redis-cli', 'ping'],
interval: 5000,
timeout: 3000,
retries: 5,
})
.start();
// 🔧 Configurar variáveis de ambiente
const postgresPort = postgresContainer.getMappedPort(5432);
const redisPort = redisContainer.getMappedPort(6379);
process.env.DATABASE_URL = `postgresql://test_user:test_password@localhost:${postgresPort}/trpc_test`;
process.env.REDIS_URL = `redis://localhost:${redisPort}`;
// 📊 Executar migrações do Prisma
console.log('📊 Executando migrações...');
await execAsync('npx prisma migrate deploy');
console.log('✅ Containers iniciados e banco configurado');
}, 60000);
// 🧹 Cleanup de containers
afterAll(async () => {
console.log('🧹 Limpando containers...');
await postgresContainer?.stop();
await redisContainer?.stop();
console.log('✅ Containers removidos');
}, 30000);
// 📁 src/test/integration/database.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { createTestCallerWithContext } from '@/test/helpers/trpc-test-utils';
// 🔧 Instância real do Prisma para testes de integração
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
});
describe('Database Integration Tests', () => {
// 🧹 Limpar dados entre testes
beforeEach(async () => {
await prisma.user.deleteMany();
await prisma.organization.deleteMany();
await prisma.permission.deleteMany();
});
afterEach(async () => {
await prisma.user.deleteMany();
await prisma.organization.deleteMany();
await prisma.permission.deleteMany();
});
describe('User Operations', () => {
it('🔄 deve criar usuário com organização e permissões', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext({
prisma, // Usar Prisma real
});
const userData = {
email: 'integration@test.com',
name: 'Integration User',
organizationName: 'Test Organization',
plan: 'PRO' as const,
};
// 🎬 Act
const result = await caller.user.createWithOrganization(userData);
// ✅ Assert - Verificar no banco real
const createdUser = await prisma.user.findUnique({
where: { id: result.userId },
include: {
organization: {
include: {
plan: true,
},
},
permissions: true,
},
});
expect(createdUser).toBeTruthy();
expect(createdUser?.email).toBe(userData.email);
expect(createdUser?.organization?.name).toBe(userData.organizationName);
expect(createdUser?.organization?.plan.name).toBe(userData.plan);
expect(createdUser?.permissions).toHaveLength(3); // Permissões padrão
});
it('🔒 deve validar constraints de unicidade', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext({ prisma });
// Criar primeiro usuário
await prisma.user.create({
data: {
email: 'duplicate@test.com',
name: 'First User',
password: 'hashed-password',
},
});
// 🎬 Act & Assert
await expect(
caller.user.register({
email: 'duplicate@test.com', // Email duplicado
name: 'Second User',
password: 'password123',
})
).rejects.toThrow('Email já está em uso');
});
it('🔄 deve executar transação completa ou rollback', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext({ prisma });
// Mock para falhar na criação de permissões
vi.spyOn(prisma.permission, 'createMany').mockRejectedValueOnce(
new Error('Falha na criação de permissões')
);
// 🎬 Act & Assert
await expect(
caller.user.createWithOrganization({
email: 'transaction@test.com',
name: 'Transaction User',
organizationName: 'Failed Org',
plan: 'FREE',
})
).rejects.toThrow();
// ✅ Verificar que nada foi criado (rollback)
const users = await prisma.user.findMany();
const organizations = await prisma.organization.findMany();
expect(users).toHaveLength(0);
expect(organizations).toHaveLength(0);
});
});
describe('Complex Queries', () => {
it('📊 deve executar queries agregadas complexas', async () => {
// 🎯 Arrange - Criar dados de teste
const org = await prisma.organization.create({
data: {
name: 'Analytics Org',
plan: { create: { name: 'PRO', apiCallsLimit: 10000 } },
},
});
// Criar múltiplos usuários
await prisma.user.createMany({
data: Array.from({ length: 10 }, (_, i) => ({
email: `user${i}@analytics.com`,
name: `User ${i}`,
password: 'password',
organizationId: org.id,
})),
});
// Criar logs de API
const users = await prisma.user.findMany();
await prisma.apiLog.createMany({
data: users.flatMap(user =>
Array.from({ length: 50 }, (_, i) => ({
userId: user.id,
endpoint: `/api/endpoint${i % 5}`,
method: 'GET',
statusCode: i % 10 === 0 ? 500 : 200,
responseTime: Math.floor(Math.random() * 1000),
timestamp: new Date(Date.now() - i * 60000),
}))
),
});
const caller = createTestCallerWithContext({ prisma });
// 🎬 Act
const analytics = await caller.organization.getDetailedAnalytics({
organizationId: org.id,
period: '7d',
});
// ✅ Assert
expect(analytics).toMatchObject({
totalUsers: 10,
totalApiCalls: 500,
averageResponseTime: expect.any(Number),
errorRate: expect.any(Number),
topEndpoints: expect.arrayContaining([
expect.objectContaining({
endpoint: expect.stringMatching(/^/api/endpointd$/),
calls: expect.any(Number),
percentage: expect.any(Number),
}),
]),
userActivity: expect.arrayContaining([
expect.objectContaining({
userId: expect.any(String),
apiCalls: expect.any(Number),
lastActivity: expect.any(Date),
}),
]),
});
});
});
});
// 📁 src/test/integration/api.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer } from 'http';
import { parse } from 'url';
import next from 'next';
import { createTRPCMsw } from 'msw-trpc';
import { setupServer } from 'msw/node';
import { appRouter } from '@/server/trpc/router';
// 🚀 Setup do servidor Next.js para testes
const app = next({ dev: false, quiet: true });
const handle = app.getRequestHandler();
let server: any;
let baseURL: string;
beforeAll(async () => {
await app.prepare();
// 🔧 Criar servidor HTTP
server = createServer((req, res) => {
const parsedUrl = parse(req.url!, true);
handle(req, res, parsedUrl);
});
// 🎯 Iniciar em porta aleatória
await new Promise<void>((resolve) => {
server.listen(0, () => {
const port = server.address().port;
baseURL = `http://localhost:${port}`;
resolve();
});
});
}, 30000);
afterAll(async () => {
if (server) {
server.close();
}
await app.close();
});
describe('API Integration Tests', () => {
describe('tRPC HTTP Endpoints', () => {
it('🔍 deve responder a query via HTTP GET', async () => {
// 🎯 Arrange
const response = await fetch(
`${baseURL}/api/trpc/user.getProfile?batch=1&input=%7B%220%22%3A%7B%22json%22%3Anull%7D%7D`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer valid-jwt-token',
},
}
);
// ✅ Assert
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toHaveProperty('0.result.data');
expect(data[0].result.data).toMatchObject({
id: expect.any(String),
email: expect.any(String),
name: expect.any(String),
});
});
it('📝 deve processar mutation via HTTP POST', async () => {
// 🎯 Arrange
const updateData = {
name: 'Updated Name',
email: 'updated@example.com',
};
const response = await fetch(
`${baseURL}/api/trpc/user.updateProfile`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer valid-jwt-token',
},
body: JSON.stringify({
0: { json: updateData },
}),
}
);
// ✅ Assert
expect(response.status).toBe(200);
const data = await response.json();
expect(data[0].result.data).toMatchObject({
id: expect.any(String),
name: updateData.name,
email: updateData.email,
updatedAt: expect.any(String),
});
});
it('🚫 deve retornar erro 401 para requisições não autenticadas', async () => {
// 🎯 Arrange & Act
const response = await fetch(
`${baseURL}/api/trpc/user.getProfile?batch=1&input=%7B%220%22%3A%7B%22json%22%3Anull%7D%7D`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
// Sem Authorization header
},
}
);
// ✅ Assert
expect(response.status).toBe(401);
const data = await response.json();
expect(data[0].error).toMatchObject({
code: -32001, // tRPC UNAUTHORIZED
message: expect.stringContaining('logado'),
});
});
it('🔄 deve processar batch requests corretamente', async () => {
// 🎯 Arrange
const batchInput = {
0: { json: null }, // getProfile
1: { json: { period: '30d' } }, // getAnalytics
};
const response = await fetch(
`${baseURL}/api/trpc/user.getProfile,organization.getAnalytics?batch=1&input=${encodeURIComponent(JSON.stringify(batchInput))}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer valid-jwt-token',
},
}
);
// ✅ Assert
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toHaveLength(2);
expect(data[0]).toHaveProperty('result.data');
expect(data[1]).toHaveProperty('result.data');
});
});
describe('WebSocket Integration', () => {
it('📡 deve estabelecer conexão WebSocket para subscriptions', async () => {
// 🎯 Arrange
const ws = new WebSocket(`ws://localhost:${baseURL.split(':')[2]}/api/trpc`);
const messages: any[] = [];
ws.onmessage = (event) => {
messages.push(JSON.parse(event.data));
};
// 🔄 Aguardar conexão
await new Promise((resolve) => {
ws.onopen = resolve;
});
// 🎬 Act - Enviar subscription
ws.send(JSON.stringify({
id: 1,
method: 'subscription',
params: {
path: 'notifications.onUserUpdate',
input: null,
},
}));
// Simular evento que deve disparar a subscription
await fetch(`${baseURL}/api/trpc/user.updateProfile`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer valid-jwt-token',
},
body: JSON.stringify({
0: { json: { name: 'WebSocket Test' } },
}),
});
// ⏰ Aguardar mensagem
await new Promise(resolve => setTimeout(resolve, 1000));
// ✅ Assert
expect(messages).toContainEqual(
expect.objectContaining({
id: 1,
result: expect.objectContaining({
type: 'data',
data: expect.objectContaining({
userId: expect.any(String),
action: 'profile_updated',
}),
}),
})
);
ws.close();
});
});
});
// 📁 src/test/integration/redis.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Redis from 'ioredis';
import { createTestCallerWithContext } from '@/test/helpers/trpc-test-utils';
// 🔴 Instância real do Redis para testes
const redis = new Redis(process.env.REDIS_URL!);
describe('Redis Integration Tests', () => {
// 🧹 Limpar Redis entre testes
beforeEach(async () => {
await redis.flushdb();
});
afterEach(async () => {
await redis.flushdb();
});
describe('Cache Operations', () => {
it('💾 deve cachear resultados de queries pesadas', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext({ redis });
// 🎬 Act - Primeira chamada (deve ir ao banco)
const start1 = Date.now();
const result1 = await caller.analytics.getComplexReport({
organizationId: 'test-org',
period: '30d',
});
const duration1 = Date.now() - start1;
// 🎬 Act - Segunda chamada (deve vir do cache)
const start2 = Date.now();
const result2 = await caller.analytics.getComplexReport({
organizationId: 'test-org',
period: '30d',
});
const duration2 = Date.now() - start2;
// ✅ Assert
expect(result1).toEqual(result2);
expect(duration2).toBeLessThan(duration1 * 0.1); // Cache 10x mais rápido
// Verificar que foi armazenado no Redis
const cacheKey = 'analytics:complex-report:test-org:30d';
const cachedData = await redis.get(cacheKey);
expect(cachedData).toBeTruthy();
expect(JSON.parse(cachedData!)).toEqual(result1);
});
it('⏰ deve respeitar TTL do cache', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext({ redis });
const cacheKey = 'test:ttl-key';
// 🎬 Act - Definir cache com TTL de 2 segundos
await redis.setex(cacheKey, 2, JSON.stringify({ data: 'test' }));
// ✅ Assert - Verificar que existe
const cached1 = await redis.get(cacheKey);
expect(cached1).toBeTruthy();
// ⏰ Aguardar expiração
await new Promise(resolve => setTimeout(resolve, 2100));
// ✅ Assert - Verificar que expirou
const cached2 = await redis.get(cacheKey);
expect(cached2).toBeNull();
});
it('🔄 deve invalidar cache automaticamente', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext({ redis });
const userId = 'test-user-id';
// Cachear perfil do usuário
await caller.user.getProfile(); // Primeira chamada para cachear
const profileCacheKey = `user:profile:${userId}`;
const cachedProfile = await redis.get(profileCacheKey);
expect(cachedProfile).toBeTruthy();
// 🎬 Act - Atualizar perfil (deve invalidar cache)
await caller.user.updateProfile({
name: 'Updated Name',
});
// ✅ Assert - Verificar que cache foi invalidado
const invalidatedCache = await redis.get(profileCacheKey);
expect(invalidatedCache).toBeNull();
});
});
describe('Rate Limiting', () => {
it('🚫 deve aplicar rate limiting corretamente', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext({ redis });
const userId = 'rate-limit-user';
// 🎬 Act - Fazer 100 requisições (limite)
const promises = Array.from({ length: 100 }, () =>
caller.user.getProfile().catch(e => e)
);
const results = await Promise.all(promises);
const successes = results.filter(r => !r.code);
const rateLimitErrors = results.filter(r => r.code === 'TOO_MANY_REQUESTS');
// ✅ Assert
expect(successes).toHaveLength(100); // Todas as primeiras 100 passam
// 🎬 Act - Tentar mais uma (deve falhar)
await expect(caller.user.getProfile()).rejects.toThrow('TOO_MANY_REQUESTS');
// Verificar contador no Redis
const rateLimitKey = `rate_limit:${userId}`;
const count = await redis.zcard(rateLimitKey);
expect(count).toBe(100);
});
it('🔄 deve resetar rate limit após janela', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext({ redis });
const rateLimitKey = 'rate_limit:reset-test';
// Simular rate limit atingido
const now = Date.now();
await redis.zadd(rateLimitKey, now, `${now}-1`);
await redis.zadd(rateLimitKey, now + 1, `${now + 1}-2`);
await redis.expire(rateLimitKey, 1); // Expira em 1 segundo
// 🎬 Act - Aguardar reset
await new Promise(resolve => setTimeout(resolve, 1100));
// ✅ Assert - Rate limit deve ter resetado
const count = await redis.zcard(rateLimitKey);
expect(count).toBe(0);
// Deve permitir novas requisições
await expect(caller.user.getProfile()).resolves.toBeTruthy();
});
});
describe('Session Management', () => {
it('🔐 deve gerenciar sessões no Redis', async () => {
// 🎯 Arrange
const sessionId = 'test-session-id';
const sessionData = {
userId: 'test-user',
organizationId: 'test-org',
role: 'USER',
createdAt: Date.now(),
};
// 🎬 Act - Criar sessão
await redis.setex(
`session:${sessionId}`,
3600, // 1 hora
JSON.stringify(sessionData)
);
// ✅ Assert - Verificar sessão criada
const storedSession = await redis.get(`session:${sessionId}`);
expect(JSON.parse(storedSession!)).toEqual(sessionData);
// 🎬 Act - Simular logout (deletar sessão)
await redis.del(`session:${sessionId}`);
// ✅ Assert - Verificar sessão removida
const deletedSession = await redis.get(`session:${sessionId}`);
expect(deletedSession).toBeNull();
});
it('🔄 deve renovar sessões automaticamente', async () => {
// 🎯 Arrange
const sessionId = 'renewal-test-session';
// Criar sessão com TTL curto
await redis.setex(
`session:${sessionId}`,
2, // 2 segundos
JSON.stringify({ userId: 'test' })
);
// Verificar TTL inicial
const initialTTL = await redis.ttl(`session:${sessionId}`);
expect(initialTTL).toBeLessThanOrEqual(2);
// 🎬 Act - Renovar sessão
await redis.expire(`session:${sessionId}`, 10); // Renovar para 10 segundos
// ✅ Assert - Verificar TTL renovado
const renewedTTL = await redis.ttl(`session:${sessionId}`);
expect(renewedTTL).toBeGreaterThan(5);
});
});
});
// 📁 .github/workflows/integration-tests.yml
name: Integration Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: trpc_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Setup test database
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/trpc_test
run: |
npx prisma migrate deploy
npx prisma db seed
- name: Run integration tests
env:
NODE_ENV: test
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/trpc_test
REDIS_URL: redis://localhost:6379
JWT_SECRET: test-secret-for-ci
run: npm run test:integration
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: integration-test-results
path: |
test-results/
coverage/
// 📁 scripts/test-setup.sh
#!/bin/bash
# 🎯 Script para setup completo de ambiente de teste
echo "🐳 Iniciando containers de teste..."
# Parar containers existentes
docker-compose -f docker-compose.test.yml down
# Iniciar containers frescos
docker-compose -f docker-compose.test.yml up -d
# Aguardar containers ficarem healthy
echo "⏰ Aguardando containers ficarem prontos..."
timeout 60s bash -c 'until docker-compose -f docker-compose.test.yml ps | grep healthy; do sleep 2; done'
# Executar migrações
echo "📊 Executando migrações..."
DATABASE_URL="postgresql://test_user:test_password@localhost:5433/trpc_test" npx prisma migrate deploy
# Executar seeds
echo "🌱 Executando seeds..."
DATABASE_URL="postgresql://test_user:test_password@localhost:5433/trpc_test" npx prisma db seed
echo "✅ Ambiente de teste pronto!"
# 📁 scripts/test-cleanup.sh
#!/bin/bash
echo "🧹 Limpando ambiente de teste..."
# Parar e remover containers
docker-compose -f docker-compose.test.yml down -v
# Remover volumes órfãos
docker volume prune -f
# Limpar cache de testes
rm -rf .vitest
rm -rf coverage
rm -rf test-results
echo "✅ Limpeza concluída!"
// 📁 vitest.integration.config.ts
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
// 🎯 Configurações específicas para integração
name: 'integration',
include: ['src/test/integration/**/*.test.ts'],
setupFiles: ['./src/test/setup-integration.ts'],
// ⏰ Timeouts maiores para testes de integração
testTimeout: 30000,
hookTimeout: 60000,
// 🔄 Executar sequencialmente para evitar conflitos
pool: 'forks',
poolOptions: {
forks: {
singleFork: true,
},
},
// 📊 Configuração de coverage
coverage: {
provider: 'v8',
include: ['src/server/**'],
exclude: [
'src/test/**',
'src/server/**/*.test.ts',
'src/server/**/*.spec.ts',
],
threshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
},
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
});
Ambiente Isolado:Use containers para garantir ambiente consistente e isolado.
Dados Realistas:Use dados que simulem cenários reais de produção.
Cleanup Rigoroso:Sempre limpe estado entre testes para evitar interferências.
Timeouts Adequados:Configure timeouts maiores para operações de I/O reais.