Domine testes unitários para aplicações tRPC: setup de testing avançado, mocks eficientes, TDD e cobertura de código para SaaS de alta qualidade.
Confiança no Deploy: Testes unitários garantem que mudanças não quebrem funcionalidades existentes.
Refactoring Seguro: Permitem evolução do código com segurança e detecção precoce de bugs.
// 📁 vitest.config.ts
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
// 🔧 Configurações globais
globals: true,
environment: 'node',
setupFiles: ['./src/test/setup.ts'],
// 📊 Cobertura de código
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'dist/',
],
threshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
// ⚡ Performance
pool: 'threads',
poolOptions: {
threads: {
singleThread: true,
},
},
},
// 🎯 Resolução de paths
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@/test': resolve(__dirname, './src/test'),
},
},
});
// 📁 src/test/setup.ts
import { beforeAll, afterAll, afterEach } from 'vitest';
import { mockDeep, mockReset } from 'vitest-mock-extended';
import { PrismaClient } from '@prisma/client';
import { Redis } from 'ioredis';
// 🔧 Mocks globais
export const prismaMock = mockDeep<PrismaClient>();
export const redisMock = mockDeep<Redis>();
// 🧹 Limpar mocks entre testes
afterEach(() => {
mockReset(prismaMock);
mockReset(redisMock);
});
// 📊 Setup de variáveis de ambiente para testes
beforeAll(() => {
process.env.NODE_ENV = 'test';
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test';
process.env.REDIS_URL = 'redis://localhost:6379/1';
process.env.JWT_SECRET = 'test-secret-key';
});
// 🧹 Cleanup global
afterAll(async () => {
await prismaMock.$disconnect();
await redisMock.quit();
});
// 📁 src/test/helpers/trpc-test-utils.ts
import { createCallerFactory } from '@trpc/server';
import { createContext } from '@/server/trpc/context';
import { appRouter } from '@/server/trpc/router';
import type { Context } from '@/server/trpc/context';
import { prismaMock, redisMock } from '../setup';
// 🔧 Factory para criar caller de teste
export const createTestCaller = createCallerFactory(appRouter);
// 🎯 Criar contexto mockado para testes
export function createMockContext(overrides: Partial<Context> = {}): Context {
const mockSession = {
user: {
id: 'test-user-id',
email: 'test@example.com',
name: 'Test User',
role: 'USER' as const,
permissions: ['user:read', 'user:write'],
},
};
const mockOrganization = {
id: 'test-org-id',
name: 'Test Organization',
plan: 'PRO' as const,
features: ['feature-a', 'feature-b'],
limits: {
apiCalls: 1000,
storage: 10000,
users: 50,
},
};
return {
session: mockSession,
organization: mockOrganization,
prisma: prismaMock,
redis: redisMock,
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
req: {} as any,
ip: '127.0.0.1',
userAgent: 'test-agent',
timestamp: Date.now(),
traceId: 'test-trace-id',
utils: {
hasPermission: vi.fn().mockReturnValue(true),
hasRole: vi.fn().mockReturnValue(true),
canAccessResource: vi.fn().mockReturnValue(true),
getRateLimitInfo: vi.fn().mockResolvedValue({
remaining: 100,
resetTime: Date.now() + 60000,
}),
},
...overrides,
};
}
// 📊 Helper para criar caller com contexto customizado
export function createTestCallerWithContext(contextOverrides?: Partial<Context>) {
const ctx = createMockContext(contextOverrides);
return createTestCaller(ctx);
}
// 🔒 Helper para contexto não autenticado
export function createUnauthenticatedCaller() {
return createTestCallerWithContext({
session: null,
organization: null,
utils: {
hasPermission: vi.fn().mockReturnValue(false),
hasRole: vi.fn().mockReturnValue(false),
canAccessResource: vi.fn().mockReturnValue(false),
getRateLimitInfo: vi.fn().mockResolvedValue({
remaining: 0,
resetTime: Date.now() + 60000,
}),
},
});
}
// 📁 src/server/trpc/routers/user.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { TRPCError } from '@trpc/server';
import { createTestCallerWithContext, createUnauthenticatedCaller } from '@/test/helpers/trpc-test-utils';
import { prismaMock } from '@/test/setup';
describe('User Router', () => {
// 🧹 Reset mocks antes de cada teste
beforeEach(() => {
vi.clearAllMocks();
});
describe('getProfile', () => {
it('🔒 deve retornar perfil do usuário autenticado', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext();
const mockUser = {
id: 'test-user-id',
email: 'test@example.com',
name: 'Test User',
createdAt: new Date(),
updatedAt: new Date(),
};
prismaMock.user.findUnique.mockResolvedValue(mockUser);
// 🎬 Act
const result = await caller.user.getProfile();
// ✅ Assert
expect(result).toEqual({
id: mockUser.id,
email: mockUser.email,
name: mockUser.name,
createdAt: mockUser.createdAt,
});
expect(prismaMock.user.findUnique).toHaveBeenCalledWith({
where: { id: 'test-user-id' },
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
});
it('🚫 deve lançar erro UNAUTHORIZED para usuário não autenticado', async () => {
// 🎯 Arrange
const caller = createUnauthenticatedCaller();
// 🎬 Act & Assert
await expect(caller.user.getProfile()).rejects.toThrow(
new TRPCError({
code: 'UNAUTHORIZED',
message: 'Você precisa estar logado para acessar este recurso',
})
);
expect(prismaMock.user.findUnique).not.toHaveBeenCalled();
});
it('🔍 deve lançar erro NOT_FOUND se usuário não existir', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext();
prismaMock.user.findUnique.mockResolvedValue(null);
// 🎬 Act & Assert
await expect(caller.user.getProfile()).rejects.toThrow(
new TRPCError({
code: 'NOT_FOUND',
message: 'Usuário não encontrado',
})
);
});
});
describe('updateProfile', () => {
const updateInput = {
name: 'Updated Name',
email: 'updated@example.com',
};
it('✅ deve atualizar perfil com dados válidos', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext();
const mockUpdatedUser = {
id: 'test-user-id',
name: updateInput.name,
email: updateInput.email,
updatedAt: new Date(),
};
prismaMock.user.update.mockResolvedValue(mockUpdatedUser);
// 🎬 Act
const result = await caller.user.updateProfile(updateInput);
// ✅ Assert
expect(result).toEqual({
id: mockUpdatedUser.id,
name: mockUpdatedUser.name,
email: mockUpdatedUser.email,
updatedAt: mockUpdatedUser.updatedAt,
});
expect(prismaMock.user.update).toHaveBeenCalledWith({
where: { id: 'test-user-id' },
data: updateInput,
select: {
id: true,
name: true,
email: true,
updatedAt: true,
},
});
});
it('❌ deve validar dados de entrada inválidos', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext();
const invalidInput = {
name: '', // Nome vazio
email: 'invalid-email', // Email inválido
};
// 🎬 Act & Assert
await expect(caller.user.updateProfile(invalidInput)).rejects.toThrow();
expect(prismaMock.user.update).not.toHaveBeenCalled();
});
});
describe('deleteAccount', () => {
it('🗑️ deve deletar conta do usuário', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext();
prismaMock.user.delete.mockResolvedValue({} as any);
// 🎬 Act
const result = await caller.user.deleteAccount();
// ✅ Assert
expect(result).toEqual({ success: true });
expect(prismaMock.user.delete).toHaveBeenCalledWith({
where: { id: 'test-user-id' },
});
});
it('👑 deve verificar permissões de admin para deletar outros usuários', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext({
utils: {
hasPermission: vi.fn().mockReturnValue(false),
hasRole: vi.fn().mockReturnValue(false),
canAccessResource: vi.fn().mockReturnValue(false),
getRateLimitInfo: vi.fn().mockResolvedValue({
remaining: 100,
resetTime: Date.now() + 60000,
}),
},
});
// 🎬 Act & Assert
await expect(
caller.user.deleteAccount({ userId: 'other-user-id' })
).rejects.toThrow(
new TRPCError({
code: 'FORBIDDEN',
message: 'Você não tem permissão para deletar outros usuários',
})
);
});
});
});
// 📁 src/server/trpc/middlewares/auth.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { TRPCError } from '@trpc/server';
import { authMiddleware, permissionMiddleware } from '@/server/trpc/middlewares/auth';
import { createMockContext } from '@/test/helpers/trpc-test-utils';
describe('Auth Middleware', () => {
describe('authMiddleware', () => {
it('✅ deve permitir acesso para usuário autenticado', async () => {
// 🎯 Arrange
const ctx = createMockContext();
const next = vi.fn().mockResolvedValue({ success: true });
// 🎬 Act
const result = await authMiddleware({ ctx, next });
// ✅ Assert
expect(result).toEqual({ success: true });
expect(next).toHaveBeenCalledWith({
ctx: expect.objectContaining({
session: expect.objectContaining({
user: expect.objectContaining({
id: 'test-user-id',
}),
}),
}),
});
});
it('🚫 deve bloquear acesso para usuário não autenticado', async () => {
// 🎯 Arrange
const ctx = createMockContext({ session: null });
const next = vi.fn();
// 🎬 Act & Assert
await expect(authMiddleware({ ctx, next })).rejects.toThrow(
new TRPCError({
code: 'UNAUTHORIZED',
message: 'Você precisa estar logado para acessar este recurso',
})
);
expect(next).not.toHaveBeenCalled();
});
});
describe('permissionMiddleware', () => {
it('✅ deve permitir acesso com permissão correta', async () => {
// 🎯 Arrange
const ctx = createMockContext();
ctx.utils.hasPermission = vi.fn().mockReturnValue(true);
const middleware = permissionMiddleware('user:write');
const next = vi.fn().mockResolvedValue({ success: true });
// 🎬 Act
const result = await middleware({ ctx, next });
// ✅ Assert
expect(result).toEqual({ success: true });
expect(ctx.utils.hasPermission).toHaveBeenCalledWith('user:write');
expect(next).toHaveBeenCalled();
});
it('🚫 deve bloquear acesso sem permissão', async () => {
// 🎯 Arrange
const ctx = createMockContext();
ctx.utils.hasPermission = vi.fn().mockReturnValue(false);
const middleware = permissionMiddleware('admin:delete');
const next = vi.fn();
// 🎬 Act & Assert
await expect(middleware({ ctx, next })).rejects.toThrow(
new TRPCError({
code: 'FORBIDDEN',
message: 'Você não tem a permissão necessária: admin:delete',
})
);
expect(next).not.toHaveBeenCalled();
});
it('🚀 deve permitir acesso para SUPER_ADMIN', async () => {
// 🎯 Arrange
const ctx = createMockContext({
session: {
user: {
id: 'super-admin-id',
email: 'admin@example.com',
name: 'Super Admin',
role: 'SUPER_ADMIN',
permissions: [],
},
},
});
ctx.utils.hasPermission = vi.fn().mockReturnValue(true);
const middleware = permissionMiddleware('any:permission');
const next = vi.fn().mockResolvedValue({ success: true });
// 🎬 Act
const result = await middleware({ ctx, next });
// ✅ Assert
expect(result).toEqual({ success: true });
expect(next).toHaveBeenCalled();
});
});
});
// 📁 src/server/trpc/routers/organization.test.ts
import { describe, it, expect } from 'vitest';
import { createTestCallerWithContext } from '@/test/helpers/trpc-test-utils';
import { prismaMock } from '@/test/setup';
describe('Organization Router Snapshots', () => {
it('📊 deve retornar estrutura consistente para dashboard', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext();
const mockDashboardData = {
organization: {
id: 'org-123',
name: 'Test Organization',
plan: 'PRO',
features: ['analytics', 'api-access'],
},
metrics: {
totalUsers: 150,
activeUsers: 120,
apiCalls: 25000,
storageUsed: 5.2,
},
recentActivity: [
{
id: 'activity-1',
type: 'user_created',
timestamp: new Date('2024-01-20T10:00:00Z'),
userId: 'user-123',
},
{
id: 'activity-2',
type: 'api_call',
timestamp: new Date('2024-01-20T09:30:00Z'),
endpoint: '/api/users',
},
],
};
prismaMock.organization.findUnique.mockResolvedValue(mockDashboardData.organization as any);
prismaMock.user.count.mockResolvedValue(150);
prismaMock.apiCall.count.mockResolvedValue(25000);
// 🎬 Act
const result = await caller.organization.getDashboard();
// 📸 Assert with snapshot
expect(result).toMatchSnapshot('organization-dashboard-structure');
});
it('📈 deve retornar formato consistente para analytics', async () => {
// 🎯 Arrange
const caller = createTestCallerWithContext();
const mockAnalytics = {
period: '30d',
metrics: {
growth: {
users: { current: 150, previous: 120, change: 25 },
revenue: { current: 5000, previous: 4200, change: 19.05 },
apiCalls: { current: 75000, previous: 65000, change: 15.38 },
},
usage: {
topEndpoints: [
{ endpoint: '/api/users', calls: 25000, percentage: 33.33 },
{ endpoint: '/api/auth', calls: 20000, percentage: 26.67 },
],
errorRate: 0.05,
avgResponseTime: 120,
},
},
charts: {
userGrowth: [
{ date: '2024-01-01', value: 100 },
{ date: '2024-01-15', value: 125 },
{ date: '2024-01-30', value: 150 },
],
},
};
prismaMock.$queryRaw.mockResolvedValue(mockAnalytics as any);
// 🎬 Act
const result = await caller.organization.getAnalytics({
period: '30d',
metrics: ['growth', 'usage'],
});
// 📸 Assert with snapshot
expect(result).toMatchSnapshot('organization-analytics-30d');
});
});
// 📁 src/test/utils/snapshot-serializers.ts
import { expect } from 'vitest';
// 🕒 Serializer para datas consistentes
expect.addSnapshotSerializer({
test: (value) => value instanceof Date,
serialize: (value: Date) => `"<Date: ${value.toISOString()}>"`,
});
// 🔢 Serializer para IDs gerados
expect.addSnapshotSerializer({
test: (value) => typeof value === 'string' && /^[a-z0-9-]{36}$/.test(value),
serialize: () => '"<UUID>"',
});
// 📊 Serializer para números flutuantes
expect.addSnapshotSerializer({
test: (value) => typeof value === 'number' && !Number.isInteger(value),
serialize: (value: number) => `"<Float: ${value.toFixed(2)}>"`,
});
// 📁 package.json - Scripts de teste
{
"scripts": {
"test": "vitest",
"test:watch": "vitest --watch",
"test:coverage": "vitest --coverage",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:e2e": "playwright test",
"test:all": "npm run test:unit && npm run test:integration && npm run test:e2e"
}
}
// 📁 .github/workflows/tests.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- 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: Run unit tests
run: npm run test:unit
- name: Generate coverage report
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
flags: unittests
name: codecov-umbrella
// 📁 src/test/factories/user.factory.ts
import { faker } from '@faker-js/faker';
import type { User, Organization } from '@prisma/client';
// 🏭 Factory para criar dados de teste consistentes
export class UserFactory {
static create(overrides?: Partial<User>): User {
return {
id: faker.string.uuid(),
email: faker.internet.email(),
name: faker.person.fullName(),
password: faker.internet.password(),
role: 'USER',
organizationId: faker.string.uuid(),
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
...overrides,
};
}
static createMany(count: number, overrides?: Partial<User>): User[] {
return Array.from({ length: count }, () => this.create(overrides));
}
static admin(overrides?: Partial<User>): User {
return this.create({
role: 'ADMIN',
...overrides,
});
}
static superAdmin(overrides?: Partial<User>): User {
return this.create({
role: 'SUPER_ADMIN',
...overrides,
});
}
}
export class OrganizationFactory {
static create(overrides?: Partial<Organization>): Organization {
return {
id: faker.string.uuid(),
name: faker.company.name(),
plan: 'FREE',
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
...overrides,
};
}
static pro(overrides?: Partial<Organization>): Organization {
return this.create({
plan: 'PRO',
...overrides,
});
}
static enterprise(overrides?: Partial<Organization>): Organization {
return this.create({
plan: 'ENTERPRISE',
...overrides,
});
}
}
AAA Pattern:Sempre estruture testes com Arrange, Act, Assert para clareza máxima.
Isolamento:Cada teste deve ser independente e não depender de outros testes.
Cobertura Estratégica:Foque em 80%+ de cobertura nas regras de negócio críticas.
Testes Descritivos:Use nomes de teste que descrevem o comportamento esperado.