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

  1. Por qué se prueba
  2. Los tipos de prueba
  3. La pirámide de pruebas y el cono de helado
  4. Qué merece la pena probar y qué no
  5. spring-boot-starter-test: la caja de herramientas
  6. Dónde viven las pruebas: estructura y convenciones
  7. Anatomía de una prueba: Preparar-Actuar-Comprobar
  8. Ejecutar las pruebas
  9. Surefire y Failsafe: *Test frente a *IT
  10. Cobertura con JaCoCo
  11. TDD: rojo, verde, refactor
  12. Los dobles de prueba
  13. Errores Comunes y Consejos
  14. Ejercicios

  1. 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).

  1. 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.

  1. 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 500 y 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.

  1. 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 ProblemDetail de 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.

  1. spring-boot-starter-test: la caja de herramientas

Spring 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:

./mvnw dependency:tree -Dincludes=org.mockito,org.assertj,org.junit.jupiter

  1. 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:

  1. 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.
  2. src/test/resources está antes que src/main/resources en el classpath de prueba. Un application.yml colocado 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.
  3. Nada de src/test/java acaba 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.

  1. 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, sin public, en el paquete com.ciclourbana.alquileres, espejo exacto de la clase probada.
  • new TarifaEstandar(): no hay Spring por ninguna parte. TarifaEstandar es 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: el import estático de AssertJ, presente en todas las pruebas del curso.
  • isEqualByComparingTo("4.10") y no isEqualTo(new BigDecimal("4.10")). El equals de BigDecimal compara también la escala, de modo que 4.1 y 4.10 no son iguales para él aunque valgan lo mismo. Con dinero, siempre isEqualByComparingTo. Es la primera de las trampas de AssertJ y se detalla en 06-02.
  • El comentario del cálculo esperado: 4.10 no 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:

./mvnw test -Dtest=TarifaEstandarTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

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:

[ERROR] TarifaEstandarTest.cobraDesbloqueoMasDoceCentimosPorMinuto:19
expected: 4.10
 but was: 5.00

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.

  1. 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 -DskipTests

Una 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.

  1. Surefire y Failsafe: *Test frente a *IT

Maven 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.

  1. 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>
./mvnw verify
# El informe queda en: target/site/jacoco/index.html

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 if de 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>

  1. 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.

  1. 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 de when(...).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:

  1. Que TarifaJubilado.nombre() devuelve "jubilado".
  2. Que GET /api/v1/estaciones/99 devuelve un ProblemDetail con estado 404.
  3. Que EstacionResponse es un record con seis componentes.
  4. Que un ciudadano recibe 403 al finalizar el alquiler de otro.
  5. Que findByUsuarioIdAndFinIsNull devuelve solo alquileres sin fecha de fin.
  6. Que la migración V4 crea el índice sobre alquileres(inicio).
  7. Que server.port vale 8080.
  8. 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, ManejadorGlobalExcepciones y todo el paquete seguridad. 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ínea Math.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

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados