Hay una frase que escucho seguido de los equipos de desarrollo: “lo probé manualmente y funciona”. Esa frase es exactamente el problema. Funciona hoy, con los datos de hoy, en el ambiente de hoy. La semana que viene alguien cambia el Handler de creación de pedidos, nadie lo nota hasta que un cliente reporta que sus pedidos desaparecen.
Aprendí esto de mi esposo de la forma más ilustrativa: me mostró una pantalla llena de tests en verde y me explicó que eso significa que el equipo puede cambiar lo que quiera sin miedo. Los tests son la red de seguridad. Para código reactivo con Mono y Flux, esa red se llama StepVerifier.
Este es el tercer artículo de la serie de microservicios en Java: el que completa el trío junto con el de Vertical Slicing y el de Arquitectura Hexagonal con Keycloak que subí hace un momento. Si quieres escribir código que no le dé miedo cambiar, este es el artículo.
La pirámide de tests para código reactivo
En cualquier aplicación hay tres niveles de tests, y en código reactivo cada uno tiene sus herramientas específicas:
[RestTestClient + TestContainers] ← integración real: lento, máximo valor
[@WebFluxTest + WebTestClient] ← slice: rápido, sin base de datos
[StepVerifier] ← unit: rapidísimo, la base de todo
Los tres son necesarios. No se reemplazan entre sí.
Nivel 1: StepVerifier — el test unitario del código reactivo
StepVerifier es la herramienta de Reactor para verificar el comportamiento de un Mono o un Flux sin levantar nada — sin servidor, sin base de datos, sin contexto de Spring.
La analogía: es como revisar una receta paso a paso en papel antes de cocinar. No ensucias la cocina, pero sabes exactamente qué debería pasar.
class CrearPedidoHandlerTest {
private PedidoRepository repository = mock(PedidoRepository.class);
private CrearPedidoHandler handler = new CrearPedidoHandler(repository);
@Test
void ejecutar_conCodigoNuevo_retornaPedido() {
when(repository.existsByCodigo("P001")).thenReturn(Mono.just(false));
when(repository.save(any())).thenReturn(Mono.just(pedidoGuardado()));
StepVerifier.create(handler.ejecutar(requestValido()))
.expectNextMatches(r -> r.id() != null && "P001".equals(r.codigo()))
.verifyComplete(); // siempre terminar con esto
}
@Test
void ejecutar_conCodigoDuplicado_lanzaExcepcion() {
when(repository.existsByCodigo("P001")).thenReturn(Mono.just(true));
StepVerifier.create(handler.ejecutar(requestConCodigo("P001")))
.expectError(PedidoDuplicadoException.class)
.verify();
}
@Test
void ejecutar_cuandoBDFalla_propagaError() {
when(repository.existsByCodigo(any()))
.thenReturn(Mono.error(new RuntimeException("Conexión perdida")));
StepVerifier.create(handler.ejecutar(requestValido()))
.expectErrorMessage("Conexión perdida")
.verify();
}
}
Tres tests, tres escenarios: el camino feliz, la validación de negocio y el fallo de infraestructura. Esa cobertura mínima es el estándar para cualquier Handler.
La regla más importante de StepVerifier: nunca uses .block() para obtener resultados en un test. Es la trampa más común — funciona, pero desactiva exactamente lo que quieres probar.
Tests para Flux
Cuando el método retorna múltiples elementos:
@Test
void listarPedidos_retornaTodosLosElementos() {
when(repository.findAll()).thenReturn(Flux.just(p1(), p2(), p3()));
StepVerifier.create(handler.listarTodos())
.expectNextCount(3)
.verifyComplete();
}
@Test
void listarPedidos_cuandoNoHayNada_completaSinError() {
when(repository.findAll()).thenReturn(Flux.empty());
StepVerifier.create(handler.listarTodos())
.verifyComplete(); // vacío y sin error es comportamiento válido
}
Tests con tiempo virtual — para timeouts y delays
@Test
void ejecutar_cuandoBDDemasiado_lanzaTimeout() {
when(repository.existsByCodigo(any()))
.thenReturn(Mono.delay(Duration.ofSeconds(10)).then(Mono.just(false)));
StepVerifier.withVirtualTime(() ->
handler.ejecutar(requestValido()).timeout(Duration.ofSeconds(5))
)
.thenAwait(Duration.ofSeconds(6))
.expectError(TimeoutException.class)
.verify();
}
withVirtualTime mueve el reloj sin esperar tiempo real — el test termina en milisegundos aunque simules 10 segundos.
Nivel 2: @WebFluxTest — el test del Controller sin base de datos
@WebFluxTest levanta solo el contexto de Spring relevante para un Controller específico. Sin base de datos, sin llamadas externas. El Handler y el Repository se reemplazan con mocks.
@WebFluxTest(CrearPedidoController.class)
@Import(SecurityConfig.class)
class CrearPedidoControllerTest {
@Autowired WebTestClient webTestClient;
@MockBean CrearPedidoHandler handler;
@Test
@WithMockUser(roles = "ADMIN")
void post_conBodyValido_retorna201() {
when(handler.ejecutar(any()))
.thenReturn(Mono.just(responseEjemplo()));
webTestClient
.post().uri("/api/v1/pedidos")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestValido())
.exchange()
.expectStatus().isCreated()
.expectBody()
.jsonPath("$.id").isNotEmpty()
.jsonPath("$.codigo").isEqualTo("P001");
}
@Test
void post_sinToken_retorna401() {
webTestClient.post().uri("/api/v1/pedidos")
.bodyValue(requestValido())
.exchange()
.expectStatus().isUnauthorized();
}
@Test
@WithMockUser(roles = "USER")
void post_conRolInsuficiente_retorna403() {
webTestClient.post().uri("/api/v1/pedidos")
.bodyValue(requestValido())
.exchange()
.expectStatus().isForbidden();
}
@Test
@WithMockUser(roles = "ADMIN")
void post_sinCamposObligatorios_retorna400() {
var invalido = new CrearPedidoRequest(null, null, List.of());
webTestClient.post().uri("/api/v1/pedidos")
.bodyValue(invalido)
.exchange()
.expectStatus().isBadRequest();
}
}
Cuatro tests mínimos por Controller: 401, 403, caso feliz, validación de inputs. Con eso cubres los escenarios que más afectan al usuario.
Nivel 3: RestTestClient — el nuevo de Spring Boot 4.1
RestTestClient es una incorporación de Spring Boot 4.1 que simplifica los tests de integración completos. La API es muy similar a WebTestClient, pero está diseñado específicamente para levantarte todo el contexto y conectarte contra el servidor real corriendo en un puerto aleatorio.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class CrearPedidoIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.r2dbc.url",
() -> "r2dbc:postgresql://localhost:" + postgres.getMappedPort(5432) + "/test");
registry.add("spring.r2dbc.username", postgres::getUsername);
registry.add("spring.r2dbc.password", postgres::getPassword);
}
@Autowired RestTestClient restTestClient; // inyectado por Boot 4.1
@Test
@WithMockUser(roles = "ADMIN")
void flujoCompleto_crearYConsultar() {
// Crear el pedido
var response = restTestClient
.post().uri("/api/v1/pedidos")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new CrearPedidoRequest("P001", UUID.randomUUID(), items()))
.exchange()
.expectStatus().isCreated()
.expectBody(CrearPedidoResponse.class)
.returnResult()
.getResponseBody();
assertThat(response).isNotNull();
assertThat(response.id()).isNotNull();
// Consultar el mismo pedido
restTestClient
.get().uri("/api/v1/pedidos/{id}", response.id())
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.codigo").isEqualTo("P001");
}
}
Este test levanta la aplicación completa, usa PostgreSQL real en Docker y verifica el flujo de extremo a extremo. Tarda más que los anteriores pero es el que más confianza da antes de hacer un deploy.
TestContainers 2.0: cambios importantes
La versión 2.0 de TestContainers trae mejoras de API. Los más relevantes para el día a día:
Las versiones de imágenes se deben especificar explícitamente — "postgres:17" en lugar de "postgres:latest". Evita sorpresas con actualizaciones automáticas en CI.
@ServiceConnection es una alternativa a @DynamicPropertySource para datasources soportados — Boot detecta el contenedor y configura la conexión automáticamente. Menos boilerplate:
@Container
@ServiceConnection // Boot auto-configura la URL de R2DBC
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
// Ya no necesitas @DynamicPropertySource para este caso
El ciclo completo que garantiza calidad
Un slice de código reactivo bien cubierto tiene esta estructura de tests:
Handler unit test (StepVerifier):
✓ Caso feliz
✓ Error de negocio (duplicado, inválido, no encontrado)
✓ Error de infraestructura (BD caída, timeout)
Controller slice test (@WebFluxTest + WebTestClient):
✓ Sin token → 401
✓ Rol insuficiente → 403
✓ Happy path → 201/200
✓ Body inválido → 400
Integration test (@SpringBootTest + RestTestClient + TestContainers):
✓ Flujo completo de extremo a extremo
Eso es, según aprendí de la práctica real, la red de seguridad mínima que te permite cambiar código con confianza.
¿Ya escribes tests en tu proyecto actual, o los pospones para “cuando haya tiempo”? Cuéntame en los comentarios — esa respuesta dice mucho sobre la salud del equipo. ⚙️💜
Comentarios