El módulo anterior terminó con una afirmación y una pregunta. La afirmación: la red de Ribalta está protegida. La pregunta: ¿cómo lo sabemos? Sabemos que un ciudadano no puede finalizar el alquiler de otro porque lo hemos probado a mano con curl una tarde de martes, y porque leyendo @PreAuthorize nos parece que dice lo correcto. Eso no es conocimiento: es confianza. Y la confianza se evapora en cuanto alguien reordena una regla de authorizeHttpRequests, sube la versión de una dependencia o toca el SelectorTarifa para añadir una tarifa nueva.
CicloUrbana tiene hoy una aplicación completa —REST, JPA sobre PostgreSQL, Flyway, seguridad con JWT— y exactamente cero pruebas: solo el contextLoads vacío que generó Spring Initializr en la lección 01-04. Este módulo corrige eso. En esta primera lección no escribiremos todavía una suite: construiremos el criterio. Qué se prueba y por qué, qué tipos de prueba existen y qué detecta cada uno, cómo se reparten en una pirámide sana, qué trae ya spring-boot-starter-test sin añadir nada, dónde se colocan los ficheros, cómo se llaman las clases y los métodos, cuál es la anatomía de una prueba bien escrita, cómo se ejecutan, cómo se mide la cobertura con JaCoCo y por qué esa métrica engaña si se convierte en objetivo. Al final tendremos escrita la primera prueba real del proyecto: pequeña, pero verdadera.
Contenido
- Por qué se prueba
- Los tipos de prueba
- La pirámide de pruebas y el cono de helado
- Qué merece la pena probar y qué no
spring-boot-starter-test: la caja de herramientas- Dónde viven las pruebas: estructura y convenciones
- Anatomía de una prueba: Preparar-Actuar-Comprobar
- Ejecutar las pruebas
- Surefire y Failsafe:
*Testfrente a*IT - Cobertura con JaCoCo
- TDD: rojo, verde, refactor
- Los dobles de prueba
- Errores Comunes y Consejos
- Ejercicios
- Por qué se prueba
La respuesta ingenua es «para encontrar errores». Es la menos importante de las cuatro razones reales.
Detectar regresiones
Una regresión es un fallo introducido en algo que antes funcionaba. Es el fallo más caro del ciclo de vida de un programa, porque nadie lo busca: el cambio se probó a mano, el cambio funciona, y lo que se rompió está tres capas más allá.
En CicloUrbana hay decenas de regresiones esperando su turno. Estas son reales y plausibles:
| Cambio inocente | Qué se rompe en silencio |
|---|---|
Añadir TarifaJubilado con @Component sin nombre() |
El SelectorTarifa la registra como TarifaJubilado en vez de jubilado; la tarifa nunca se selecciona |
Reordenar dos reglas en authorizeHttpRequests |
/api/v1/estaciones/** pasa a ser público antes de que la regla restrictiva lo alcance |
Cambiar @Transactional por @Transactional(readOnly = true) en finalizar |
El importe se calcula pero no se guarda; nadie lo nota hasta la facturación mensual |
Renombrar un campo de EstacionResponse |
La aplicación móvil deja de mostrar la capacidad; la API responde 200 |
Añadir una columna en V7 sin tocar la entidad |
ddl-auto: validate sigue pasando, pero un INSERT falla con NOT NULL en producción |
| Subir la versión de Jackson | Un Instant empieza a serializarse como número; los clientes se rompen |
Ninguno de esos cambios produce un error de compilación. Todos los detecta una prueba escrita una sola vez y ejecutada mil veces.
Refactorizar sin miedo
Refactorizar es cambiar la estructura interna sin cambiar el comportamiento observable. La definición contiene una trampa: para saber que el comportamiento no ha cambiado hay que poder comprobarlo. Sin pruebas, «refactorizar» es un eufemismo de «reescribir y rezar», y el resultado práctico es que nadie toca el código feo: se acumula, se rodea y se hereda.
Con una suite decente, el mapeador de 03-05 que consulta repositorios —y que admitíamos que estaba mal diseñado— se puede reescribir en una tarde. Sin ella, se queda ahí para siempre.
Documentación viva
Un comentario miente en cuanto alguien cambia el código y no lo actualiza. Una prueba que miente falla. El nombre de un método de prueba bien escrito es una frase del dominio:
devuelve404CuandoLaEstacionNoExiste rechazaAlquilerCuandoLaBateriaEstaPorDebajoDelUmbral elCiudadanoNoPuedeFinalizarElAlquilerDeOtro aplicaTarifaEstudianteConQuinceMinutosGratuitos
Leídos en el informe de una ejecución, esos nombres son la especificación de CicloUrbana, y es una especificación que se compila y se verifica sola.
Presión de diseño
Esta es la razón menos evidente y la más valiosa. Un código difícil de probar es un código mal diseñado, y la prueba te lo dice antes que ningún revisor.
Si AlquilerService llamara a LocalDateTime.now() directamente, probar «un alquiler de dos horas» exigiría esperar dos horas o manipular el reloj del sistema. Que en la lección 02-01 inyectáramos un Clock no fue elegancia gratuita: fue diseñar para poder probar. Lo mismo vale para la inyección por constructor de 02-02 (permite construir el objeto en una prueba con colaboradores falsos), para la interfaz EstacionRepositorio de 02-01 (permite sustituir la implementación) y para SeguridadAlquileres de 05-05 (una regla de seguridad convertida en un bean normal, comprobable con una prueba unitaria).
- Los tipos de prueba
«Prueba» no es una categoría única. Estas son las que nos interesan, ordenadas de más rápida y barata a más lenta y cara:
| Tipo | Qué verifica | Alcance | Velocidad típica | Coste de escritura | Coste de mantenimiento | Qué detecta que las anteriores no |
|---|---|---|---|---|---|---|
| Unitaria | Una clase o método aislado | Una unidad, colaboradores falsos | 1–10 ms | Bajo | Bajo | Errores de lógica, cálculo y casos límite |
| De componente / rodaja | Una capa con parte del framework | Controlador o repositorio + Spring | 0,1–2 s | Medio | Medio | Fallos de anotaciones, mapeos HTTP, JSON, consultas |
| De integración | Varias capas cooperando de verdad | Contexto completo + base de datos | 1–10 s | Medio-alto | Medio | Fallos de cableado, transacciones, esquema, seguridad |
| De extremo a extremo (E2E) | Un caso de uso completo por HTTP | Aplicación desplegada | 5–60 s | Alto | Alto | Fallos de configuración de entorno y despliegue |
| De contrato | Que dos servicios siguen entendiéndose | La frontera entre dos sistemas | 0,1–2 s | Medio | Medio | Cambios incompatibles en una API publicada |
| De carga / rendimiento | Comportamiento bajo concurrencia y volumen | Sistema completo | Minutos | Alto | Alto | Degradación, fugas, límites del pool, N+1 |
Dos matices que ahorran discusiones estériles:
- La frontera entre unitaria e integración es un continuo, no una línea. Lo que importa no es la etiqueta sino dos propiedades: si la prueba es rápida y si es determinista. Una prueba que tarda 40 ms y no toca red ni disco se comporta como unitaria aunque instancie tres clases reales.
- Las de carga y las de contrato quedan fuera de este módulo. Las de rendimiento aparecerán en 09-01, y las de contrato tienen sentido cuando CicloUrbana se parta en varios servicios (07-05 y 07-06). Aquí construimos las cuatro primeras filas.
- La pirámide de pruebas y el cono de helado
La pirámide de pruebas describe la proporción sana entre tipos: muchas pruebas rápidas y baratas en la base, muy pocas lentas y frágiles en la cima.
flowchart TB
subgraph SANO["Pirámide (sano)"]
direction TB
E1["E2E · pocas · lentas"]
I1["Integración y rodajas · algunas"]
U1["Unitarias · muchas · milisegundos"]
E1 --- I1 --- U1
end
subgraph MALO["Cono de helado (antipatrón)"]
direction TB
E2["E2E y manuales · muchísimas"]
I2["Integración · algunas"]
U2["Unitarias · cuatro"]
E2 --- I2 --- U2
end
La lógica de la forma es económica. Una prueba unitaria cuesta milisegundos, así que se pueden tener miles y ejecutarlas en cada guardado del fichero. Una E2E cuesta segundos y falla a veces sin motivo (la red, un tiempo de espera, un dato residual), así que cada una que añades encarece la suite y erosiona la confianza en ella.
El cono de helado es la pirámide invertida: casi todo se comprueba arrancando la aplicación entera —o peor, a mano— y apenas existen pruebas unitarias. Sus síntomas son inconfundibles:
- La suite tarda 25 minutos, así que nadie la ejecuta en local.
- Hay pruebas que fallan «a veces» y el equipo las reintenta en lugar de arreglarlas: son pruebas escamosas (flaky), y una sola envenena la confianza en todas las demás.
- Cuando algo falla, el mensaje es
Expected 200 but was 500y hay que leer 300 líneas de log para saber qué se ha roto. Una prueba unitaria te dice qué método y qué valor.
La proporción recomendada para CicloUrbana, dado su tamaño y su forma:
| Nivel | Proporción objetivo | Qué se prueba aquí | Presupuesto de tiempo |
|---|---|---|---|
| Unitarias | ~70 % | CalculadoraTarifa y sus tres implementaciones, SelectorTarifa, reglas de EstacionService y AlquilerService, SeguridadAlquileres, mapeadores |
< 5 s en total |
Rodajas (@WebMvcTest, @DataJpaTest) |
~20 % | EstacionController y AlquilerController, consultas del AlquilerRepositorio, serialización de DTOs |
< 30 s |
| Integración con contexto y PostgreSQL real | ~9 % | Flujo alquilar-devolver completo, migraciones Flyway, reglas de seguridad de extremo a extremo | < 2 min |
| E2E sobre el entorno desplegado | ~1 % | Un puñado de caminos críticos: registro, login, alquilar, devolver | Fuera del ciclo de desarrollo |
No son porcentajes que haya que medir con una hoja de cálculo. Son un recordatorio: si escribir una prueba nueva te obliga siempre a levantar el contexto de Spring, el problema no es la prueba, es el diseño de la clase.
- Qué merece la pena probar y qué no
Escribir pruebas cuesta tiempo y mantenerlas cuesta más. Gastarlo bien exige decir que no.
Sí merece la pena probar:
- La lógica de negocio con ramas: el cálculo de tarifas con sus minutos gratuitos, la regla de los anclajes mínimos, la comprobación del umbral de batería, el recargo por superar la duración máxima.
- Los casos límite y los límites exactos: 0 minutos, 1 minuto, exactamente 15 minutos con la tarifa de estudiante, exactamente el umbral de batería, la estación con un anclaje libre y con ninguno.
- Los caminos de error: qué pasa cuando la estación no existe, cuando la bicicleta está en mantenimiento, cuando el alquiler ya está finalizado.
- Las reglas de seguridad, sin excepción. Son las que fallan en silencio y las que más caras salen.
- El contrato público de la API: los códigos de estado, la forma del JSON, los
ProblemDetailde 03-06. - Todo error encontrado en producción: antes de arreglarlo, una prueba que lo reproduzca. Es la regla que impide que el mismo fallo vuelva dos veces.
No merece la pena probar:
| Qué | Por qué no |
|---|---|
Getters, setters y record sin lógica |
No hay comportamiento que verificar; la prueba solo repite el código |
toString, equals autogenerados |
Salvo el equals de una entidad JPA, que sí tiene reglas propias (04-03) y merece prueba |
| Que Spring inyecte un bean | Estarías probando Spring, no CicloUrbana |
Que @GetMapping mapee una ruta trivial sin lógica |
Lo cubre una sola prueba de rodaja del controlador, no una por método |
| Que Flyway aplique migraciones | Con matiz: no probamos Flyway, sino que nuestras migraciones dejan el esquema que las entidades esperan (06-05) |
Configuración trivial (server.port) |
Si falla, no arranca; el arranque ya es la prueba |
| Código de terceros | Si dudas de una librería, la prueba correcta es una prueba de tu integración con ella, no de ella |
La pregunta que resuelve casi todos los casos dudosos: «si esto se rompe, ¿me entero antes de que se entere un ciudadano de Ribalta?» Si la respuesta es «solo con una prueba», escríbela.
spring-boot-starter-test: la caja de herramientas
spring-boot-starter-test: la caja de herramientasSpring Initializr ya añadió esta dependencia en 01-03, y es la única que necesitamos para todo el módulo salvo dos añadidos concretos (seguridad en 06-04 y contenedores en 06-05):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>El <scope>test</scope> es esencial: estas librerías se usan al compilar y ejecutar src/test/java, y no se empaquetan en el JAR de producción. Un assertThat no puede acabar en el artefacto desplegado.
Lo que trae dentro:
| Librería | Para qué sirve | ¿La usaremos? |
|---|---|---|
| JUnit 5 (Jupiter) | El motor de pruebas: @Test, ciclo de vida, parametrización |
Constantemente (06-02) |
| Spring Test y Spring Boot Test | @SpringBootTest, rodajas, MockMvc, caché de contextos |
06-04 y 06-05 |
| AssertJ | Aserciones fluidas: assertThat(x).isEqualTo(y) |
Es el estilo oficial del curso |
| Hamcrest | Aserciones con matchers: assertThat(x, is(y)) |
Solo dentro de jsonPath(...) en MockMvc |
| Mockito 5 | Dobles de prueba: mock, when, verify |
06-03 |
| JSONassert | Comparar dos documentos JSON ignorando el orden | Puntualmente en 06-04 |
| JsonPath | Extraer valores de un JSON con expresiones $.contenido[0].nombre |
En cada prueba de controlador |
| Awaitility | Esperar a que una condición asíncrona se cumpla, sin Thread.sleep |
En 07-03, con @Async |
| XMLUnit | Comparar documentos XML | No: CicloUrbana solo habla JSON |
| JUnit Vintage | Ejecutar pruebas JUnit 4 antiguas | No: el proyecto nace en JUnit 5 |
Por qué no hay que añadir dependencias sueltas. El BOM de Spring Boot fija versiones compatibles entre sí de todas ellas. Si añades org.mockito:mockito-core con una versión propia, acabas con dos Mockito en el classpath y errores de arranque de la extensión que cuesta media mañana diagnosticar. La regla del proyecto: si algo ya está en el starter, no se declara; y si necesitas cambiarle la versión, se hace con <mockito.version> en <properties>, que es la palanca que el BOM ofrece para eso.
Comprueba qué tienes realmente con:
- Dónde viven las pruebas: estructura y convenciones
Maven separa el código de producción del de pruebas en dos árboles paralelos:
ciclourbana/ ├── src/main/java/com/ciclourbana/alquileres/TarifaEstandar.java ├── src/main/resources/application.yml ├── src/test/java/com/ciclourbana/alquileres/TarifaEstandarTest.java └── src/test/resources/application-test.yml
Tres reglas que se siguen sin excepción:
- La prueba vive en el mismo paquete que la clase probada, aunque en otro árbol. Así puede acceder a miembros con visibilidad de paquete sin abrir la clase de producción, y el árbol de pruebas es un espejo navegable del de producción.
src/test/resourcesestá antes quesrc/main/resourcesen el classpath de prueba. Unapplication.ymlcolocado ahí no se suma al de producción: lo sustituye por completo. Es un error clásico; la forma correcta de configurar las pruebas es un perfil (application-test.yml), que veremos en 06-04.- Nada de
src/test/javaacaba en el JAR. Puedes crear ahí las clases de apoyo que quieras.
Convenciones de nombres
| Elemento | Convención | Ejemplo en CicloUrbana |
|---|---|---|
| Clase de prueba unitaria o de rodaja | <ClaseProbada>Test |
TarifaEstandarTest, EstacionControllerTest |
| Clase de prueba de integración | <Caso>IT |
AlquilerFlujoCompletoIT |
| Clase de apoyo (datos, base común) | Nombre descriptivo, sin Test ni IT |
EstacionesDePrueba, PruebaIntegracionBase |
| Método de prueba | Frase en español, descriptiva, sin test delante |
calculaCuatroDiezPorTreintaMinutos |
Sobre los nombres de método, la política del curso merece una justificación. testCalcular1() no dice nada: cuando falla en la integración continua a las tres de la tarde, hay que abrir el fichero. rechazaLaEstacionCuandoLaCapacidadEsMenorQueOcho describe la regla de negocio de Ribalta, en el idioma del proyecto, y el informe de fallos se lee como una lista de requisitos incumplidos. Tres formas válidas, elige una y sé consistente:
// 1. Frase directa (la que usa este curso)
void devuelve404CuandoLaEstacionNoExiste()
// 2. Estructura dado-cuando-entonces explícita
void dadaUnaEstacionLlena_cuandoSeDevuelveUnaBicicleta_entoncesLanzaEstacionLlenaException()
// 3. Frase corta + @DisplayName para el informe (se ve en 06-02)
@DisplayName("Devuelve 404 cuando la estación no existe")
void estacionInexistente()Y una nota que evita un fallo desconcertante: en JUnit 5 las clases y los métodos de prueba no necesitan ser public. La visibilidad de paquete es suficiente y es la convención actual. Lo que sí sigue prohibido es que sean private o static.
- Anatomía de una prueba: Preparar-Actuar-Comprobar
Toda prueba bien escrita tiene tres partes, separadas visualmente. El patrón se conoce como Arrange-Act-Assert (AAA) o Given-When-Then; en este curso lo llamaremos Preparar-Actuar-Comprobar:
| Fase | Qué hace | Regla |
|---|---|---|
| Preparar | Construye el estado y los datos de entrada | Solo lo que esta prueba necesita |
| Actuar | Ejecuta una sola operación: la que se está probando | Una línea, casi siempre |
| Comprobar | Verifica el resultado | Un solo concepto verificado |
La primera prueba real de CicloUrbana. Probamos TarifaEstandar de 02-02: 0,50 € de desbloqueo más 0,12 € por minuto.
package com.ciclourbana.alquileres;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
class TarifaEstandarTest {
@Test
void cobraDesbloqueoMasDoceCentimosPorMinuto() {
// Preparar
TarifaEstandar tarifa = new TarifaEstandar();
Duration duracion = Duration.ofMinutes(30);
// Actuar
BigDecimal importe = tarifa.calcular(duracion);
// Comprobar: 0,50 + (0,12 × 30) = 4,10
assertThat(importe).isEqualByComparingTo("4.10");
}
}Cada línea tiene una decisión detrás:
class TarifaEstandarTest, sinpublic, en el paquetecom.ciclourbana.alquileres, espejo exacto de la clase probada.new TarifaEstandar(): no hay Spring por ninguna parte.TarifaEstandares una clase Java normal que resulta estar anotada con@Component; la anotación no impide instanciarla. Esta prueba se ejecuta en menos de un milisegundo, y esa es exactamente la propiedad que la hace útil.import static ...Assertions.assertThat: elimportestático de AssertJ, presente en todas las pruebas del curso.isEqualByComparingTo("4.10")y noisEqualTo(new BigDecimal("4.10")). ElequalsdeBigDecimalcompara también la escala, de modo que4.1y4.10no son iguales para él aunque valgan lo mismo. Con dinero, siempreisEqualByComparingTo. Es la primera de las trampas de AssertJ y se detalla en 06-02.- El comentario del cálculo esperado:
4.10no es un número mágico si al lado está la operación que lo produce. Quien lea la prueba dentro de un año sabrá si el fallo está en el código o en la expectativa.
Ejecutémosla:
CicloUrbana tiene su primera prueba. Es minúscula, y sin embargo ya afirma algo que antes solo estaba en la cabeza de quien escribió la clase.
Veamos ahora cómo falla, que es lo que de verdad importa. Si alguien cambia POR_MINUTO a 0.15:
El mensaje dice el nombre de la regla incumplida, el valor esperado y el obtenido. La calidad de un mensaje de fallo es una característica de la prueba, no un accidente, y es la razón principal por la que este curso usa AssertJ.
- Ejecutar las pruebas
# Todas las pruebas del proyecto
./mvnw test
# Una clase concreta
./mvnw test -Dtest=TarifaEstandarTest
# Un método concreto
./mvnw test -Dtest=TarifaEstandarTest#cobraDesbloqueoMasDoceCentimosPorMinuto
# Varias clases, con comodines
./mvnw test -Dtest='Tarifa*Test,SelectorTarifaTest'
# Compilar y empaquetar saltándose las pruebas (para depurar el empaquetado)
./mvnw package -DskipTestsUna advertencia sobre esa última línea, porque la diferencia se pregunta en cada revisión de código:
| Opción | Efecto |
|---|---|
-DskipTests |
Compila las pruebas pero no las ejecuta |
-Dmaven.test.skip=true |
Ni siquiera las compila: puede ocultar que el código de prueba ya no compila |
Usa siempre la primera. La segunda deja pasar pruebas rotas sin avisar.
Desde el IDE, el flujo cotidiano es distinto y mejor: el triángulo verde junto al método ejecuta esa prueba en milisegundos, y el atajo de «repetir la última ejecución» (Ctrl+Shift+F10 en IntelliJ, Ctrl+F11 en Eclipse) es el que más se usa mientras se programa. El IDE ejecuta JUnit directamente, sin pasar por Maven: es mucho más rápido, pero no aplica la configuración de Surefire, así que una prueba puede pasar en el IDE y fallar en ./mvnw test si depende de argumentos de JVM o perfiles configurados en el pom.xml. Antes de subir nada, ./mvnw verify.
- Surefire y Failsafe:
*Test frente a *IT
*Test frente a *ITMaven tiene dos plugins de pruebas, y confundirlos es la causa de que muchas suites nunca ejecuten sus pruebas de integración.
| Surefire | Failsafe | |
|---|---|---|
| Fase del ciclo | test |
integration-test y verify |
| Qué ejecuta | *Test, Test*, *Tests, *TestCase |
*IT, IT*, *ITCase |
| Si una prueba falla | Detiene la construcción inmediatamente | Registra el fallo y sigue hasta post-integration-test |
| Cuándo se ejecuta | Antes de package |
Después de package, sobre el artefacto ya construido |
| Uso previsto | Pruebas unitarias y rápidas | Pruebas que necesitan recursos externos |
La diferencia de comportamiento ante un fallo tiene una razón práctica: si una prueba de integración levanta un contenedor de PostgreSQL (06-05), Failsafe debe llegar siempre a la fase que lo apaga; por eso no aborta al primer fallo y deja que verify sea quien rompa la construcción.
Failsafe no viene activado por defecto. Se añade así:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>Y a partir de ahí, la convención de CicloUrbana:
./mvnw test # segundos: unitarias y rodajas. Se ejecuta constantemente.
./mvnw verify # minutos: además, las *IT con PostgreSQL real. Antes de subir y en CI.Por qué separar. Si las pruebas lentas están mezcladas con las rápidas, la suite entera tarda minutos y deja de ejecutarse durante el desarrollo; y cuando una suite deja de ejecutarse, deja de servir. Separarlas permite el ciclo corto —guardar, ./mvnw test, cinco segundos— sin renunciar a la comprobación completa antes de subir el cambio.
- Cobertura con JaCoCo
La cobertura de código mide qué porcentaje del código se ejecuta durante la suite de pruebas. JaCoCo lo instrumenta en tiempo de ejecución y genera un informe HTML. Se configura así:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>preparar-agente</id>
<goals>
<goal>prepare-agent</goal> <!-- engancha el agente antes de las pruebas -->
</goals>
</execution>
<execution>
<id>generar-informe</id>
<phase>verify</phase>
<goals>
<goal>report</goal> <!-- genera el HTML tras las pruebas -->
</goals>
</execution>
</executions>
<configuration>
<excludes>
<!-- Clases sin lógica propia: incluirlas solo diluye el número -->
<exclude>com/ciclourbana/**/dto/**</exclude>
<exclude>com/ciclourbana/CicloUrbanaApplication.class</exclude>
</excludes>
</configuration>
</plugin>El informe colorea cada línea del código fuente: verde si se ejecutó, rojo si no, amarillo si es una rama de la que solo se ha recorrido una salida (un if que siempre se evaluó a cierto). El amarillo es el color más informativo: señala exactamente los casos límite que faltan por probar.
Dos métricas conviven en el informe, y solo una es interesante:
| Métrica | Qué mide | Utilidad |
|---|---|---|
| Cobertura de líneas | Líneas ejecutadas / líneas totales | Baja: una línea con un && cuenta como cubierta habiendo probado un solo caso |
| Cobertura de ramas | Salidas de condición recorridas / totales | Alta: revela los if a medio probar |
La advertencia, que es lo importante de este apartado
La cobertura mide qué código se ha ejecutado, no qué comportamiento se ha verificado. Estas dos cosas no se parecen en nada, y aquí está la demostración:
@Test
void esteTestDaCoberturaTotalYNoVerificaAbsolutamenteNada() {
new TarifaEstandar().calcular(Duration.ofMinutes(30));
new TarifaEstudiante().calcular(Duration.ofMinutes(30));
new TarifaJubilado().calcular(Duration.ofMinutes(30));
// Ni un solo assert.
}JaCoCo informará del 100 % de cobertura de las tres tarifas. Si mañana alguien cambia el precio por minuto de 0,12 € a 1,20 €, esta prueba seguirá pasando, y la ciudad de Ribalta facturará diez veces de más con la construcción en verde.
De ahí la ley de Goodhart aplicada al software: cuando una medida se convierte en objetivo, deja de ser una buena medida. Si el equipo se marca «85 % de cobertura o falla la construcción», lo que se obtiene son pruebas sin asertos escritas la tarde antes de la entrega. La política de CicloUrbana:
- La cobertura se mira, no se persigue. Su uso correcto es buscar el rojo: un
ifde negocio entero sin cubrir es una pregunta legítima. - Un umbral mínimo bajo (40–50 %) que solo impida el retroceso grosero es defendible; uno alto es contraproducente.
- Mucho más valioso que el porcentaje global es el porcentaje del código nuevo, que es lo que los sistemas de revisión modernos muestran en cada cambio.
Si aun así quieres un umbral que rompa la construcción, jacoco:check con una regla:
<execution>
<id>comprobar-cobertura</id>
<phase>verify</phase>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.50</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
- TDD: rojo, verde, refactor
El desarrollo guiado por pruebas invierte el orden habitual: primero la prueba, después el código. Su ciclo tiene tres pasos y se repite en minutos, no en horas.
flowchart LR
R["ROJO<br/>Escribe una prueba<br/>que falla"] --> V["VERDE<br/>El código más simple<br/>que la hace pasar"]
V --> F["REFACTOR<br/>Mejora el diseño<br/>con la prueba en verde"]
F --> R
Aplicado a una regla real de Ribalta —el ayuntamiento exige que ninguna estación tenga menos de 8 anclajes, la capacidadMinima de RedProperties de 02-05:
Rojo. La prueba se escribe antes de que exista el método:
@Test
void rechazaLaEstacionCuandoLaCapacidadEsMenorQueOcho() {
EstacionService servicio = new EstacionService(new EstacionRepositorioEnMemoria());
assertThatThrownBy(() -> servicio.validarCapacidad(6))
.isInstanceOf(ReglaNegocioException.class)
.hasMessageContaining("8 anclajes");
}No compila: validarCapacidad no existe. Eso ya es el rojo, y es informativo: al escribir la llamada has diseñado la firma del método antes de escribirlo.
Verde. Lo mínimo que la hace pasar:
public void validarCapacidad(int capacidad) {
if (capacidad < 8) {
throw new ReglaNegocioException("CAPACIDAD_INSUFICIENTE",
"Una estación de Ribalta necesita al menos 8 anclajes");
}
}Refactor. Con la prueba en verde, el 8 literal se sustituye por redProperties.capacidadMinima(). Si al hacerlo la prueba sigue pasando, el refactor es correcto; si se rompe, lo has sabido en cinco segundos.
Qué aporta realmente, más allá del eslogan:
| Ventaja | En qué se nota |
|---|---|
| Diseño desde fuera | Escribes la llamada antes que la implementación, así que la firma sale legible |
| Solo el código necesario | Cuesta escribir funcionalidad que nadie pidió si primero hay que justificarla con una prueba |
| Cobertura como consecuencia | No se persigue: aparece |
| Retroalimentación en segundos | El ciclo dura minutos; el error nunca está lejos |
Y una postura honesta: TDD no es obligatorio y este curso no lo impone. Es especialmente cómodo en lógica algorítmica con reglas claras —tarifas, validaciones, cálculos— y bastante incómodo cuando estás explorando una API que no conoces. Lo que sí es innegociable es que la prueba exista antes de dar la tarea por terminada, se escriba antes o después.
- Los dobles de prueba
Para probar AlquilerService en aislamiento hace falta darle un AlquilerRepositorio que no toque PostgreSQL. Los objetos que sustituyen a un colaborador real se llaman genéricamente dobles de prueba, y no son todos iguales:
| Doble | Qué hace | Ejemplo en CicloUrbana | Se verifica |
|---|---|---|---|
| Dummy | Se pasa para rellenar un parámetro, nunca se usa | Un Clock en un método que no lo consulta |
Nada |
| Stub | Devuelve respuestas fijas preprogramadas | Un BicicletaRepositorio que siempre devuelve RB-0142 |
El estado del resultado |
| Spy | Objeto real que además registra cómo se le llamó | Un SelectorTarifa real que anota qué tarifa se pidió |
El comportamiento, parcialmente |
| Mock | Doble con expectativas de interacción definidas | Un ApplicationEventPublisher que debe recibir AlquilerIniciado |
El comportamiento |
| Fake | Implementación real pero simplificada | EstacionRepositorioEnMemoria de 02-01, con su ConcurrentHashMap |
El estado |
Dos observaciones que orientan todo el módulo 6:
- En la práctica cotidiana, «mock» se usa para todo, y Mockito lo alimenta: su método se llama
mock(...)aunque lo habitual sea usarlo como stub. La distinción conceptual sigue importando, porque verificar el estado produce pruebas robustas y verificar interacciones produce pruebas frágiles, acopladas a cómo está escrito el método por dentro. - CicloUrbana ya tiene un fake escrito:
EstacionRepositorioEnMemoria. En muchos casos es preferible a cinco líneas dewhen(...).thenReturn(...), y en 06-03 compararemos ambas opciones con un criterio claro.
La lección 06-03 está dedicada por completo a Mockito. Aquí basta con reconocer los nombres.
Errores Comunes y Consejos
Escribir pruebas sin asertos. El caso del apartado 10: se ejecuta el código, no se comprueba nada, JaCoCo dice 100 %. Regla mecánica de revisión: toda prueba tiene al menos un assertThat o un assertThatThrownBy; si no lo tiene, no es una prueba.
Confundir «no falla» con «funciona». Llamar a un método y comprobar que no lanza excepción es el aserto más débil posible. Comprueba el valor devuelto, el estado resultante o la interacción concreta.
Pruebas dependientes entre sí. Si pruebaB necesita que pruebaA haya insertado un dato, la suite es una casa de naipes: JUnit no garantiza el orden y cambiar una rompe la otra. Cada prueba prepara lo suyo y no deja rastro. Si sientes la tentación de fijar el orden con @TestMethodOrder, casi siempre es la señal de un problema de diseño.
Poner un application.yml en src/test/resources. No se fusiona con el de producción: lo reemplaza, y las pruebas empiezan a fallar por propiedades que «están puestas». Usa application-test.yml con @ActiveProfiles("test") (06-04).
Usar @SpringBootTest para todo. Es el error estructural que construye el cono de helado. Levantar el contexto para probar una fórmula de tarifa multiplica por mil el tiempo de ejecución y no detecta ni un fallo más. El contexto se levanta cuando lo que pruebas es la integración.
Lógica dentro de la prueba. Un if, un for o un cálculo dentro del bloque de comprobación introducen la posibilidad de que la prueba tenga su propio error, y entonces ¿quién prueba la prueba? Los valores esperados se escriben literales; para varios casos existen las pruebas parametrizadas de 06-02.
Consejo: la prueba se escribe pensando en el día que falle. Un nombre que describa la regla, un mensaje de fallo que se entienda sin abrir el código y una única razón para fallar. Cuando una prueba te ahorre media hora de depuración a las once de la noche, entenderás por qué.
Consejo: cuando llegue un fallo de producción, la prueba primero. Reproduce el error con una prueba que falla, arréglalo y observa cómo se pone en verde. Así se sabe que se ha arreglado lo que se creía y se garantiza que no vuelva.
Consejo: si probar es difícil, no fuerces la prueba: arregla el diseño. La necesidad de simular métodos estáticos, de instanciar seis colaboradores o de manipular el reloj del sistema son síntomas, no obstáculos.
Ejercicios
Ejercicio 1
Escribe TarifaEstudianteTest con tres pruebas que cubran la regla de los 15 minutos gratuitos de 02-02: un alquiler de 10 minutos (gratis), uno de exactamente 15 minutos (gratis: el límite es inclusivo) y uno de 45 minutos. Aplica el patrón Preparar-Actuar-Comprobar con los comentarios de fase, usa isEqualByComparingTo y nombra los métodos en español describiendo la regla. Justifica en un comentario por qué el caso de los 15 minutos exactos es el más importante de los tres.
Ejercicio 2
Configura JaCoCo en el pom.xml de CicloUrbana según el apartado 10, ejecuta ./mvnw verify con las pruebas de las tarifas ya escritas y abre target/site/jacoco/index.html. Responde por escrito: ¿qué porcentaje de cobertura de ramas tiene el paquete com.ciclourbana.alquileres? ¿Qué clase del proyecto tiene 0 %? ¿Y cuál es el if amarillo más preocupante que encuentres? Después escribe una prueba deliberadamente inútil (sin asertos) sobre SelectorTarifa, vuelve a generar el informe y anota cuánto ha subido el porcentaje.
Ejercicio 3
Clasifica las siguientes ocho comprobaciones pendientes de CicloUrbana por tipo de prueba (unitaria, rodaja, integración, E2E) y decide para cada una si merece la pena escribirla, justificando la decisión en una frase:
- Que
TarifaJubilado.nombre()devuelve"jubilado". - Que
GET /api/v1/estaciones/99devuelve unProblemDetailcon estado 404. - Que
EstacionResponsees unrecordcon seis componentes. - Que un ciudadano recibe
403al finalizar el alquiler de otro. - Que
findByUsuarioIdAndFinIsNulldevuelve solo alquileres sin fecha de fin. - Que la migración
V4crea el índice sobrealquileres(inicio). - Que
server.portvale 8080. - Que un token JWT caduca a los quince minutos.
Soluciones
Solución 1
package com.ciclourbana.alquileres;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
class TarifaEstudianteTest {
private final TarifaEstudiante tarifa = new TarifaEstudiante();
@Test
void noCobraNadaPorDebajoDeLosQuinceMinutosGratuitos() {
// Preparar
Duration duracion = Duration.ofMinutes(10);
// Actuar
BigDecimal importe = tarifa.calcular(duracion);
// Comprobar: 10 < 15, todo dentro del convenio universitario
assertThat(importe).isEqualByComparingTo("0.00");
}
/*
* El caso decisivo. El código es Math.max(0, minutos - 15):
* con 15 minutos exactos el resultado es 0 y el alquiler es gratis.
* Si alguien cambiara la condición a "minutos > 15 ? ... : cobrar",
* o convirtiera el límite en exclusivo, esta es la ÚNICA prueba de las
* tres que se pondría en rojo. Los límites exactos son donde viven
* los errores por uno, y por eso son los casos que nunca deben faltar.
*/
@Test
void conQuinceMinutosExactosElAlquilerSigueSiendoGratuito() {
BigDecimal importe = tarifa.calcular(Duration.ofMinutes(15));
assertThat(importe).isEqualByComparingTo("0.00");
}
@Test
void cobraOchoCentimosPorCadaMinutoQueExcedeLaFranquicia() {
// Preparar: 45 minutos -> 30 facturables
Duration duracion = Duration.ofMinutes(45);
// Actuar
BigDecimal importe = tarifa.calcular(duracion);
// Comprobar: 0,08 × (45 - 15) = 2,40
assertThat(importe).isEqualByComparingTo("2.40");
}
}Comentario: el campo private final TarifaEstudiante tarifa como estado compartido es aceptable aquí precisamente porque la clase no tiene estado mutable —la misma propiedad que en 02-03 la hacía segura como singleton—. Si lo tuviera, cada prueba debería crear su propia instancia en un @BeforeEach. Nótese también que las tres pruebas comprueban con isEqualByComparingTo("0.00") y no con isZero(): isZero() funcionaría, pero deja de leerse como un importe.
Solución 2
Con solo las pruebas de las tarifas escritas, el informe muestra algo parecido a esto:
| Paquete | Cobertura de instrucciones | Cobertura de ramas |
|---|---|---|
com.ciclourbana.alquileres |
~35 % | ~40 % (solo las tarifas) |
com.ciclourbana.estaciones |
0 % | 0 % |
com.ciclourbana.seguridad |
0 % | 0 % |
com.ciclourbana.comun |
0 % | 0 % |
- Con 0 % están casi todas, incluidas las tres clases que más importan:
AlquilerService,ManejadorGlobalExcepcionesy todo el paqueteseguridad. Ese es el valor real del primer informe: el mapa de lo que no está protegido. - El amarillo más preocupante suele ser
TarifaEstandar.calcular: la líneaMath.max(1, duracion.toMinutes())contiene una rama que las pruebas de 30 y 45 minutos nunca recorren, la del alquiler de menos de un minuto. Ahí hay una regla de negocio escondida —«se cobra un minuto como mínimo»— que nadie ha verificado nunca. - Tras añadir la prueba sin asertos sobre
SelectorTarifa, su cobertura salta a cerca del 100 % y el total del paquete sube varios puntos. Ni un solo comportamiento nuevo está verificado. La conclusión del ejercicio es esa: el número subió, la seguridad no. La cobertura es útil leída como mapa del rojo, y engañosa leída como puntuación.
Solución 3
| # | Tipo | ¿Merece la pena? | Justificación |
|---|---|---|---|
| 1 | Unitaria | Sí, aunque sea trivial | No se prueba el return sino el contrato con SelectorTarifa: si alguien borra el nombre(), la tarifa deja de encontrarse y el fallo es silencioso |
| 2 | Rodaja (@WebMvcTest) |
Sí | Es contrato público: código de estado y forma del ProblemDetail (06-04) |
| 3 | — | No | Es la firma de la clase: lo comprueba el compilador |
| 4 | Rodaja de seguridad o integración | Sí, prioritaria | Es la regla de 05-05, protege datos de terceros y falla en silencio (06-04) |
| 5 | Rodaja (@DataJpaTest) |
Sí | Una consulta derivada es código generado a partir de un nombre: un cambio de nombre la altera sin avisar (06-04) |
| 6 | Integración con PostgreSQL | Sí | Es exactamente lo que H2 no puede validar; es el caso de 06-05 |
| 7 | — | No | Si el puerto está mal, la aplicación no arranca: el arranque ya lo comprueba |
| 8 | Unitaria con Clock fijo |
Sí | Regla de seguridad con dependencia temporal; el Clock inyectado de ServicioJwt existe justo para esto (06-02) |
El patrón que emerge de la tabla: se prueba lo que puede fallar en silencio. Lo que rompe la compilación o impide el arranque ya tiene quien lo vigile.
Conclusión
Este módulo empieza donde terminaba el anterior: con la sospecha de que CicloUrbana funciona y sin ninguna forma automática de demostrarlo. Ahora tienes el criterio para construirla. Sabes que se prueba por cuatro razones —regresiones, refactorización segura, documentación viva y presión de diseño— y que la última explica decisiones que arrastramos desde el módulo 2: el Clock inyectado, la inyección por constructor, la interfaz EstacionRepositorio y el bean SeguridadAlquileres no eran elegancia académica, eran diseño para poder probar. Conoces los seis tipos de prueba con su coste y lo que detecta cada uno, la pirámide y el cono de helado, y la proporción concreta que buscamos en CicloUrbana: setenta por ciento de unitarias que se ejecutan en segundos, veinte de rodajas, nueve de integración con base de datos real y un uno por ciento de extremo a extremo.
Sabes decir que no: no se prueban los getters, ni el framework ajeno, ni la configuración trivial, y sí todo lo que puede fallar en silencio, empezando por las reglas de seguridad y terminando por cada error encontrado en producción. Tienes inventariado lo que spring-boot-starter-test ya trae —JUnit 5, Spring Test, AssertJ, Hamcrest, Mockito, JSONassert, JsonPath y Awaitility— y la razón para no añadir ni una dependencia suelta al lado. Conoces la estructura espejo de src/test/java, la trampa de colocar un application.yml en src/test/resources, las convenciones de nombres del curso y el patrón Preparar-Actuar-Comprobar, aplicado ya a TarifaEstandarTest: la primera prueba real de CicloUrbana, con su isEqualByComparingTo y su comentario del cálculo esperado. Sabes ejecutarlas con ./mvnw test y sus filtros, distinguir Surefire de Failsafe y por qué *Test y *IT viven separadas, configurar JaCoCo y —sobre todo— leerlo como un mapa del rojo y nunca como una puntuación, después de ver una prueba sin asertos alcanzar el 100 %. Y has visto el ciclo rojo-verde-refactor aplicado a la regla de los ocho anclajes, y la tabla de los cinco dobles de prueba.
Con el criterio puesto, toca la técnica. La lección siguiente, Pruebas Unitarias con JUnit, entra a fondo en la herramienta: la arquitectura de JUnit 5, el ciclo de vida completo de sus anotaciones, el catálogo de aserciones de AssertJ por tipo de dato —con su trampa de BigDecimal y las aserciones blandas—, las pruebas parametrizadas aplicadas a las tres tarifas de Ribalta en una sola clase, la organización con @Nested y @DisplayName, el Clock.fixed que hace determinista el cálculo de la duración de un alquiler y las buenas prácticas de datos de prueba con el patrón Object Mother. Vamos a llenar la base de la pirámide.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
