Saltar al contenido principal

Mocks no son Stubs: la diferencia que cambia cómo testeas

11 min lectura
Guía práctica sobre test doubles en Spring Boot: cuándo usar mocks, stubs, fakes o spies, y por qué confundirlos genera tests frágiles e inútiles.
Escuchar artículo

Llevas años escribiendo @Mock en tus tests de Spring Boot y asumiendo que eso es “mockear”. Luego alguien en code review te dice “eso es un stub, no un mock” y la conversación se convierte en semántica sin fin.

Pero no es semántica. La diferencia importa, y afecta directamente a la calidad, fragilidad y utilidad de tus tests.

Este artículo está basado en el clásico de Martin Fowler: Mocks Aren’t Stubs. Lo traigo al mundo real con ejemplos en Java con Spring Boot y Mockito, los que seguramente usas a diario.


El escenario: un pedido que necesita stock

Vamos a usar un ejemplo concreto a lo largo de todo el artículo. Tienes un servicio OrderService que procesa pedidos. Para completar un pedido, necesita verificar con un WarehouseService que hay stock disponible.

public class OrderService {

    private final WarehouseService warehouseService;

    public OrderService(WarehouseService warehouseService) {
        this.warehouseService = warehouseService;
    }

    public boolean fill(Order order) {
        if (warehouseService.hasInventory(order.getProduct(), order.getQuantity())) {
            warehouseService.remove(order.getProduct(), order.getQuantity());
            order.setFilled(true);
            return true;
        }
        return false;
    }
}

Simple. Ahora, ¿cómo lo testeas?


Los cinco tipos de Test Doubles

Antes de responder, necesitas conocer el vocabulario. Gerard Meszaros acuñó el término Test Double (doble de prueba) para referirse a cualquier objeto que reemplaza a una dependencia real en un test. Y hay cinco tipos:

TipoQué hace
DummySe pasa pero nunca se usa. Rellena parámetros obligatorios.
FakeImplementación funcional pero con atajos (ej: base de datos en memoria).
StubDevuelve respuestas predefinidas. No verifica nada.
SpyComo un stub, pero registra cómo fue llamado.
MockObjeto pre-programado con expectativas. Verifica el comportamiento.

Solo los mocks verifican comportamiento. Los demás son herramientas de soporte.


Verificación de estado vs verificación de comportamiento

Esta es la distinción fundamental. Y es donde se rompe todo cuando no la entiendes.

Verificación de estado (el enfoque clásico)

Ejecutas el código y luego compruebas cómo quedó el mundo. El estado del objeto después de la operación es tu fuente de verdad.

// ✅ Verificación de estado con un Fake
@Test
void orderFilledWhenEnoughStock() {
    // Usamos un Fake: implementación real pero simplificada
    WarehouseService warehouse = new InMemoryWarehouse();
    warehouse.addInventory("TECLADO", 50);

    OrderService service = new OrderService(warehouse);
    Order order = new Order("TECLADO", 10);

    service.fill(order);

    // Verificamos el ESTADO: ¿se llenó el pedido? ¿bajó el stock?
    assertTrue(order.isFilled());
    assertEquals(40, warehouse.getInventory("TECLADO"));
}

No hay mocks aquí. Hay un InMemoryWarehouse (un Fake) que funciona de verdad pero sin base de datos real. Al final compruebas el estado resultante.

Verificación de comportamiento (el enfoque mockista)

No te importa cómo quedó el estado. Te importa qué métodos se llamaron y con qué parámetros.

// ✅ Verificación de comportamiento con un Mock real
@Test
void orderFilledWhenEnoughStock_withMock() {
    WarehouseService warehouseMock = mock(WarehouseService.class);

    // Especificas las expectativas ANTES de ejecutar
    when(warehouseMock.hasInventory("TECLADO", 10)).thenReturn(true);

    OrderService service = new OrderService(warehouseMock);
    Order order = new Order("TECLADO", 10);

    service.fill(order);

    // Verificas que se llamó al método correcto con los parámetros correctos
    verify(warehouseMock).remove("TECLADO", 10);
    assertTrue(order.isFilled());
}

Aquí el mock verifica comportamiento: comprueba que remove() fue invocado. Si OrderService llama a remove() con parámetros distintos, el test falla.


El problema: confundir Stubs con Mocks

Aquí está la trampa en la que cae casi todo el mundo con Mockito.

// ❌ Esto parece un mock pero es un STUB
@Test
void orderNotFilledWhenNoStock() {
    WarehouseService warehouseStub = mock(WarehouseService.class);
    when(warehouseStub.hasInventory("TECLADO", 10)).thenReturn(false);

    OrderService service = new OrderService(warehouseStub);
    Order order = new Order("TECLADO", 10);

    service.fill(order);

    // Solo verificamos estado: el pedido no se completó
    assertFalse(order.isFilled());
    // No verificamos ninguna interacción -> esto es un STUB, no un Mock
}

Usas mock() de Mockito, pero lo estás usando como stub: defines una respuesta y verificas el estado final. No hay verify(). No hay verificación de comportamiento.

No es un problema en sí mismo, pero hay que saber lo que se está haciendo.


Cuándo usar cada uno: guía práctica con Spring Boot

Stubs: cuando solo necesitas controlar respuestas

Perfecto para dependencias externas que necesitas que devuelvan datos específicos.

@ExtendWith(MockitoExtension.class)
class PricingServiceTest {

    @Mock
    private ProductRepository productRepository; // ← Es un STUB aquí

    @InjectMocks
    private PricingService pricingService;

    @Test
    void appliesDiscountForPremiumProduct() {
        // Solo necesitamos que devuelva un producto con cierto precio
        Product product = new Product("LAPTOP", new BigDecimal("1000.00"), true);
        when(productRepository.findById("LAPTOP")).thenReturn(Optional.of(product));

        BigDecimal finalPrice = pricingService.calculatePrice("LAPTOP", "PREMIUM_USER");

        assertEquals(new BigDecimal("900.00"), finalPrice);
        // Sin verify() -> uso como stub, verificación de estado
    }
}

Mocks: cuando el comportamiento ES lo que testeas

Cuando la lógica está en las llamadas que se hacen, no en el estado resultante.

@ExtendWith(MockitoExtension.class)
class NotificationServiceTest {

    @Mock
    private EmailClient emailClient; // ← Mock real: verificaremos interacciones

    @Mock
    private SmsClient smsClient;

    @InjectMocks
    private NotificationService notificationService;

    @Test
    void sendsEmailAndSmsWhenOrderShipped() {
        Order order = new Order("user@example.com", "+34600000000", "PEDIDO-123");

        notificationService.notifyShipped(order);

        // Verificamos COMPORTAMIENTO: ¿se enviaron las notificaciones correctas?
        verify(emailClient).send(
            eq("user@example.com"),
            contains("PEDIDO-123"),
            any()
        );
        verify(smsClient).send(
            eq("+34600000000"),
            contains("enviado")
        );
    }
}

Aquí lo que importa es que se llamen esos métodos. No hay estado que verificar porque send() es void.

Fakes: cuando necesitas algo más realista

Un Fake es una implementación completa pero simplificada. Ideal para repositorios en tests de integración.

// Fake: implementación real con almacenamiento en memoria
public class InMemoryOrderRepository implements OrderRepository {

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

    @Override
    public void save(Order order) {
        store.put(order.getId(), order);
    }

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

    @Override
    public List<Order> findAll() {
        return new ArrayList<>(store.values());
    }
}

// Test usando el Fake
@Test
void processMultipleOrdersInSequence() {
    OrderRepository fakeRepo = new InMemoryOrderRepository();
    OrderService service = new OrderService(fakeRepo, warehouseService);

    service.process(new Order("TECLADO", 5));
    service.process(new Order("RATON", 3));

    List<Order> processed = fakeRepo.findAll();
    assertEquals(2, processed.size());
}

Un Fake da confianza real porque ejercita la lógica completa. El trade-off es que requiere más código para mantener.

Spies: el doble filo

Un Spy envuelve un objeto real y registra las llamadas. Úsalo con mucho cuidado.

@Test
void spyOnRealWarehouse() {
    WarehouseService realWarehouse = new WarehouseServiceImpl();
    realWarehouse.addInventory("MONITOR", 20);

    WarehouseService spy = spy(realWarehouse); // ← Spy sobre objeto real

    OrderService service = new OrderService(spy);
    service.fill(new Order("MONITOR", 5));

    // Verificamos que se llamó remove() en el objeto real
    verify(spy).remove("MONITOR", 5);
    // Y el estado real cambió también
    assertEquals(15, realWarehouse.getInventory("MONITOR"));
}

Ojo con los spies: estás ejecutando código real. Si ese código tiene efectos secundarios (llamadas a APIs, escrituras a disco), los spies pueden convertir un unit test en un test de integración sin querer.


Classical TDD vs Mockist TDD

Martin Fowler describe dos escuelas de pensamiento que llevan años debatiendo esto.

Classical TDD (escuela de Detroit)

  • Usa objetos reales cuando es posible.
  • Solo usa doubles cuando las dependencias son incómodas (base de datos, APIs externas).
  • Los tests verifican el resultado observable del sistema.
// ✅ Classical: objeto real, verificación de estado
@Test
void classicalApproach() {
    Warehouse warehouse = new Warehouse(); // Objeto real
    warehouse.addInventory("TECLADO", 50);

    Order order = new Order("TECLADO", 10);
    OrderService service = new OrderService(warehouse);

    service.fill(order);

    assertTrue(order.isFilled());
    assertEquals(40, warehouse.getInventory("TECLADO"));
}

Ventaja: los tests detectan errores de integración entre clases reales.

Desventaja: un fallo en Warehouse puede romper 20 tests de OrderService. El efecto dominó es real.

Mockist TDD (escuela de Londres)

  • Usa mocks para cualquier colaborador con comportamiento.
  • Los tests especifican exactamente qué interacciones deben ocurrir.
  • Se trabaja “outside-in”: desde la interfaz hacia dentro.
// ✅ Mockist: todo mockeado, verificación de comportamiento
@Test
void mockistApproach() {
    WarehouseService mockWarehouse = mock(WarehouseService.class);
    when(mockWarehouse.hasInventory("TECLADO", 10)).thenReturn(true);

    Order order = new Order("TECLADO", 10);
    OrderService service = new OrderService(mockWarehouse);

    service.fill(order);

    verify(mockWarehouse).remove("TECLADO", 10);
    assertTrue(order.isFilled());
}

Ventaja: aislamiento total. Un fallo apunta exactamente a la clase rota.

Desventaja: los tests se acoplan a la implementación interna. Si refactorizas OrderService para llamar a remove() de otra forma, el test falla aunque el comportamiento externo sea correcto.


Los pros y contras que nadie te cuenta

Pros de los Mocks

  • Aislamiento quirúrgico: cuando el test falla, sabes exactamente qué clase tiene el bug.
  • Simplicidad de setup: no necesitas construir grafos de objetos complejos.
  • Guían el diseño: si un mock es difícil de configurar, suele ser síntoma de que tu clase tiene demasiadas responsabilidades.
  • Testean efectos secundarios: imprescindibles cuando el resultado es una llamada (enviar email, publicar evento).

Contras de los Mocks

  • Acoplamiento a la implementación: el test sabe demasiado sobre cómo funciona el código por dentro.
// ❌ Test frágil: acoplado a detalles internos
@Test
void fragileTest() {
    verify(service).internalHelper("dato"); // Si renombras este método, el test explota
    verify(service, times(2)).buildRequest(any()); // ¿Y si optimizas a una sola llamada?
}
  • Falsa seguridad: puedes tener 100% de cobertura con mocks y aun así tener bugs en integración porque los mocks no verifican que los colaboradores reales se comporten como esperas.
  • Mantenimiento costoso: cada refactor que no cambia el comportamiento externo puede romper decenas de tests.
  • El problema del “over-mocking”: si mockeas todo, ¿qué estás testeando realmente?

Cuándo usar cada enfoque: la regla práctica

SituaciónQué usar
Dependencia externa (API, BD, email)Mock o Stub
Quieres testear que SE LLAMA algoMock con verify()
Solo necesitas que devuelva un datoStub
Test de integración ligeroFake
Objeto complejo con demasiado setupFake o Stub
Repositorios en tests unitariosStub o Fake
Servicios de dominio con lógicaObjeto real (Classical)

Un error común en Spring Boot: mockear lo que no deberías

Con @MockBean en Spring Boot es muy fácil caer en el over-mocking:

// ❌ Over-mocking: mockeas hasta la lógica de dominio
@SpringBootTest
class OrderControllerTest {

    @MockBean
    private OrderService orderService; // ¿Por qué mockear la lógica principal que quieres testear?

    @MockBean
    private PricingService pricingService;

    @MockBean
    private InventoryService inventoryService;

    @Test
    void testOrder() {
        when(orderService.process(any())).thenReturn(OrderResult.success());
        // Este test no prueba nada real del flujo
    }
}
// ✅ Mejor: solo mockeas dependencias externas reales
@SpringBootTest
class OrderControllerTest {

    @MockBean
    private ExternalPaymentGateway paymentGateway; // Esto SÍ debe ser mock

    @Autowired
    private OrderService orderService; // Objeto real: queremos testear su lógica

    @Test
    void processesOrderWithRealLogic() {
        when(paymentGateway.charge(any())).thenReturn(PaymentResult.approved("TX-123"));

        // Ahora sí testeas el flujo completo con lógica real
        OrderResult result = orderService.process(new Order("TECLADO", 5));

        assertTrue(result.isSuccess());
    }
}

Mi opinión: el pragmatismo gana

Fowler termina su artículo como classical TDDer convencido. Yo también me inclino por ese lado, pero con matices.

La verificación de estado da tests más resistentes al refactoring. Cuando cambias la implementación sin cambiar el comportamiento, los tests no se rompen. Eso es salud a largo plazo.

Pero hay casos donde los mocks son la herramienta correcta: cuando el comportamiento observable es la llamada al colaborador. Enviar un email, publicar un evento en Kafka, llamar a una API externa. Ahí los mocks no son una opción, son la única forma sensata de testear.

La regla que funciona para mí: usa objetos reales siempre que puedas, doubles cuando tengas que hacerlo, y mocks solo cuando el comportamiento que testeas sean las interacciones en sí mismas.

Si tu test tiene más líneas de when() y verify() que de lógica real, algo huele mal.


Preguntas frecuentes

¿Es malo usar @Mock de Mockito?

No, el problema no es la anotación sino cómo la usas. @Mock puede crear tanto stubs (si solo usas when()) como mocks reales (si añades verify()). Sé consciente de cuál de los dos estás usando.

¿Cuándo usar @SpyBean en Spring Boot?

Casi nunca. Los spies mezclan comportamiento real y simulado, lo que hace los tests difíciles de razonar. Úsalo solo cuando necesitas sobrescribir un método específico de un bean real que no puedes cambiar.

¿Los Fakes son mejores que los Mocks?

Depende del contexto. Los Fakes dan más confianza pero requieren más mantenimiento. Para repositorios y servicios de infraestructura donde quieres tests de integración ligeros, los Fakes son excelentes. Para dependencias externas que cambian, un Stub o Mock es más manejable.

¿Cómo evito tests frágiles con Mockito?

Evita verificar llamadas a métodos privados o de “helper” internos. Solo verifica las interacciones que son parte del contrato público de tu clase. Si el test rompe al mover código interno sin cambiar el comportamiento externo, el test está mal escrito.

¿Tiene sentido mezclar Classical y Mockist TDD?

Absolutamente. La mayoría de proyectos reales usan un enfoque híbrido: objetos de dominio reales (Classical) con doubles para infraestructura (Mockist). La clave es ser consistente dentro de un mismo test y no mezclar estilos sin razón.

Artículos relacionados

Newsletter

Aprende algo útil cada semana.

Ideas sobre programación, arquitectura e IA para mejorar tu código en menos de 5 minutos de lectura.

Sin spam · Baja cuando quieras