Cómo difieren mock, stub y fake
1. Descripción general
Al escribir pruebas unitarias, a menudo tienes que reemplazar dependencias reales como bases de datos, pasarelas de pago o servicios de correo electrónico con un objeto “sustituto”. Estos objetos se conocen colectivamente como test double. Muchas personas los llaman todos “mock”, pero mock, stub y fake sirven tres propósitos diferentes.
Este artículo te ayuda a distinguir estos tres tipos a través del mismo ejemplo, saber cuándo usar cada uno y evitar errores que hagan que las pruebas sean frágiles o sin sentido.
2. Ejemplo que se utilizará en todo el artículo
2.1. Dependencias
En este artículo, usamos JUnit 5, AssertJ y Mockito. Si usas Spring Boot 3.x, spring-boot-starter-test ya incluye los tres. Si no, añade las siguientes dependencias:
<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 probar
Tenemos un OrderService simple. Este servicio cobra dinero, guarda el pedido y luego envía un correo de confirmación:
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() + " ha sido confirmado");
return true;
}
}
OrderService tiene tres dependencias. Cada dependencia es adecuada para un tipo diferente de test double:
PaymentGatewayproporciona datos de entrada (resultado del pago), así que es adecuado para stub.EmailSenderes un efecto secundario que necesita verificación (¿se envía el correo?), así que es adecuado para mock.OrderRepositorytiene comportamiento de almacenamiento real, así que es adecuado para fake.
3. Stub: retorna respuestas preconstruidas
Stub es un objeto que retorna valores fijos para las llamadas. A stub no le importa cuántas veces sea llamado. Tampoco haces aserciones sobre el stub. El único propósito del stub es llevar el código que estás probando a la rama lógica correcta que quieres verificar.
3.1. Stub escrito manualmente
La forma más simple es implementar la interfaz tú mismo:
class DecliningPaymentGateway implements PaymentGateway {
@Override
public boolean charge(String customerEmail, BigDecimal amount) {
return false;
}
}
Esta clase siempre rechaza el pago. La usamos para verificar la rama “pago fallido”:
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();
}
}
En esta prueba:
DecliningPaymentGatewayes un stub. Fuerza aplaceOrder()a entrar en la ramareturn false.- La aserción está en el valor de retorno del servicio, no en el stub.
mock(OrderRepository.class)ymock(EmailSender.class)aquí solo juegan el rol de dummy. Son objetos que se pasan para completar los parámetros del constructor y no se usan.
3.2. Stub con Mockito
Escribir manualmente una clase para cada escenario rápidamente se vuelve engorroso. Mockito permite crear stubs directamente en la prueba:
@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();
}
Aunque se crea con la función mock(), paymentGateway aquí sigue siendo un stub en términos de rol: solo configuramos el valor de retorno con when(...).thenReturn(...) y nunca lo verify. El nombre de la función de la biblioteca no determina el tipo de test double. Cómo uses el objeto es lo que lo determina.
4. Mock: verificar interacciones
Mock es un objeto que registra las llamadas que se le hacen, para que luego puedas verificar si el código llamó al método correcto, con los parámetros correctos y el número correcto de veces. A diferencia del stub, la aserción está en el mock mismo.
Mock es útil cuando el comportamiento a verificar es un efecto secundario que no devuelve nada, como enviar correo, publicar eventos o registrar auditoría.
@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 ha sido 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);
}
}
Analicemos cada parte:
@ExtendWith(MockitoExtension.class)activa Mockito para JUnit 5. Los campos@Mockse inicializan antes de cada prueba.@InjectMockscreaOrderServicee inyecta los tres objetos@Mocka través del constructor.paymentGatewaysigue siendo un stub, mientras queemailSenderes ahora el mock real porque lo verificamos (verify).verify(emailSender).send(...)verifica quesend()fue llamado exactamente una vez con los dos parámetros correctos.verifyNoInteractions(...)afirma que cuando el pago falla, el sistema no guarda el pedido ni envía correo.
Si accidentalmente eliminas la línea emailSender.send(...) en OrderService, la primera prueba fallará con un mensaje similar:
Se esperaba pero no fue invocado:
emailSender.send(
"an@example.com",
"Pedido ORD-1 ha sido confirmado"
);
-> at OrderServiceMockTest.givenApprovedPayment_whenPlaceOrder_thenSendConfirmationEmail
En realidad, no hubo interacciones con este mock.
Nota: No debes hacer stub y verify de la misma llamada, por ejemplo
when(paymentGateway.charge(...))y luegoverify(paymentGateway).charge(...). Si el stub no se llama, el servicio no habría devueltotruey la prueba fallaría en otro lugar. El comandoverifyaquí es innecesario y solo hace que la prueba dependa más fuertemente de la implementación.
5. Fake: implementación real pero simplificada
Fake es una implementación que funciona realmente pero usa atajos que no son apropiados para production. Un ejemplo típico es un repositorio que almacena datos en HashMap en lugar de en una base de datos:
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));
}
}
A diferencia del stub, fake tiene lógica y estado. Llamar a save() y luego a findById() devuelve el pedido que acabas de guardar. Por lo tanto, verificas el estado final en lugar de verificar las llamadas:
class OrderServiceFakeTest {
private final InMemoryOrderRepository orderRepository = new InMemoryOrderRepository();
private final OrderService orderService = new OrderService(
(email, amount) -> true, // stub como lambda
orderRepository, // fake
(to, subject) -> { }); // dummy como 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);
}
}
Algunos puntos a notar:
PaymentGatewayyEmailSendertienen solo un método abstracto cada uno, así que creamos stub y dummy usando lambdas sin necesidad de Mockito.- La prueba no sabe cuántas veces
OrderServicellama asave()ni en qué orden. La prueba solo se preocupa de que después de hacer un pedido, el pedido esté en el almacén. - Si luego refactorizas
OrderServicepara usar un método diferente, por ejemplosaveAll(), la prueba con fake seguirá funcionando mientras el resultado final sea correcto. Una prueba que useverify(orderRepository).save(order)se romperá.
Nota: Fake es código, así que fake también puede tener bugs. Si fake se comporta diferente a la implementación real (por ejemplo, no lanza error en caso de clave duplicada), la prueba podría pasar mientras que production falla. Para fakes importantes, escribe un conjunto de contract tests compartido, que se ejecute en tanto el fake como la implementación real (por ejemplo, la versión JPA ejecutándose con Testcontainers).
6. Comparación y cómo elegir
La tabla de abajo resume las diferencias:
| Criterio | Stub | Mock | Fake |
|---|---|---|---|
| Propósito | Proporcionar datos de entrada | Verificar interacciones | Reemplazar el comportamiento real de forma más ligera |
| ¿Tiene lógica? | No, retorna valores fijos | No, solo registra las llamadas | Sí, funciona como la versión real |
| ¿Dónde está la aserción? | En el resultado del código que se prueba | En el mock mismo (verify) |
En el estado del fake |
| Tipo de prueba | State-based | Interaction-based | State-based |
| Resiliencia al refactorizar | Alta | Más baja | Alta |
Además de estos tres tipos, también encontrarás dos tipos más:
- Dummy: un objeto que se pasa para completar los parámetros y nunca se usa.
- Spy: envuelve un objeto real, sigue ejecutando la lógica real pero registra las llamadas para
verify. En Mockito, creas un spy usandospy(...).
Una regla simple para elegir:
- Si la dependencia devuelve datos a tu código, usa stub.
- Si la dependencia es un efecto secundario externo (correo, message queue, API de terceros) y no hay forma de observar el estado, usa mock.
- Si la dependencia tiene estado y se usa en varias pruebas (repositorio, caché, reloj), considera escribir un fake una vez y reutilizarlo en muchos lugares.
En general, debes priorizar verificar estado (stub, fake) sobre verificar interacciones (mock). Las pruebas basadas en estado describen el resultado, mientras que las pruebas basadas en interacciones describen la forma, y la forma cambia con más frecuencia.
7. Errores comunes
Mockear todo. Si una prueba tiene cinco comandos when y cuatro comandos verify, esa prueba está copiando la implementación en lugar de verificar el comportamiento. Solo necesitas cambiar el orden de dos líneas de código y la prueba se pondrá roja aunque el resultado sea correcto.
Mockear value objects. No mockees Order, BigDecimal o LocalDate. Crea instancias reales. Mockito 5 por defecto puede mockear incluso clases final y records, pero poder hacerlo no significa que debas hacerlo.
Mockear tipos que no posees. Mockear directamente RestTemplate o un SDK de proveedor hace que la prueba asuma incorrectamente el comportamiento de la biblioteca. En su lugar, envuelve la biblioteca detrás de una interfaz tuya (como PaymentGateway) y luego mockea esa interfaz. Verifica la implementación real con una prueba de integración.
Stubs innecesarios. Con MockitoExtension, Mockito por defecto usa strict stubs. Si haces stub de una llamada que el código nunca ejecuta, la prueba fallará:
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Stubings innecesarios detectados.
El código de prueba limpio y mantenible requiere cero código innecesario.
No desactives rápidamente esta advertencia con lenient(). Este mensaje generalmente indica que la prueba no entra en la rama que esperabas, o que la configuración está desactualizada.
Usar fake en lugar de integration tests. InMemoryOrderRepository no puede verificar SQL, restricciones o transacciones. Fake ayuda a que las pruebas unitarias sean rápidas, pero aún necesitas algunas pruebas de integración con la base de datos real.
8. Conclusión
Stub, mock y fake son todos test doubles pero responden tres preguntas diferentes. Stub responde “¿qué devuelve la dependencia?”. Mock responde “¿llama el código la dependencia de forma correcta?”. Fake responde “¿cuál es el resultado final si hubiera una versión real pero ligera?”.
Elige el tipo de test double según el rol de la dependencia, no por la herramienta: un objeto creado con mock() puede ser perfectamente solo un stub. Prioriza verificar estado, usa verify solo para efectos secundarios que realmente necesiten verificación. Tus pruebas serán más cortas, más legibles y menos propensas a romperse cuando refactorices.