🚀 Oferta especial: 60% OFF no CrazyStack - Últimas vagas!Garantir vaga →
Módulo 4 - Aula 1

Testes Unitários com tRPC

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.

75 min
Avançado
Testing

🎯 Por que testes unitários são críticos em tRPC?

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.

🔧 Setup de Testing Environment

vitest.config.ts
// 📁 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();
});

🧪 Testando tRPC Procedures

src/test/helpers/trpc-test-utils.ts
// 📁 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,
      }),
    },
  });
}

✅ Exemplo de Teste Unitário

src/server/trpc/routers/user.test.ts
// 📁 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',
        })
      );
    });
  });
});

🛡️ Testando Middleware

src/server/trpc/middlewares/auth.test.ts
// 📁 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();
    });
  });
});

📸 Snapshot Testing

src/server/trpc/routers/organization.test.ts
// 📁 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)}>"`,
});

🚀 Scripts e Automação

package.json
// 📁 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,
    });
  }
}

💡 Melhores Práticas para Testes Unitários

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.