Mocks e stubs: simulando dependências externas em testes

1. Fundamentos: O Problema das Dependências Externas

1.1. Por que dependências externas tornam testes lentos e frágeis

Dependências externas — APIs REST, bancos de dados, filas de mensagens, serviços de e-mail — são o calcanhar de Aquiles dos testes automatizados. Uma chamada HTTP real pode levar centenas de milissegundos; uma consulta ao banco de dados, dezenas. Multiplique por centenas de testes e seu pipeline de CI/CD se arrasta por minutos. Pior: se o serviço externo estiver fora do ar, seus testes quebram mesmo sem você ter alterado uma linha de código. Testes frágeis minam a confiança da equipe.

1.2. O conceito de isolamento em testes

Testes de unidade exigem isolamento total: a classe sob teste deve ser executada como uma ilha, sem tocar em infraestrutura real. Já testes de integração verificam a comunicação entre componentes, incluindo sistemas externos controlados (como bancos em memória). Mocks e stubs são as ferramentas que criam esse isolamento artificial, substituindo dependências reais por versões controladas.

1.3. Diferença prática entre stubs, mocks, fakes e dummies

  • Dummy: objeto passado como argumento, mas nunca usado (ex.: null ou objeto vazio).
  • Fake: implementação funcional simplificada (ex.: banco de dados em memória).
  • Stub: fornece respostas prontas para chamadas feitas durante o teste.
  • Mock: verifica se métodos específicos foram chamados com os argumentos esperados.

A linha entre stub e mock é sutil: stubs focam em estado (o que retornam), mocks focam em comportamento (como foram chamados).

2. Stubs: Simulando Respostas Fixas

2.1. Como criar stubs

Stubs são ideais quando você precisa que uma dependência retorne dados previsíveis. Não verificam interações — apenas fornecem respostas.

2.2. Exemplo: stub de repositório

// Stub de repositório de usuários
class UserRepositoryStub implements UserRepository {
    private final List<User> users;

    UserRepositoryStub(List<User> users) {
        this.users = users;
    }

    @Override
    public User findById(String id) {
        return users.stream()
            .filter(u -> u.getId().equals(id))
            .findFirst()
            .orElse(null);
    }
}

// Teste usando o stub
@Test
void shouldReturnUserWhenExists() {
    UserRepositoryStub stub = new UserRepositoryStub(
        List.of(new User("1", "Alice"))
    );
    UserService service = new UserService(stub);

    User result = service.getUser("1");

    assertEquals("Alice", result.getName());
}

2.3. Quando usar stubs

Use stubs quando o teste precisa apenas dos dados retornados, não de verificar como a dependência foi chamada. Evite stubs para lógicas complexas — nesse caso, prefira fakes ou mocks.

3. Mocks: Verificando Interações e Comportamento

3.1. A diferença essencial

Mocks registram expectativas: "o método X deve ser chamado com argumentos Y". Se a chamada não ocorrer, o teste falha. Isso é crucial para verificar efeitos colaterais, como envio de e-mails ou registro em logs.

3.2. Exemplo: mock de serviço de e-mail

// Mock de serviço de notificação
EmailService mockEmail = mock(EmailService.class);
NotificationService notification = new NotificationService(mockEmail);

notification.sendWelcomeEmail("user@example.com");

verify(mockEmail).send(
    "user@example.com",
    "Bem-vindo!",
    "Seu cadastro foi concluído."
);

3.3. Armadilhas comuns

Mocks excessivos criam testes frágeis — qualquer mudança na implementação quebre o teste, mesmo que o comportamento externo permaneça correto. A regra de ouro: mocke apenas o que você possui (sua própria interface), nunca bibliotecas terceiras.

4. Ferramentas e Frameworks Populares

4.1. Visão geral

  • Mockito (Java): framework maduro com anotações @Mock e @InjectMocks.
  • unittest.mock (Python): patch e MagicMock para substituir objetos dinamicamente.
  • Jest mock (JavaScript): jest.fn() e jest.mock() para módulos inteiros.

4.2. Exemplo: mock com retorno condicional

# Python com unittest.mock
from unittest.mock import Mock

api_client = Mock()
api_client.fetch_data.side_effect = [
    {"status": "ok", "data": [1, 2, 3]},
    {"status": "error", "message": "timeout"}
]

# Primeira chamada
result1 = api_client.fetch_data()  # retorna {"status": "ok", ...}

# Segunda chamada
result2 = api_client.fetch_data()  # retorna {"status": "error", ...}

4.3. Spy vs. mock

Spies envolvem objetos reais e permitem verificar chamadas sem substituir a implementação. Use spies quando quiser testar o comportamento real, mas ainda monitorar interações.

5. Estratégias Avançadas de Simulação

5.1. WireMock e Testcontainers

Para simular serviços HTTP completos, WireMock cria servidores stub que respondem com headers, delays e erros específicos. Testcontainers sobe bancos de dados reais em containers Docker para testes de integração.

5.2. Simulação de falhas

Testar cenários de erro é essencial:

// Mock de serviço que simula timeout
when(apiClient.call()).thenThrow(new TimeoutException("Serviço indisponível"));

// Teste verifica tratamento de erro
assertThrows(GatewayException.class, () -> service.process());

5.3. Dependências encadeadas

Mock de mock (ex.: servicoA.getRepositorio().buscar()) é um anti-padrão. Prefira injeção de dependência direta: passe o repositório mockado diretamente, sem encadeamento.

6. Boas Práticas e Anti-Padrões

6.1. Não mocke o que você não possui

Bibliotecas terceiras (ex.: AWS SDK, Stripe) devem ser encapsuladas atrás de suas próprias interfaces. Mocke sua interface, não a biblioteca.

6.2. Evite mocks de objetos de valor

Entidades simples (User, Product) não precisam ser mockadas — use objetos reais com dados de teste.

6.3. Factory methods para mocks limpos

// Factory method para criar mocks configurados
private EmailService createEmailMock(boolean shouldFail) {
    EmailService mock = mock(EmailService.class);
    if (shouldFail) {
        doThrow(new SendException()).when(mock).send(any());
    }
    return mock;
}

7. Mocks no Contexto de TDD e Testes de Integração

7.1. Mocks no ciclo TDD

No TDD, mocks permitem escrever o teste antes da implementação: defina o comportamento esperado, implemente até o teste passar, depois refatore.

7.2. Transição de mocks para testes reais

Comece com mocks para validar a lógica. Gradualmente, adicione testes de integração com containers reais para verificar o comportamento completo.

7.3. Exemplo comparativo

// Teste unitário com mock
@Test
void testWithMock() {
    Database mockDb = mock(Database.class);
    when(mockDb.find("1")).thenReturn(new User("1", "Alice"));

    User result = new UserService(mockDb).getUser("1");
    assertEquals("Alice", result.getName());
}

// Teste de integração com container
@Test
void testWithRealDb() {
    try (PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")) {
        DataSource ds = createDataSource(postgres);
        UserService service = new UserService(new RealUserRepository(ds));
        service.createUser(new User("1", "Alice"));

        User result = service.getUser("1");
        assertEquals("Alice", result.getName());
    }
}

8. Conclusão e Checklist Final

8.1. Resumo das diferenças

  • Stub: você pergunta "o que retornar?" — foco em estado.
  • Mock: você pergunta "foi chamado?" — foco em comportamento.
  • Fake: implementação simplificada, mas funcional.

8.2. Checklist para decidir

Cenário Ferramenta
Preciso de dados fixos Stub
Preciso verificar chamadas Mock
Preciso de lógica real simplificada Fake
Teste de integração completo Container real

8.3. Referência rápida

Mocks e stubs são aliados poderosos quando usados com moderação. Lembre-se: o objetivo não é testar a implementação, mas o comportamento observável. Use stubs para dados, mocks para interações, e sempre prefira testes reais quando o custo for aceitável.

Referências