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

Mocking e Test Doubles

Domine mocking e test doubles para tRPC: mocks avançados, spies, stubs, fakes e dependency injection para testes isolados, rápidos e confiáveis.

80 min
Avançado
Mocking

🎯 Por que mocking é fundamental para testes eficazes?

Testes Rápidos: Mocks eliminam dependências externas, tornando testes 10x mais rápidos.

Isolamento: Permitem testar unidades de código sem interferência de sistemas externos.

🎭 Tipos de Test Doubles

src/test/doubles/test-doubles-examples.ts
// 📁 src/test/doubles/test-doubles-examples.ts
import { vi } from 'vitest';

// 🎯 1. DUMMY - Objetos que não são usados, apenas preenchem parâmetros
class DummyLogger {
  log() {} // Não faz nada
  error() {} // Não faz nada
  warn() {} // Não faz nada
}

// 🎯 2. STUB - Retorna respostas predefinidas
class StubUserRepository {
  async findById(id: string) {
    // Sempre retorna o mesmo usuário para testes
    return {
      id: 'test-id',
      name: 'Test User',
      email: 'test@example.com',
    };
  }
}

// 🎯 3. SPY - Grava informações sobre como foi chamado
class SpyEmailService {
  private calls: any[] = [];
  
  async sendEmail(to: string, subject: string, body: string) {
    this.calls.push({ to, subject, body, timestamp: Date.now() });
    return { messageId: 'test-message-id' };
  }
  
  getCallHistory() {
    return this.calls;
  }
  
  wasCalledWith(to: string) {
    return this.calls.some(call => call.to === to);
  }
}

// 🎯 4. MOCK - Combina stub + spy + verificações
class MockPaymentService {
  private expectedCalls: any[] = [];
  private actualCalls: any[] = [];
  
  expectCall(method: string, args: any[], returnValue: any) {
    this.expectedCalls.push({ method, args, returnValue });
  }
  
  async processPayment(amount: number, token: string) {
    this.actualCalls.push({ method: 'processPayment', args: [amount, token] });
    
    const expected = this.expectedCalls.find(
      call => call.method === 'processPayment'
    );
    
    if (!expected) {
      throw new Error('Unexpected call to processPayment');
    }
    
    return expected.returnValue;
  }
  
  verify() {
    expect(this.actualCalls).toEqual(
      this.expectedCalls.map(call => ({ 
        method: call.method, 
        args: call.args 
      }))
    );
  }
}

// 🎯 5. FAKE - Implementação simplificada mas funcional
class FakeDatabase {
  private data: Map<string, any> = new Map();
  
  async save(table: string, id: string, data: any) {
    const key = `${table}:${id}`;
    this.data.set(key, { ...data, id, createdAt: new Date() });
    return data;
  }
  
  async findById(table: string, id: string) {
    const key = `${table}:${id}`;
    return this.data.get(key) || null;
  }
  
  async findMany(table: string, filter?: any) {
    const results = [];
    for (const [key, value] of this.data.entries()) {
      if (key.startsWith(`${table}:`)) {
        if (!filter || this.matchesFilter(value, filter)) {
          results.push(value);
        }
      }
    }
    return results;
  }
  
  private matchesFilter(item: any, filter: any): boolean {
    return Object.entries(filter).every(
      ([key, value]) => item[key] === value
    );
  }
  
  clear() {
    this.data.clear();
  }
}

🔧 Mocking Avançado com Vitest

src/test/mocks/advanced-mocking.test.ts
// 📁 src/test/mocks/advanced-mocking.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mockDeep, mockReset } from 'vitest-mock-extended';
import { PrismaClient } from '@prisma/client';
import { Redis } from 'ioredis';

// 🎯 Mocks profundos para objetos complexos
const prismaMock = mockDeep<PrismaClient>();
const redisMock = mockDeep<Redis>();

// 🔧 Mock de módulos inteiros
vi.mock('@/lib/prisma', () => ({
  prisma: prismaMock,
}));

vi.mock('ioredis', () => ({
  default: vi.fn(() => redisMock),
  Redis: vi.fn(() => redisMock),
}));

// 📧 Mock de serviços externos
vi.mock('@/lib/email-service', () => ({
  EmailService: vi.fn().mockImplementation(() => ({
    sendEmail: vi.fn().mockResolvedValue({ messageId: 'test-123' }),
    sendBulkEmail: vi.fn().mockResolvedValue({ sent: 100, failed: 0 }),
    getDeliveryStatus: vi.fn().mockResolvedValue('delivered'),
  })),
}));

describe('Advanced Mocking Patterns', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockReset(prismaMock);
    mockReset(redisMock);
  });

  describe('Conditional Mocking', () => {
    it('🔄 deve simular diferentes cenários de resposta', async () => {
      // 🎯 Arrange - Mock que retorna diferentes valores baseado no input
      prismaMock.user.findUnique
        .mockImplementation(async ({ where }) => {
          if (where.id === 'admin-id') {
            return {
              id: 'admin-id',
              role: 'ADMIN',
              email: 'admin@test.com',
              name: 'Admin User',
            } as any;
          }
          
          if (where.id === 'banned-id') {
            return {
              id: 'banned-id',
              role: 'USER',
              status: 'BANNED',
              email: 'banned@test.com',
              name: 'Banned User',
            } as any;
          }
          
          return null; // Usuário não encontrado
        });

      // 🎬 Act & Assert
      const adminUser = await prismaMock.user.findUnique({ 
        where: { id: 'admin-id' } 
      });
      expect(adminUser?.role).toBe('ADMIN');

      const bannedUser = await prismaMock.user.findUnique({ 
        where: { id: 'banned-id' } 
      });
      expect(bannedUser?.status).toBe('BANNED');

      const notFound = await prismaMock.user.findUnique({ 
        where: { id: 'invalid-id' } 
      });
      expect(notFound).toBeNull();
    });

    it('📊 deve simular paginação complexa', async () => {
      // 🎯 Arrange
      const mockUsers = Array.from({ length: 100 }, (_, i) => ({
        id: `user-${i}`,
        name: `User ${i}`,
        email: `user${i}@test.com`,
      }));

      prismaMock.user.findMany
        .mockImplementation(async ({ skip = 0, take = 10 }) => {
          return mockUsers.slice(skip, skip + take) as any;
        });

      prismaMock.user.count
        .mockResolvedValue(100);

      // 🎬 Act
      const page1 = await prismaMock.user.findMany({ skip: 0, take: 10 });
      const page2 = await prismaMock.user.findMany({ skip: 10, take: 10 });
      const totalCount = await prismaMock.user.count();

      // ✅ Assert
      expect(page1).toHaveLength(10);
      expect(page1[0].id).toBe('user-0');
      expect(page2[0].id).toBe('user-10');
      expect(totalCount).toBe(100);
    });
  });

  describe('Time-based Mocking', () => {
    it('⏰ deve simular operações dependentes de tempo', async () => {
      // 🎯 Arrange - Mock do Date.now()
      const mockDate = new Date('2024-01-15T10:00:00Z');
      vi.setSystemTime(mockDate);

      // Mock de operação que depende do tempo
      const mockOperation = vi.fn().mockImplementation(() => {
        const now = Date.now();
        return {
          timestamp: now,
          expired: now > new Date('2024-01-15T09:00:00Z').getTime(),
        };
      });

      // 🎬 Act
      const result1 = mockOperation();
      
      // Avançar tempo
      vi.advanceTimersByTime(2 * 60 * 60 * 1000); // 2 horas
      const result2 = mockOperation();

      // ✅ Assert
      expect(result1.expired).toBe(true);
      expect(result2.timestamp).toBeGreaterThan(result1.timestamp);
      
      // 🧹 Cleanup
      vi.useRealTimers();
    });

    it('🔄 deve simular retry com backoff', async () => {
      // 🎯 Arrange
      let attemptCount = 0;
      const mockApiCall = vi.fn().mockImplementation(async () => {
        attemptCount++;
        if (attemptCount < 3) {
          throw new Error('Temporary failure');
        }
        return { success: true, attempt: attemptCount };
      });

      // Função com retry
      const retryOperation = async (maxAttempts = 3) => {
        for (let i = 0; i < maxAttempts; i++) {
          try {
            return await mockApiCall();
          } catch (error) {
            if (i === maxAttempts - 1) throw error;
            await new Promise(resolve => setTimeout(resolve, 100 * (i + 1)));
          }
        }
      };

      // 🎬 Act
      const result = await retryOperation();

      // ✅ Assert
      expect(result.success).toBe(true);
      expect(result.attempt).toBe(3);
      expect(mockApiCall).toHaveBeenCalledTimes(3);
    });
  });

  describe('Async Mock Patterns', () => {
    it('🌊 deve simular streams de dados', async () => {
      // 🎯 Arrange
      const mockEventEmitter = {
        events: [] as any[],
        emit(event: string, data: any) {
          this.events.push({ event, data, timestamp: Date.now() });
        },
        on: vi.fn(),
        off: vi.fn(),
      };

      const mockStream = {
        async *getData() {
          yield { id: 1, data: 'first chunk' };
          yield { id: 2, data: 'second chunk' };
          yield { id: 3, data: 'final chunk' };
        }
      };

      // 🎬 Act
      const results = [];
      for await (const chunk of mockStream.getData()) {
        results.push(chunk);
        mockEventEmitter.emit('data', chunk);
      }

      // ✅ Assert
      expect(results).toHaveLength(3);
      expect(mockEventEmitter.events).toHaveLength(3);
      expect(mockEventEmitter.events[0].data.id).toBe(1);
    });

    it('🔄 deve simular operações concorrentes', async () => {
      // 🎯 Arrange
      const mockConcurrentOperation = vi.fn()
        .mockImplementationOnce(async () => {
          await new Promise(resolve => setTimeout(resolve, 100));
          return { result: 'operation-1', duration: 100 };
        })
        .mockImplementationOnce(async () => {
          await new Promise(resolve => setTimeout(resolve, 50));
          return { result: 'operation-2', duration: 50 };
        })
        .mockImplementationOnce(async () => {
          await new Promise(resolve => setTimeout(resolve, 200));
          return { result: 'operation-3', duration: 200 };
        });

      // 🎬 Act
      const start = Date.now();
      const results = await Promise.all([
        mockConcurrentOperation(),
        mockConcurrentOperation(),
        mockConcurrentOperation(),
      ]);
      const totalDuration = Date.now() - start;

      // ✅ Assert
      expect(results).toHaveLength(3);
      expect(totalDuration).toBeLessThan(250); // Rodou em paralelo
      expect(mockConcurrentOperation).toHaveBeenCalledTimes(3);
    });
  });
});

💉 Dependency Injection para Testes

src/lib/di-container.ts
// 📁 src/lib/di-container.ts
import { PrismaClient } from '@prisma/client';
import Redis from 'ioredis';
import { EmailService } from './email-service';
import { Logger } from './logger';

// 🎯 Container de dependências
export class DIContainer {
  private dependencies = new Map<string, any>();
  
  register<T>(key: string, dependency: T): void {
    this.dependencies.set(key, dependency);
  }
  
  resolve<T>(key: string): T {
    const dependency = this.dependencies.get(key);
    if (!dependency) {
      throw new Error(`Dependency '${key}' not found`);
    }
    return dependency;
  }
  
  clear(): void {
    this.dependencies.clear();
  }
}

// 🔧 Factory para criar container de produção
export function createProductionContainer(): DIContainer {
  const container = new DIContainer();
  
  container.register('prisma', new PrismaClient());
  container.register('redis', new Redis(process.env.REDIS_URL));
  container.register('emailService', new EmailService());
  container.register('logger', new Logger());
  
  return container;
}

// 🧪 Factory para criar container de testes
export function createTestContainer(): DIContainer {
  const container = new DIContainer();
  
  // Registrar mocks ao invés de dependências reais
  container.register('prisma', mockDeep<PrismaClient>());
  container.register('redis', mockDeep<Redis>());
  container.register('emailService', {
    sendEmail: vi.fn().mockResolvedValue({ messageId: 'test' }),
    sendBulkEmail: vi.fn().mockResolvedValue({ sent: 1, failed: 0 }),
  });
  container.register('logger', {
    info: vi.fn(),
    error: vi.fn(),
    warn: vi.fn(),
    debug: vi.fn(),
  });
  
  return container;
}

// 📁 src/server/services/user-service.ts
import { DIContainer } from '@/lib/di-container';
import { TRPCError } from '@trpc/server';

export class UserService {
  constructor(private container: DIContainer) {}
  
  async createUser(data: {
    email: string;
    name: string;
    organizationId?: string;
  }) {
    const prisma = this.container.resolve('prisma');
    const emailService = this.container.resolve('emailService');
    const logger = this.container.resolve('logger');
    
    try {
      // 🔄 Verificar se email já existe
      const existingUser = await prisma.user.findUnique({
        where: { email: data.email },
      });
      
      if (existingUser) {
        throw new TRPCError({
          code: 'CONFLICT',
          message: 'Email já está em uso',
        });
      }
      
      // 🔧 Criar usuário
      const user = await prisma.user.create({
        data: {
          email: data.email,
          name: data.name,
          organizationId: data.organizationId,
        },
      });
      
      // 📧 Enviar email de boas-vindas
      await emailService.sendEmail(
        user.email,
        'Bem-vindo!',
        'Sua conta foi criada com sucesso.'
      );
      
      logger.info('User created', { userId: user.id });
      
      return user;
    } catch (error) {
      logger.error('Error creating user', error);
      throw error;
    }
  }
  
  async updateUser(userId: string, data: Partial<{
    name: string;
    email: string;
  }>) {
    const prisma = this.container.resolve('prisma');
    const logger = this.container.resolve('logger');
    
    try {
      const user = await prisma.user.update({
        where: { id: userId },
        data,
      });
      
      logger.info('User updated', { userId });
      
      return user;
    } catch (error) {
      logger.error('Error updating user', error);
      throw error;
    }
  }
}

// 📁 src/test/services/user-service.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { UserService } from '@/server/services/user-service';
import { createTestContainer } from '@/lib/di-container';
import { TRPCError } from '@trpc/server';

describe('UserService with DI', () => {
  let userService: UserService;
  let container: DIContainer;
  let prisma: any;
  let emailService: any;
  let logger: any;

  beforeEach(() => {
    // 🔧 Setup do container de teste
    container = createTestContainer();
    userService = new UserService(container);
    
    // 🎯 Obter referências dos mocks
    prisma = container.resolve('prisma');
    emailService = container.resolve('emailService');
    logger = container.resolve('logger');
  });

  describe('createUser', () => {
    it('✅ deve criar usuário com sucesso', async () => {
      // 🎯 Arrange
      const userData = {
        email: 'test@example.com',
        name: 'Test User',
        organizationId: 'org-123',
      };
      
      const mockUser = { id: 'user-123', ...userData };
      
      prisma.user.findUnique.mockResolvedValue(null); // Email não existe
      prisma.user.create.mockResolvedValue(mockUser);
      emailService.sendEmail.mockResolvedValue({ messageId: 'email-123' });

      // 🎬 Act
      const result = await userService.createUser(userData);

      // ✅ Assert
      expect(result).toEqual(mockUser);
      expect(prisma.user.findUnique).toHaveBeenCalledWith({
        where: { email: userData.email },
      });
      expect(prisma.user.create).toHaveBeenCalledWith({
        data: userData,
      });
      expect(emailService.sendEmail).toHaveBeenCalledWith(
        userData.email,
        'Bem-vindo!',
        'Sua conta foi criada com sucesso.'
      );
      expect(logger.info).toHaveBeenCalledWith(
        'User created',
        { userId: 'user-123' }
      );
    });

    it('🚫 deve falhar se email já existir', async () => {
      // 🎯 Arrange
      const userData = {
        email: 'existing@example.com',
        name: 'Test User',
      };
      
      prisma.user.findUnique.mockResolvedValue({ id: 'existing-user' });

      // 🎬 Act & Assert
      await expect(userService.createUser(userData)).rejects.toThrow(
        new TRPCError({
          code: 'CONFLICT',
          message: 'Email já está em uso',
        })
      );

      expect(prisma.user.create).not.toHaveBeenCalled();
      expect(emailService.sendEmail).not.toHaveBeenCalled();
    });

    it('📧 deve lidar com falha no envio de email', async () => {
      // 🎯 Arrange
      const userData = {
        email: 'test@example.com',
        name: 'Test User',
      };
      
      const mockUser = { id: 'user-123', ...userData };
      
      prisma.user.findUnique.mockResolvedValue(null);
      prisma.user.create.mockResolvedValue(mockUser);
      emailService.sendEmail.mockRejectedValue(new Error('Email service down'));

      // 🎬 Act & Assert
      await expect(userService.createUser(userData)).rejects.toThrow('Email service down');
      
      expect(logger.error).toHaveBeenCalledWith(
        'Error creating user',
        expect.any(Error)
      );
    });
  });
});

🌐 API Mocking com MSW

src/test/mocks/msw-handlers.ts
// 📁 src/test/mocks/msw-handlers.ts
import { rest } from 'msw';
import { createTRPCMsw } from 'msw-trpc';
import { appRouter } from '@/server/trpc/router';

// 🎯 Criar MSW handlers para tRPC
const trpcMsw = createTRPCMsw(appRouter);

export const trpcHandlers = [
  // 👤 User handlers
  trpcMsw.user.getProfile.query((req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.data({
        id: 'test-user-id',
        name: 'Test User',
        email: 'test@example.com',
        createdAt: new Date(),
      })
    );
  }),

  trpcMsw.user.updateProfile.mutation((req, res, ctx) => {
    const { name, email } = req.body;
    return res(
      ctx.status(200),
      ctx.data({
        id: 'test-user-id',
        name: name || 'Test User',
        email: email || 'test@example.com',
        updatedAt: new Date(),
      })
    );
  }),

  // 🏢 Organization handlers
  trpcMsw.organization.getAnalytics.query((req, res, ctx) => {
    const { period } = req.query;
    
    const baseMetrics = {
      totalUsers: 100,
      activeUsers: 75,
      apiCalls: 10000,
    };
    
    // Simular dados diferentes baseado no período
    const multiplier = period === '30d' ? 1 : period === '7d' ? 0.3 : 0.1;
    
    return res(
      ctx.status(200),
      ctx.data({
        ...baseMetrics,
        apiCalls: Math.floor(baseMetrics.apiCalls * multiplier),
        period,
        charts: {
          userGrowth: Array.from({ length: 7 }, (_, i) => ({
            date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(),
            value: Math.floor(baseMetrics.totalUsers * (1 + Math.random() * 0.1)),
          })),
        },
      })
    );
  }),
];

// 🌐 REST API handlers para serviços externos
export const restHandlers = [
  // 📧 Email service
  rest.post('https://api.emailservice.com/send', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        messageId: 'mock-message-id',
        status: 'sent',
        deliveredAt: new Date().toISOString(),
      })
    );
  }),

  // 💳 Payment service
  rest.post('https://api.stripe.com/v1/charges', (req, res, ctx) => {
    const amount = req.body?.amount;
    
    if (amount && amount > 100000) { // Simular falha para valores altos
      return res(
        ctx.status(402),
        ctx.json({
          error: {
            type: 'card_error',
            code: 'card_declined',
            message: 'Your card was declined.',
          },
        })
      );
    }
    
    return res(
      ctx.status(200),
      ctx.json({
        id: 'ch_mock_charge_id',
        amount: amount || 2000,
        currency: 'usd',
        status: 'succeeded',
        paid: true,
        created: Math.floor(Date.now() / 1000),
      })
    );
  }),

  // 📊 Analytics service
  rest.get('https://api.analytics.com/events', (req, res, ctx) => {
    const startDate = req.url.searchParams.get('start_date');
    const endDate = req.url.searchParams.get('end_date');
    
    return res(
      ctx.status(200),
      ctx.json({
        events: Array.from({ length: 50 }, (_, i) => ({
          id: `event-${i}`,
          type: ['page_view', 'click', 'form_submit'][i % 3],
          timestamp: new Date(Date.now() - i * 60000).toISOString(),
          userId: `user-${Math.floor(i / 10)}`,
          properties: {
            page: '/dashboard',
            browser: 'Chrome',
          },
        })),
        total: 50,
        startDate,
        endDate,
      })
    );
  }),
];

// 📁 src/test/setup-msw.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { trpcHandlers, restHandlers } from './mocks/msw-handlers';

// 🔧 Setup do MSW server
const server = setupServer(...trpcHandlers, ...restHandlers);

// 🚀 Iniciar server antes de todos os testes
beforeAll(() => {
  server.listen({ onUnhandledRequest: 'error' });
});

// 🧹 Reset handlers após cada teste
afterEach(() => {
  server.resetHandlers();
});

// 🔥 Fechar server após todos os testes
afterAll(() => {
  server.close();
});

// 📁 src/test/integration/msw-integration.test.ts
import { describe, it, expect } from 'vitest';
import { rest } from 'msw';
import { server } from '../setup-msw';
import { createTestCallerWithContext } from '@/test/helpers/trpc-test-utils';

describe('MSW Integration Tests', () => {
  describe('Dynamic Handler Override', () => {
    it('🔄 deve permitir override de handlers durante teste', async () => {
      // 🎯 Arrange - Override handler para simular erro
      server.use(
        rest.post('https://api.emailservice.com/send', (req, res, ctx) => {
          return res(
            ctx.status(500),
            ctx.json({ error: 'Service temporarily unavailable' })
          );
        })
      );

      const caller = createTestCallerWithContext();

      // 🎬 Act & Assert
      await expect(
        caller.user.createWithEmail({
          email: 'test@example.com',
          name: 'Test User',
        })
      ).rejects.toThrow('Failed to send welcome email');
    });

    it('📊 deve simular diferentes respostas baseado em parâmetros', async () => {
      // 🎯 Arrange
      const caller = createTestCallerWithContext();

      // 🎬 Act - Diferentes períodos devem retornar dados diferentes
      const analytics30d = await caller.organization.getAnalytics({ period: '30d' });
      const analytics7d = await caller.organization.getAnalytics({ period: '7d' });

      // ✅ Assert
      expect(analytics30d.apiCalls).toBeGreaterThan(analytics7d.apiCalls);
      expect(analytics30d.period).toBe('30d');
      expect(analytics7d.period).toBe('7d');
    });

    it('🌊 deve simular network delays', async () => {
      // 🎯 Arrange - Simular delay de 500ms
      server.use(
        rest.get('https://api.analytics.com/events', (req, res, ctx) => {
          return res(
            ctx.delay(500),
            ctx.status(200),
            ctx.json({ events: [], total: 0 })
          );
        })
      );

      const caller = createTestCallerWithContext();

      // 🎬 Act
      const start = Date.now();
      await caller.analytics.getExternalEvents();
      const duration = Date.now() - start;

      // ✅ Assert
      expect(duration).toBeGreaterThanOrEqual(500);
    });
  });
});

💡 Melhores Práticas para Mocking

Mock o Mínimo:Mocke apenas as dependências necessárias, não toda a aplicação.

Comportamento Realista:Mocks devem simular o comportamento real, incluindo erros.

Reset Consistente:Sempre limpe mocks entre testes para evitar interferências.

Verificação de Chamadas:Verifique não apenas o retorno, mas como as dependências foram chamadas.