Nivel avanzado11 min de lectura · Publicado el

Kata Stubs y Spies de Carlos Blé: dobles de prueba en Java

Stubs y spies: un contestador que reproduce la misma respuesta a UserFinder (stub) y un registro que apunta lo que manda Reporter (spy). Pregunta: stub. Ordena: spyrubentejera.com stubUserFinderresponde igualspyReporterapunta lo recibido
El stub es el contestador: cuando UserFinder pregunta, reproduce siempre la misma respuesta. El spy es el registro de llamadas: apunta lo que Reporter le mandó, para comprobarlo después.

Quieres probar una clase, pero esa clase habla con otras: una base de datos, un servicio que escribe ficheros, una API. Si usas las de verdad, el test es lento, frágil o directamente imposible de montar. La salida es sustituirlas en el test por piezas de mentira que tú controlas. A esas piezas se las llama dobles de prueba (test doubles).

El nombre viene del cine. Cuando una escena es peligrosa para el actor, entra un doble de acción: no tiene que actuar como el protagonista, solo parecerlo desde la cámara. Un doble de prueba igual: no hace todo lo que hace la pieza real, solo ofrece la misma interfaz para que tu código «crea» que habla con la de verdad.

La kata: dos clases, dos dobles

La kata la publicó Carlos Blé en julio de 2018 en la rama stubs_spies de carlosble/java-katas. El commit se titula «test driving collaboration with stubs and spies»: guiar con tests (TDD) cómo colaboran varias clases. Si aún no conoces la técnica, empieza por qué es TDD.

El problema tiene dos piezas:

Clase Qué hace De quién depende Doble que practica
UserFinder Busca los usuarios que tienen un perfil UserRepository, que devuelve todos los usuarios Stub
Reporter Genera un informe de los usuarios de un perfil UserFinder y ReportWriter, que escribe el informe Spy

Fíjate en la diferencia. UserFinder pide datos a su dependencia: necesitas que le respondan. Reporter manda algo a su dependencia: necesitas saber qué le mandó. Esa diferencia es la que decide qué doble usar.

Si tu código pregunta, usa un stub. Si tu código ordena, usa un spy.

La frase es una simplificación de este artículo, no de Fowler ni de Meszaros, pero sirve de brújula para la mayoría de casos.

Los dobles de prueba de esta kata

La kata practica dos de los cinco dobles de prueba que describió Gerard Meszaros: el stub, que le da respuestas preparadas a tu código (UserFinder le pregunta a UserRepository), y el spy, que apunta lo que tu código le manda para comprobarlo después (Reporter le ordena a ReportWriter). Más abajo verás también un fake, para comparar. Los cinco tipos (dummy, stub, spy, mock y fake), la diferencia entre mock y stub y los estilos clásico y mockista, con ejemplos ejecutados en JavaScript y en Java, están en mocks, stubs y dobles de prueba: qué son y cuándo usarlos.

Solución con un stub: UserFinder

El repositorio es una interfaz con un método:

public interface UserRepository {
    List<User> findAll();
}

UserFinder filtra los usuarios que devuelve el repositorio:

public class UserFinder {
    private final UserRepository repository;

    public UserFinder(UserRepository repository) {
        this.repository = repository;
    }

    public List<User> findByProfile(String profile) {
        return repository.findAll().stream()
                .filter(user -> user.hasProfile(profile))
                .toList();
    }
}

Para probarlo no necesitas una base de datos. Te basta un stub hecho a mano, una clase de tres líneas que devuelve los usuarios que tú le digas:

static class UserRepositoryStub implements UserRepository {
    private List<User> users = List.of();

    void returns(User... users) {
        this.users = List.of(users);
    }

    @Override
    public List<User> findAll() {
        return users;
    }
}

@Test
void find_users_with_the_given_profile() {
    var repository = new UserRepositoryStub();
    repository.returns(admin, guest);
    var finder = new UserFinder(repository);

    assertThat(finder.findByProfile("admin")).containsExactly(admin);
}

El test comprueba el estado: qué devuelve findByProfile. Al stub no le pregunta nada.

Como la interfaz tiene un solo método, en Java moderno el stub cabe en una línea con una lambda:

UserRepository repository = () -> List.of(admin);

El mismo stub con Mockito

Mockito te ahorra escribir la clase. when(...).thenReturn(...) prepara la respuesta:

@Test
void find_users_with_a_mockito_stub() {
    UserRepository repository = mock(UserRepository.class);
    when(repository.findAll()).thenReturn(List.of(admin, guest));
    var finder = new UserFinder(repository);

    assertThat(finder.findByProfile("guest")).containsExactly(guest);
}

Y un fake, para comparar

Un fake no devuelve respuestas fijas: tiene lógica de verdad. Aquí, un repositorio que guarda los usuarios en una lista en memoria en lugar de en una base de datos:

static class InMemoryUserRepository implements UserRepository {
    private final List<User> users = new ArrayList<>();

    void save(User user) {
        users.add(user);
    }

    @Override
    public List<User> findAll() {
        return List.copyOf(users);
    }
}

Compensa cuando muchos tests necesitan guardar y leer datos: escribes el fake una vez y te olvidas de preparar respuestas en cada test.

Solución con un spy: Reporter

Reporter busca usuarios y le pasa el informe al escritor. No devuelve nada:

public class Reporter {
    private final UserFinder finder;
    private final ReportWriter writer;

    public Reporter(UserFinder finder, ReportWriter writer) {
        this.finder = finder;
        this.writer = writer;
    }

    public void generateReportBy(String profile) {
        writer.write(new UserReport(profile, finder.findByProfile(profile)));
    }
}

Si el método no devuelve nada, ¿qué compruebas? Lo que le llegó al escritor. Para eso está el spy: un escritor de mentira que se guarda el informe que recibe.

static class ReportWriterSpy implements ReportWriter {
    UserReport writtenReport;

    @Override
    public void write(UserReport report) {
        this.writtenReport = report;
    }
}

@Test
void ask_the_writer_to_write_the_report() {
    UserRepository repository = () -> List.of(admin);   // stub en una línea
    var writer = new ReportWriterSpy();
    var reporter = new Reporter(new UserFinder(repository), writer);

    reporter.generateReportBy("admin");

    assertThat(writer.writtenReport)
            .isEqualTo(new UserReport("admin", List.of(admin)));
}

User y UserReport son record, así que Java genera equals comparando campo a campo y basta un isEqualTo.

El mismo spy con Mockito

Con Mockito, verify comprueba después de ejecutar que el escritor recibió exactamente ese informe:

@Test
void ask_the_writer_to_write_the_report_with_mockito() {
    UserFinder finder = mock(UserFinder.class);
    when(finder.findByProfile("admin")).thenReturn(List.of(admin));
    ReportWriter writer = mock(ReportWriter.class);
    var reporter = new Reporter(finder, writer);

    reporter.generateReportBy("admin");

    verify(writer).write(new UserReport("admin", List.of(admin)));
}

Si Reporter se olvida de llamar al escritor, el test falla con un mensaje claro:

Wanted but not invoked:

Qué cambia respecto a 2018

La rama original usa Java 8, JUnit 4.8.1, AssertJ 2.1.0 y mockito-all 1.10.19. La kata de este artículo está al día:

En 2018 Hoy Por qué
JUnit 4 (@Before, org.junit.Test) JUnit 6 (org.junit.jupiter.api.Test) JUnit Jupiter es el modelo de programación actual de JUnit
mockito-all 1.10.19 mockito-core 5.24.0 mockito-all se dejó de publicar con Mockito 2
isEqualToComparingFieldByField record + isEqualTo El método de AssertJ está obsoleto; los record ya comparan campo a campo
UserFinder miraba solo el primer usuario Filtra todos con stream().filter() La kata se quedó a medias; ahora el test con dos usuarios lo cubre
ArgumentCaptor para mirar el informe verify(writer).write(informeEsperado) Con equals bien definido, el captor sobra

Dos detalles del pom.xml que te ahorrarán un rato:

  • Los tests *Should no se ejecutan solos. Por defecto Maven Surefire solo ejecuta clases que se llamen Test*, *Test, *Tests o *TestCase. Carlos nombra los tests UserFinderShould, así que sin configurar nada mvn test termina en BUILD SUCCESS sin haber ejecutado ni uno. Se ha comprobado al ejecutar la kata: hay que añadir el patrón.
  • Mockito y Java 21. Desde Java 21 el JDK restringe que una librería cargue un agente en su propia JVM, y la documentación de Mockito pide declararlo como agente en Surefire.
<properties>
  <maven.compiler.release>21</maven.compiler.release>
  <argLine/>
</properties>

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.junit</groupId>
      <artifactId>junit-bom</artifactId>
      <version>6.1.3</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.27.7</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.24.0</version>
    <scope>test</scope>
  </dependency>
</dependencies>

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-dependency-plugin</artifactId>
      <version>3.11.0</version>
      <executions>
        <execution>
          <goals><goal>properties</goal></goals>
        </execution>
      </executions>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.6.1</version>
      <configuration>
        <!-- Mockito como agente (Java 21+) -->
        <argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
        <!-- Ejecuta también las clases *Should -->
        <includes>
          <include>**/*Should.java</include>
        </includes>
      </configuration>
    </plugin>
  </plugins>
</build>

Cuándo no usar dobles

Los dobles tienen un precio: el test sabe cómo está hecho tu código por dentro. Si verificas cada llamada, cualquier refactor rompe tests aunque el comportamiento siga igual.

  • Si la dependencia es rápida y no tiene efectos secundarios (un cálculo, un objeto de valor), usa la real.
  • Sustituye lo que está en el borde: base de datos, red, ficheros, reloj.
  • Prefiere verificar el estado; deja verify para cuando lo único observable es la llamada, como en Reporter.

Qué pasa cuando un spy verifica llamadas internas y llega un refactor, con los mismos tests reescritos con un fake, lo tienes en tests frágiles: por qué se rompen al refactorizar.

Hasta dónde llevar los dobles es justo lo que separa a la escuela clásica de la mockista de TDD. Lo tienes en Inside-out TDD y outside-in TDD.

Dobles de prueba cuando programas con IA

Un criterio del enfoque IA Limpia: cuando un agente de IA te escribe los tests, revisa los dobles antes que nada. Es fácil acabar con tests que sustituyen todas las dependencias y verifican cada llamada: pasan en verde, pero solo comprueban que el código hace lo que ya hace, y se rompen en cuanto lo refactorizas. Pídele stubs o fakes en los bordes, estado antes que llamadas, y haz fallar cada test una vez para ver que mira lo que debe. Cuando lo que hay detrás del doble es un modelo de IA, un stub que responde siempre lo mismo es lo que permite probar las reglas sin pagar cada llamada: hay un ejemplo completo en un LLM detrás de un adaptador.

Practícala tú

  1. Clona la rama original: git clone --branch stubs_spies https://github.com/carlosble/java-katas.git.
  2. Borra las clases de src/main y deja solo las interfaces.
  3. Escribe primero el stub y el test de UserFinder, y después el código mínimo para que pase.
  4. Repite con el spy y Reporter.
  5. Cambia los dobles hechos a mano por Mockito y compara cuál se lee mejor.

Si quieres más material de Carlos Blé, su libro sobre TDD en español está en Libro TDD Carlos Blé. Si buscas «Código sostenible», su libro sobre código fácil de mantener, la editorial Savvily ofrece una muestra gratuita en su página, no el libro completo.

Fuentes

Comprobado en noviembre de 2026:

Todo el código del artículo se ha ejecutado con Maven 3.10.0 y Eclipse Temurin 21.0.13 (imagen oficial maven:3-eclipse-temurin-21): pasan los 6 tests. También se comprobó que los tests de Reporter fallan si no se llama al escritor y que, sin el patrón *Should, Maven no ejecuta ningún test.