Como mock, stub e fake são diferentes

1. Visão geral

Ao escrever testes unitários, você frequentemente precisa substituir dependências reais como banco de dados, gateway de pagamento ou serviço de email por um objeto “substituto”. Esses objetos são chamados coletivamente de test double. Muitas pessoas chamam tudo de “mock”, mas mock, stub e fake servem três propósitos diferentes.

Este artigo o ajuda a diferenciar esses três tipos através de um exemplo único, saber quando usar cada um e evitar erros que tornam o teste frágil ou sem significado.

2. Exemplo usado ao longo do artigo

2.1. Dependências

Neste artigo, usamos JUnit 5, AssertJ e Mockito. Se você estiver usando Spring Boot 3.x, spring-boot-starter-test já inclui os três. Caso contrário, adicione as seguintes dependências:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-junit-jupiter</artifactId>
        <version>5.14.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

2.2. Código a testar

Temos um OrderService simples. Este serviço cobra o cliente, salva o pedido e depois envia um email de confirmação:

public record Order(String id, String customerEmail, BigDecimal amount) {}

public interface PaymentGateway {
    boolean charge(String customerEmail, BigDecimal amount);
}

public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(String id);
}

public interface EmailSender {
    void send(String to, String subject);
}

public class OrderService {

    private final PaymentGateway paymentGateway;
    private final OrderRepository orderRepository;
    private final EmailSender emailSender;

    public OrderService(PaymentGateway paymentGateway,
                        OrderRepository orderRepository,
                        EmailSender emailSender) {
        this.paymentGateway = paymentGateway;
        this.orderRepository = orderRepository;
        this.emailSender = emailSender;
    }

    public boolean placeOrder(Order order) {
        if (!paymentGateway.charge(order.customerEmail(), order.amount())) {
            return false;
        }
        orderRepository.save(order);
        emailSender.send(order.customerEmail(), "Pedido " + order.id() + " foi confirmado");
        return true;
    }
}

OrderService tem três dependências. Cada dependência é adequada para um tipo diferente de test double:

  • PaymentGateway fornece dados de entrada (resultado do pagamento), então é adequado para stub.
  • EmailSender é um efeito colateral que precisa ser verificado (o email foi enviado?), então é adequado para mock.
  • OrderRepository tem comportamento de persistência real, então é adequado para fake.

3. Stub: retorna uma resposta pré-construída

Stub é um objeto que retorna um valor fixo para as chamadas. Stub não se importa quantas vezes é chamado. Você também não faz assert sobre o stub. O único propósito do stub é colocar o código sob teste no branch de lógica correto que você quer verificar.

3.1. Stub escrito manualmente

A forma mais simples é implementar a interface você mesmo:

class DecliningPaymentGateway implements PaymentGateway {
    @Override
    public boolean charge(String customerEmail, BigDecimal amount) {
        return false;
    }
}

Esta classe sempre rejeita o pagamento. Usamos para verificar o branch “pagamento rejeitado”:

class OrderServiceStubTest {

    @Test
    void givenDeclinedPayment_whenPlaceOrder_thenReturnFalse() {
        OrderService service = new OrderService(
                new DecliningPaymentGateway(),
                mock(OrderRepository.class),
                mock(EmailSender.class));

        boolean result = service.placeOrder(
                new Order("ORD-1", "an@example.com", new BigDecimal("150000")));

        assertThat(result).isFalse();
    }
}

Neste teste:

  • DecliningPaymentGateway é um stub. Força placeOrder() a entrar no branch return false.
  • A asserção está no resultado retornado pelo serviço, não no stub.
  • mock(OrderRepository.class) e mock(EmailSender.class) aqui apenas fazem papel de dummy. São objetos passados apenas para satisfazer os parâmetros do constructor, não são usados.

3.2. Stub com Mockito

Escrever uma classe para cada cenário rapidamente se torna verboso. Mockito permite criar stubs diretamente no teste:

@Test
void givenApprovedPayment_whenPlaceOrder_thenReturnTrue() {
    PaymentGateway paymentGateway = mock(PaymentGateway.class);
    when(paymentGateway.charge(anyString(), any(BigDecimal.class))).thenReturn(true);

    OrderService service = new OrderService(
            paymentGateway, mock(OrderRepository.class), mock(EmailSender.class));

    assertThat(service.placeOrder(
            new Order("ORD-2", "binh@example.com", new BigDecimal("99000"))))
            .isTrue();
}

Embora criado com a função mock(), paymentGateway aqui ainda é um stub em termos de função: apenas configuramos o valor de retorno com when(...).thenReturn(...) e nunca o verify. O nome da função da biblioteca não determina o tipo de test double. Como você usa o objeto é o que realmente importa.

4. Mock: verificação de interações

Mock é um objeto que registra as chamadas para ele, para que você possa depois verificar se o código chamou o método correto, com os parâmetros corretos, o número correto de vezes. Diferente de stub, a asserção fica sobre o próprio mock.

Mock é útil quando o comportamento a verificar é um efeito colateral que não retorna nada, como enviar email, publicar um evento ou registrar auditoria.

@ExtendWith(MockitoExtension.class)
class OrderServiceMockTest {

    @Mock
    PaymentGateway paymentGateway;

    @Mock
    OrderRepository orderRepository;

    @Mock
    EmailSender emailSender;

    @InjectMocks
    OrderService orderService;

    @Test
    void givenApprovedPayment_whenPlaceOrder_thenSendConfirmationEmail() {
        when(paymentGateway.charge("an@example.com", new BigDecimal("150000")))
                .thenReturn(true);

        orderService.placeOrder(new Order("ORD-1", "an@example.com", new BigDecimal("150000")));

        verify(emailSender).send("an@example.com", "Pedido ORD-1 foi confirmado");
    }

    @Test
    void givenDeclinedPayment_whenPlaceOrder_thenNoEmailSent() {
        when(paymentGateway.charge(anyString(), any(BigDecimal.class))).thenReturn(false);

        orderService.placeOrder(new Order("ORD-1", "an@example.com", new BigDecimal("150000")));

        verifyNoInteractions(emailSender, orderRepository);
    }
}

Analisando cada parte:

  • @ExtendWith(MockitoExtension.class) ativa Mockito para JUnit 5. Os campos @Mock são inicializados antes de cada teste.
  • @InjectMocks cria OrderService e injeta os três objetos @Mock através do constructor.
  • paymentGateway ainda é um stub, enquanto emailSender é o verdadeiro mock porque fazemos verify nele.
  • verify(emailSender).send(...) verifica se send() foi chamado exatamente uma vez com os dois parâmetros corretos.
  • verifyNoInteractions(...) afirma que quando o pagamento falha, o sistema não salva o pedido e não envia email.

Se você acidentalmente deletar a linha emailSender.send(...) em OrderService, o primeiro teste falhará com uma mensagem similar:

Wanted but not invoked:
emailSender.send(
    "an@example.com",
    "Pedido ORD-1 foi confirmado"
);
-> at OrderServiceMockTest.givenApprovedPayment_whenPlaceOrder_thenSendConfirmationEmail

Actually, there were zero interactions with this mock.

Nota: Você não deve nem stub nem verify a mesma chamada, como when(paymentGateway.charge(...)) e depois verify(paymentGateway).charge(...). Se o stub não for chamado, o serviço não retornou true e o teste falhará em outro lugar. O comando verify neste caso é redundante e apenas acopla mais o teste à implementação.

5. Fake: implementação real mas simplificada

Fake é uma implementação que funciona de verdade mas usa atalhos inadequados para produção. Um exemplo típico é um repositório que armazena dados em um HashMap em vez de usar um banco de dados:

public class InMemoryOrderRepository implements OrderRepository {

    private final Map<String, Order> storage = new HashMap<>();

    @Override
    public void save(Order order) {
        storage.put(order.id(), order);
    }

    @Override
    public Optional<Order> findById(String id) {
        return Optional.ofNullable(storage.get(id));
    }
}

Diferente de stub, fake tem lógica e estado. Chamar save() e depois findById() retornará o pedido que foi salvo. Assim, você verifica o estado final em vez de verificar chamadas:

class OrderServiceFakeTest {

    private final InMemoryOrderRepository orderRepository = new InMemoryOrderRepository();

    private final OrderService orderService = new OrderService(
            (email, amount) -> true,      // stub em forma de lambda
            orderRepository,               // fake
            (to, subject) -> { });         // dummy em forma de lambda

    @Test
    void givenApprovedPayment_whenPlaceOrder_thenOrderIsPersisted() {
        Order order = new Order("ORD-3", "chi@example.com", new BigDecimal("250000"));

        orderService.placeOrder(order);

        assertThat(orderRepository.findById("ORD-3")).contains(order);
    }
}

Alguns pontos a notar:

  • PaymentGateway e EmailSender têm apenas um método abstrato, então criamos stub e dummy usando lambda sem precisar de Mockito.
  • O teste não sabe quantas vezes OrderService chama save() ou em que ordem. O teste apenas se importa se após fazer o pedido, o pedido está armazenado.
  • Se mais tarde você refatorar OrderService para usar um método diferente, como saveAll(), o teste usando fake ainda funcionará desde que o resultado final esteja correto. O teste usando verify(orderRepository).save(order) quebrará.

Nota: Fake é código, então fake também pode ter bugs. Se fake se comportar diferente da implementação real (por exemplo, não lançar exceção em caso de chave duplicada), o teste pode passar enquanto em produção falha. Para fakes importantes, escreva um conjunto de contract tests compartilhados que rodem tanto no fake quanto na implementação real (por exemplo, a versão JPA rodando com Testcontainers).

6. Comparação e como escolher

A tabela abaixo resume as diferenças:

Critério Stub Mock Fake
Propósito Fornecer dados de entrada Verificar interações Substituir comportamento real de forma mais leve
Tem lógica? Não, retorna valor fixo Não, apenas registra chamadas Sim, funciona como a versão real
Onde fazer assert No resultado do código sob teste No próprio mock (verify) No estado do fake
Tipo de teste Baseado em estado Baseado em interações Baseado em estado
Resistência a refatoração Alta Mais baixa Alta

Além dos três tipos acima, você ainda encontrará dois outros:

  • Dummy: objeto passado apenas para satisfazer parâmetros, nunca realmente usado.
  • Spy: envolve um objeto real, ainda executa a lógica real mas registra chamadas para verify. No Mockito, você cria um spy com spy(...).

Uma regra simples de seleção:

  1. Dependência que retorna dados para seu código, use stub.
  2. Dependência que é um efeito colateral externo (email, message queue, API de terceiros) e não há forma de observar o estado, use mock.
  3. Dependência que tem estado e é usada em muitos testes (repositório, cache, relógio), considere escrever um fake uma vez e reutilizá-lo em vários lugares.

Em geral, você deve priorizar verificações de estado (stub, fake) sobre verificações de interação (mock). Testes baseados em estado descrevem o resultado, enquanto testes baseados em interação descrevem como fazer, e como fazer muda com mais frequência.

7. Erros comuns

Mockar tudo. Se um teste tem cinco comandos when e quatro comandos verify, esse teste está copiando a implementação em vez de verificar o comportamento. Basta mudar a ordem de duas linhas de código e o teste ficará vermelho mesmo que o resultado ainda esteja correto.

Mockar value objects. Não mocke Order, BigDecimal ou LocalDate. Crie instâncias reais. Mockito 5 pode mockar classes final e records por padrão, mas só porque você pode fazer algo não significa que deva.

Mockar tipos que você não possui. Mockar diretamente RestTemplate ou SDK de um fornecedor faz o teste assumir comportamentos incorretos sobre a biblioteca. Envolva a biblioteca atrás de uma interface sua (como PaymentGateway) e depois mocke a interface. A implementação real é verificada com testes de integração.

Stubs desnecessários. Com MockitoExtension, Mockito usa strict stubs por padrão. Se você criar um stub para uma chamada que o código nunca executa, o teste falhará:

org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Stubbings desnecessários detectados.
Código de teste limpo e mantível requer zero código desnecessário.

Não desative este aviso apressadamente com lenient(). Esta mensagem geralmente indica que o teste não está entrando no branch que você pensa, ou que a configuração está desatualizada.

Usar fake no lugar de integration test. InMemoryOrderRepository não consegue verificar SQL, constraints ou transações. Fake torna testes unitários rápidos, mas você ainda precisa de alguns testes de integração com banco de dados real.

8. Conclusão

Stub, mock e fake são todos test doubles mas respondem a três perguntas diferentes. Stub responde “o que a dependência retorna?”. Mock responde “o código chama a dependência corretamente?”. Fake responde “se houvesse uma versão que funciona de verdade mas de forma mais leve, qual seria o resultado final?”.

Escolha o tipo de test double baseado no papel da dependência, não na ferramenta: um objeto criado com mock() pode perfeitamente ser apenas um stub. Priorize verificações de estado e use verify apenas para efeitos colaterais que realmente precisam ser verificados. Seus testes serão mais curtos, mais fáceis de ler e quebrarão menos durante refatoração.

testingmockitojunit5unit-testjava