La lección anterior dejó a CicloUrbana con una prueba: TarifaEstandarTest, doce líneas que afirman que treinta minutos cuestan 4,10 €. Sirvió para ilustrar el patrón Preparar-Actuar-Comprobar, pero es evidente que no basta. Ribalta tiene tres tarifas, cada una con su franquicia de minutos gratuitos, y los casos que importan son los límites: cero minutos, exactamente quince, exactamente treinta. Escribir doce clases casi idénticas sería el peor camino posible.

Esta lección llena la base de la pirámide con la herramienta adecuada. Veremos la arquitectura de JUnit 5, el catálogo de anotaciones con su ciclo de vida demostrado en ejecución, AssertJ a fondo —el estilo de aserción de todo el curso— con sus trampas de BigDecimal y sus aserciones blandas, las pruebas parametrizadas aplicadas a las tarifas de Ribalta en una sola clase legible, la organización por escenarios con @Nested, 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. Todo sin levantar el contexto de Spring ni una sola vez: aquí no hace falta, y esa es precisamente la lección.

Contenido

  1. La arquitectura de JUnit 5
  2. Las anotaciones fundamentales y su ciclo de vida
  3. AssertJ: por qué es el estilo del curso
  4. El catálogo de aserciones por tipo
  5. Excepciones y aserciones blandas
  6. Pruebas parametrizadas
  7. La CalculadoraTarifa parametrizada
  8. Organizar con @Nested, @DisplayName y @Tag
  9. Probar código dependiente del tiempo
  10. @TempDir, suposiciones y @RepeatedTest
  11. EstacionService sin Spring
  12. Datos de prueba: Object Mother y builders
  13. Errores Comunes y Consejos
  14. Ejercicios

  1. La arquitectura de JUnit 5

JUnit 5 no es una librería, son tres módulos con responsabilidades separadas:

Módulo Qué es Quién lo usa
JUnit Platform Motor de descubrimiento y ejecución; define la API que consumen las herramientas Maven Surefire, IntelliJ, Eclipse
JUnit Jupiter El modelo de programación moderno: @Test, @BeforeEach, extensiones Tú, al escribir pruebas
JUnit Vintage Motor que ejecuta pruebas de JUnit 3 y 4 sobre la Platform Proyectos con historia

La separación tiene una consecuencia práctica: la Platform puede ejecutar varios motores a la vez, de modo que un proyecto en migración corre Jupiter y Vintage en la misma construcción. CicloUrbana nace en JUnit 5, así que Vintage no está en el classpath y no debe añadirse: solo sirve para arrastrar deuda.

flowchart TB
    IDE["IntelliJ / Eclipse / Maven Surefire"] --> P["JUnit Platform<br/>(descubre y ejecuta)"]
    P --> J["Jupiter Engine<br/>@Test de JUnit 5"]
    P --> V["Vintage Engine<br/>@Test de JUnit 4"]
    J --> T["Tus pruebas<br/>TarifaEstandarTest"]

El error asociado más habitual: mezclar org.junit.Test (JUnit 4) con org.junit.jupiter.api.Test (JUnit 5) en el mismo fichero. La prueba compila y no se ejecuta nunca, sin aviso. Si un método «no aparece» en el informe, mira el import antes que ninguna otra cosa.

  1. Las anotaciones fundamentales y su ciclo de vida

Anotación Cuándo se ejecuta Método Uso típico en CicloUrbana
@Test Es la prueba De instancia Todos los casos
@BeforeEach Antes de cada @Test De instancia Construir el objeto bajo prueba
@AfterEach Después de cada @Test De instancia Cerrar recursos abiertos
@BeforeAll Una vez, antes de todas static Cargar algo costoso e inmutable
@AfterAll Una vez, después de todas static Liberar ese recurso
@DisplayName — Clase o método Frase legible en el informe
@Disabled — Clase o método Desactivar, siempre con motivo
@Nested / @Tag — Clase interna no static / cualquiera Agrupar por escenario / etiquetar
flowchart TB
    A["@BeforeAll (static) · una vez"] --> B["Nueva instancia de la clase"]
    B --> C["@BeforeEach"] --> D["@Test"] --> E["@AfterEach"]
    E --> F{"¿Quedan pruebas?"}
    F -- "Sí" --> B
    F -- "No" --> G["@AfterAll (static) · una vez"]

El punto que sorprende a quien viene de JUnit 4 es el segundo recuadro: JUnit crea una instancia nueva de la clase de prueba para cada método @Test. Es aislamiento deliberado: un campo que una prueba modifique no lo verá la siguiente, porque la siguiente corre sobre otro objeto. Esta prueba lo demuestra:

class CicloDeVidaTest {

    private int contador = 0;   // campo de instancia: se reinicia en cada prueba

    @BeforeAll static void antesDeTodas()   { System.out.println("1. @BeforeAll"); }
    @AfterAll  static void despuesDeTodas() { System.out.println("5. @AfterAll"); }

    @BeforeEach
    void antesDeCadaUna(TestInfo info) {     // JUnit inyecta TestInfo si lo pides
        contador++;
        System.out.println("  2. @BeforeEach — " + info.getDisplayName()
                + " · contador=" + contador + " · instancia=" + hashCode());
    }

    @Test void primeraPrueba() { System.out.println("    3. primera, c=" + contador); }
    @Test void segundaPrueba() { System.out.println("    3. segunda, c=" + contador); }
}
1. @BeforeAll
  2. @BeforeEach — primeraPrueba() · contador=1 · instancia=1829164700
    3. primera, c=1 · 4. @AfterEach
  2. @BeforeEach — segundaPrueba() · contador=1 · instancia=1259475182
    3. segunda, c=1 · 4. @AfterEach
5. @AfterAll

Lo importante está en dos números: contador vale 1 en ambas pruebas, no 1 y 2, y el hashCode es distinto, que es la causa. Esa es la garantía de independencia sobre la que descansa todo lo demás; el orden de ejecución es determinista pero no el del fichero, y jamás debe importar.

Dos detalles con consecuencias. @BeforeAll y @AfterAll deben ser static, justamente porque no hay una instancia que dure más que una prueba; existe la alternativa @TestInstance(Lifecycle.PER_CLASS), que este curso no usa porque abre la puerta a que el estado de una prueba contamine la siguiente. Y @Disabled sin motivo es basura acumulada: escribe @Disabled("Bloqueado por CU-412; reactivar al corregirlo"), o la prueba se quedará desactivada para siempre dando la impresión de que algo está cubierto.

El parámetro TestInfo del ejemplo no es casual: JUnit 5 inyecta parámetros en los métodos mediante resolvedores, y es el mismo mecanismo con el que @ExtendWith(MockitoExtension.class) inyectará los mocks en 06-03.

  1. AssertJ: por qué es el estilo del curso

JUnit trae sus propias aserciones y funcionan. AssertJ, incluido también en spring-boot-starter-test, hace el mismo trabajo mucho mejor:

Aspecto JUnit Assertions AssertJ
Sintaxis assertEquals(esperado, real) assertThat(real).isEqualTo(esperado)
Orden de argumentos Esperado primero: se invierte constantemente Sujeto primero: se lee como una frase
Descubrimiento Hay que recordar el nombre del método El punto tras assertThat(...) ofrece lo aplicable al tipo
Encadenamiento No Sí, varias comprobaciones sobre el mismo sujeto
Colecciones Muy pobre contains, extracting, filteredOn, allMatch
Mensaje de fallo expected: <4.10> but was: <5.00> Además señala el campo y el elemento concretos

La inversión de argumentos produce mensajes que mienten: assertEquals(importe, new BigDecimal("4.10")) informa «expected: 5.00 but was: 4.10», al revés, y a depurar. Con AssertJ el error es imposible. Regla del curso: todas las aserciones se escriben con AssertJ; la única excepción es Hamcrest dentro de jsonPath(...) en MockMvc (06-04), porque esa API lo exige.

import static org.assertj.core.api.Assertions.*;          // assertThat, assertThatThrownBy...
import static org.assertj.core.api.SoftAssertions.assertSoftly;

  1. El catálogo de aserciones por tipo

Objetos generales:

Aserción Qué comprueba
isEqualTo(x) / isNotEqualTo(x) Igualdad por equals
isSameAs(x) Identidad de referencia (==)
isNull() / isNotNull() / isInstanceOf(C.class) Nulidad y tipo
usingRecursiveComparison().isEqualTo(otro) Igualdad campo a campo, sin usar equals
extracting(Estacion::getNombre, Estacion::getCapacidad) Extrae varios campos para compararlos juntos

usingRecursiveComparison() resuelve un problema real: las entidades JPA de 04-03 definen equals por identificador, así que isEqualTo diría que dos estaciones con el mismo id son iguales aunque una tenga el nombre cambiado. La forma habitual es assertThat(guardada).usingRecursiveComparison().ignoringFields("id", "version", "creadoEn", "modificadoEn").isEqualTo(esperada), ignorando lo que asigna la base de datos.

Cadenas y números:

assertThat(bicicleta.getMatricula())
        .isNotBlank()                       // ni null, ni vacía, ni solo espacios
        .startsWith("RB-").hasSize(7)
        .matches("RB-\\d{4}");              // el formato de Ribalta

assertThat(problema.getDetail()).doesNotContain("SQLException");  // 05-05
assertThat(estacion.getCapacidad()).isPositive().isBetween(8, 60);
assertThat(0.1 + 0.2).isCloseTo(0.3, within(0.0001));   // coma flotante

BigDecimal: la trampa que hay que memorizar.

BigDecimal importe = tarifa.calcular(Duration.ofMinutes(30));   // 4.10

assertThat(importe).isEqualTo(new BigDecimal("4.1"));       // ❌ FALLA
assertThat(importe).isEqualByComparingTo("4.10");           // ✅

BigDecimal.equals compara valor y escala: 4.1 tiene escala 1 y 4.10 escala 2, luego no son equals aunque compareTo devuelva 0. Como isEqualTo usa equals, una prueba correcta puede fallar solo porque el cálculo produjo un decimal más. Con dinero, siempre isEqualByComparingTo. En CicloUrbana afecta a todos los importes y tarifas.

Optional y colecciones, donde AssertJ es incomparable:

assertThat(estacionRepositorio.buscarPorId(1L))
        .isPresent().get()
        .extracting(Estacion::getNombre).isEqualTo("Plaza Mayor");
assertThat(estacionRepositorio.buscarPorId(99L)).isEmpty();

assertThat(estaciones).hasSize(4)
        .extracting(Estacion::getNombre, Estacion::getCapacidad)    // tuplas
        .containsExactlyInAnyOrder(
                tuple("Plaza Mayor", 24), tuple("Estación Norte", 30),
                tuple("Parque del Río", 18), tuple("Universidad", 36));

assertThat(estaciones).allMatch(e -> e.getCapacidad() >= 8);        // regla de Ribalta

La diferencia entre los métodos de contenido se olvida siempre:

Método Orden Elementos extra permitidos
containsExactly Importa No
containsExactlyInAnyOrder No importa No
contains No importa Sí

Y sobre mapas, aplicado al SelectorTarifa de 02-02, assertThat(selector.disponibles()).hasSize(3).containsKeys("estandar", "estudiante", "jubilado").doesNotContainKey("TarifaEstandar") verifica de una vez el registro completo y, con la última cláusula, el fallo de olvidar nombre() en una tarifa nueva.

  1. Excepciones y aserciones blandas

Tres formas de verificar un fallo, en orden de preferencia:

// 1. assertThatThrownBy — la habitual
assertThatThrownBy(() -> alquilerService.iniciar(peticionConBateriaAgotada))
        .isInstanceOf(BicicletaNoDisponibleException.class)
        .hasMessageContaining("RB-0142")
        .hasFieldOrPropertyWithValue("codigo", "BICICLETA_NO_DISPONIBLE");

// 2. assertThatExceptionOfType — más explícita sobre el tipo
assertThatExceptionOfType(EstacionLlenaException.class)
        .isThrownBy(() -> alquilerService.finalizar(1L, enPlazaMayorLlena))
        .withMessageContaining("no tiene anclajes libres");

// 3. catchThrowable — cuando hay que inspeccionar el objeto en profundidad
Throwable error = catchThrowable(() -> servicioJwt.extraerCorreo(tokenCaducado));
assertThat(error).isInstanceOf(ExpiredJwtException.class);

assertThatNoException().isThrownBy(() -> estacionService.validarCapacidad(24));

Comprobar el codigo —el campo estable de CicloUrbanaException de 03-06— es lo que convierte el aserto en una verificación del contrato con el cliente de la API. El aserto peligrosamente débil es isInstanceOf(RuntimeException.class): cualquier NullPointerException accidental lo satisface y tiñe la prueba de verde.

Aserciones blandas. Cuando una prueba tiene varios asertos, el primero que falla oculta los demás: arreglas uno, ejecutas, falla el segundo. assertSoftly los evalúa todos:

@Test
void elAlquilerDevueltoTieneTodosSusCamposCorrectos() {
    AlquilerResponse respuesta = alquilerService.iniciar(peticionValida);

    assertSoftly(c -> {
        c.assertThat(respuesta.matriculaBicicleta()).isEqualTo("RB-0142");
        c.assertThat(respuesta.estacionOrigen()).isEqualTo("Plaza Mayor");
        c.assertThat(respuesta.estado()).isEqualTo(EstadoAlquiler.EN_CURSO);
        c.assertThat(respuesta.fin()).isNull();
        c.assertThat(respuesta.importe()).isNull();
    });
}

Son ideales para verificar un solo objeto compuesto: un concepto expresado en varios campos. No son una licencia para meter cinco comprobaciones inconexas en una prueba.

  1. Pruebas parametrizadas

Una prueba parametrizada ejecuta el mismo método con distintos datos. Es la respuesta al problema con el que abría la lección, y junit-jupiter-params ya viene en el starter.

Fuente Qué aporta Ejemplo
@ValueSource Un array de literales de un solo tipo ints = {0, 1, 15, 30}
@CsvSource Varias columnas escritas en línea "30, 4.10"
@CsvFileSource Las mismas columnas desde un fichero de src/test/resources Cientos de casos
@EnumSource Todos (o algunos) valores de un enum EstadoBicicleta.class
@NullSource, @EmptySource, @NullAndEmptySource Los casos degenerados Validaciones de cadenas
@MethodSource Un método static que devuelve Stream<Arguments> Objetos complejos
@ArgumentsSource Un proveedor propio, reutilizable entre clases Catálogos del dominio
@ParameterizedTest(name = "una capacidad de {0} anclajes debe rechazarse")
@ValueSource(ints = {-5, 0, 1, 7})
void rechazaCapacidadesPorDebajoDelMinimo(int capacidad) {
    assertThatThrownBy(() -> estacionService.validarCapacidad(capacidad))
            .isInstanceOf(ReglaNegocioException.class);
}
@ParameterizedTest
@NullAndEmptySource @ValueSource(strings = {"  ", "\t"})
void rechazaNombresDeEstacionVacios(String nombre) {
    assertThatThrownBy(() -> estacionService.validarNombre(nombre))
            .isInstanceOf(ReglaNegocioException.class);
}

@ParameterizedTest
@EnumSource(value = EstadoBicicleta.class, names = {"MANTENIMIENTO", "RETIRADA", "EN_USO"})
void ningunEstadoDistintoDeDisponiblePermiteAlquilar(EstadoBicicleta estado) {
    assertThat(BicicletasDePrueba.conEstado(estado).puedeAlquilarse(20)).isFalse();
}

El atributo name merece atención: sin él el informe muestra [1], [2], [3]; con él, «una capacidad de -5 anclajes debe rechazarse». Los marcadores son {0}, {1}... para los argumentos, {index} para el número de caso y {displayName}.

  1. La CalculadoraTarifa parametrizada

El ejemplo central de la lección: las tres tarifas de Ribalta, con sus reglas y sus límites, en una sola clase legible. Las reglas de 02-02:

Tarifa Desbloqueo €/minuto Minutos gratis Mínimo facturable
estandar 0,50 € 0,12 € 0 1 minuto
estudiante 0 € 0,08 € 15 —
jubilado 0 € 0,05 € 30 —
package com.ciclourbana.alquileres;

@DisplayName("Cálculo de tarifas de la red de Ribalta")
class CalculadoraTarifaTest {

    @DisplayName("Tarifa estándar: 0,50 € de desbloqueo + 0,12 €/minuto")
    @ParameterizedTest(name = "{0} min -> {1} €")
    @CsvSource({"0, 0.62",   // se factura el mínimo de 1 minuto: 0,50 + 0,12
                "1, 0.62", "10, 1.70", "30, 4.10",
                "120, 14.90"})   // el máximo permitido por RedProperties
    void tarifaEstandar(long minutos, BigDecimal esperado) {
        assertThat(new TarifaEstandar().calcular(Duration.ofMinutes(minutos)))
                .isEqualByComparingTo(esperado);
    }

    @DisplayName("Cada tarifa aplica su franquicia y su precio por minuto")
    @ParameterizedTest(name = "[{index}] {0}: {1} min -> {2} €")
    @MethodSource("casosDeTarifa")
    void cadaTarifaCalculaSuImporte(String nombre, long minutos, BigDecimal esperado) {
        assertThat(tarifaPorNombre(nombre).calcular(Duration.ofMinutes(minutos)))
                .isEqualByComparingTo(esperado);
    }

    /** El proveedor: static, sin argumentos, devuelve Stream<Arguments>. */
    static Stream<Arguments> casosDeTarifa() {
        return Stream.of(
                Arguments.of("estandar",    1, new BigDecimal("0.62")),   // sin franquicia
                Arguments.of("estandar",   30, new BigDecimal("4.10")),
                Arguments.of("estudiante", 14, new BigDecimal("0.00")),   // 15 min gratis,
                Arguments.of("estudiante", 15, new BigDecimal("0.00")),   // LÍMITE INCLUSIVO
                Arguments.of("estudiante", 16, new BigDecimal("0.08")),
                Arguments.of("estudiante", 45, new BigDecimal("2.40")),
                Arguments.of("jubilado",   30, new BigDecimal("0.00")),   // 30 min gratis
                Arguments.of("jubilado",   31, new BigDecimal("0.05")),
                Arguments.of("jubilado",   90, new BigDecimal("3.00")));
    }

    private static CalculadoraTarifa tarifaPorNombre(String nombre) {
        return switch (nombre) {
            case "estandar" -> new TarifaEstandar();
            case "estudiante" -> new TarifaEstudiante();
            case "jubilado" -> new TarifaJubilado();
            default -> throw new IllegalArgumentException(nombre);
        };
    }
}

Qué hace bueno a este diseño:

  • Los casos límite de cada franquicia están juntos y a la vista: 14, 15 y 16 minutos para el estudiante; 30 y 31 para el jubilado. Leyendo la lista se ve que el límite es inclusivo. Es la documentación viva de 06-01 en su forma más pura.
  • @CsvSource convierte automáticamente "4.10" a BigDecimal, "30" a long y "MANTENIMIENTO" a la constante del enum (para tipos propios existe @ConvertWith), y @MethodSource es la fuente para objetos complejos: su método debe ser static, salvo con Lifecycle.PER_CLASS.
  • Un fallo señala el caso exacto: el informe dirá [5] estudiante: 16 min -> 0.08 €, no «falló la prueba de tarifas».

Cuando la lista crece y se comparte entre clases, @ArgumentsSource la extrae a un ArgumentsProvider reutilizable, con la misma forma que casosDeTarifa pero implementando provideArguments(ExtensionContext).

  1. Organizar con @Nested, @DisplayName y @Tag

Una clase con veinte métodos planos es tan ilegible como un método de doscientas líneas. @Nested agrupa por escenario, y cada grupo tiene su propio @BeforeEach:

@DisplayName("EstacionService")
class EstacionServiceTest {

    private EstacionService servicio;

    @BeforeEach
    void prepararServicio() {
        servicio = new EstacionService(new EstacionRepositorioEnMemoria());
    }

    @Nested @DisplayName("cuando se da de alta una estación")
    class AlDarDeAlta {
        @Test @DisplayName("la acepta si cumple el mínimo de anclajes")
        void aceptaCapacidadSuficiente() { /* ... */ }
    }

    @Nested @DisplayName("cuando la estación ya existe")
    class ConEstacionExistente {

        @BeforeEach   // se ejecuta DESPUÉS del @BeforeEach externo
        void darDeAltaPlazaMayor() { servicio.crear(EstacionesDePrueba.plazaMayor()); }

        @Test @DisplayName("rechaza un nombre duplicado con 409")
        void rechazaNombreDuplicado() { /* ... */ }
    }
}

Reglas que hay que conocer: la clase interna no puede ser static —necesita la instancia externa—, y los @BeforeEach se acumulan de fuera hacia dentro, que es lo que permite la preparación incremental. El informe se muestra jerárquico y se lee como una especificación:

EstacionService
├─ cuando se da de alta una estación · ✔ la acepta si cumple el mínimo de anclajes
└─ cuando la estación ya existe · ✔ rechaza un nombre duplicado con 409

@DisplayName no sustituye a un buen nombre de método: el nombre es lo que aparece en las trazas de fallo y en los filtros de -Dtest=.

@Tag corta la suite por criterios transversales al nombre del fichero:

./mvnw test -Dgroups=tarifas             # solo las etiquetadas
./mvnw test -DexcludedGroups=lento       # todo menos las lentas

Usa pocas etiquetas y con significado claro: en CicloUrbana bastan lento y seguridad. Y recuerda que para separar unitarias de integración ya tenemos un mecanismo mejor, el sufijo *IT con Failsafe de 06-01, que no depende de que nadie se acuerde de anotar.

  1. Probar código dependiente del tiempo

Aquí se cobra una decisión tomada en 02-01. ConfiguracionComun declara @Bean Clock relojRibalta(), y en su momento pareció una formalidad. Si AlquilerService calculara la duración con Instant.now(), probar un alquiler de dos horas exigiría esperar dos horas, cambiar el reloj del sistema o simular un método estático (06-03: se puede, y casi nunca se debe). Con el Clock inyectado —Instant.now(clock)— el tiempo es un argumento más:

class DuracionDelAlquilerTest {

    private static final Instant INICIO = Instant.parse("2026-03-14T09:00:00Z");

    @Test
    void calculaDosHorasDeAlquilerSinEsperarDosHoras() {
        // Preparar: un reloj congelado dos horas después del inicio
        Clock reloj = Clock.fixed(INICIO.plus(Duration.ofHours(2)), ZoneOffset.UTC);
        Alquiler alquiler = AlquileresDePrueba.enCursoDesde(INICIO);
        // Actuar
        Duration duracion = Duration.between(alquiler.getInicio(), Instant.now(reloj));
        // Comprobar
        assertThat(duracion.toMinutes()).isEqualTo(120);
    }
}

Las dos fábricas que se usan en pruebas:

Fábrica Qué hace Cuándo
Clock.fixed(instante, zona) El tiempo no avanza: siempre el mismo instante El 99 % de los casos
Clock.offset(base, duracion) Desplaza un reloj una cantidad fija Simular «dentro de 16 minutos»

La segunda es la que permite probar que un token JWT caduca a los quince minutos: se genera con un reloj y se valida con otro adelantado dieciséis. Sin esperar dieciséis minutos.

La regla general: cualquier fuente de no determinismo debe ser inyectable. El reloj, el generador aleatorio —el Random con semilla fija de 02-01 responde a la misma idea— y el generador de identificadores. Un new Random() o un UUID.randomUUID() incrustados en la lógica hacen imposible una aserción exacta.

  1. @TempDir, suposiciones y @RepeatedTest

@TempDir da un directorio que JUnit crea antes y borra después, sin código de limpieza:

@Test
void exportaElInformeDeOcupacionEnCsv(@TempDir Path directorio) throws IOException {
    Path fichero = directorio.resolve("ocupacion-ribalta.csv");
    exportador.exportar(EstacionesDePrueba.lasCuatroDeRibalta(), fichero);

    assertThat(Files.readAllLines(fichero)).hasSize(5)     // cabecera + 4 estaciones
            .first().isEqualTo("estacion;capacidad;libres");
}

Las suposiciones abortan la prueba sin marcarla como fallida cuando no se dan las condiciones: assumeTrue(DockerClientFactory.instance().isDockerAvailable(), "Docker no disponible") la omite entera, y assumingThat(condicion, () -> assertThat(...)) omite solo un bloque de asertos. La diferencia con @Disabled es que este desactiva siempre y la suposición decide en ejecución. El matiz que hay que vigilar: una prueba omitida aparece en verde en el resumen, así que una suposición laxa oculta que algo no se ejecuta nunca. Ponles siempre mensaje.

@RepeatedTest(100) ejecuta la misma prueba varias veces; su uso legítimo es comprobar algo con aleatoriedad interna, como un generador de matrículas RB-\d{4}. No sirve para detectar problemas de concurrencia: repetir cien veces en el mismo hilo no crea contención. Y si una prueba pasa 99 veces de 100, no tienes una prueba escamosa: tienes un error.

  1. EstacionService sin Spring

Poniéndolo todo junto sobre una clase real. EstacionService recibe su repositorio por constructor (02-02), así que le entregamos el EstacionRepositorioEnMemoria de 02-01 —el fake de la tabla de dobles de 06-01— y no hace falta nada más:

@DisplayName("EstacionService · reglas de la red de Ribalta")
class EstacionServiceTest {

    private EstacionService servicio;

    @BeforeEach
    void prepararServicioLimpio() {
        // Instancia nueva en CADA prueba: sin estado compartido, sin orden implícito
        servicio = new EstacionService(new EstacionRepositorioEnMemoria());
    }

    @ParameterizedTest(name = "{0} anclajes se aceptan")
    @ValueSource(ints = {8, 18, 24, 30, 36, 60})
    void aceptaLasCapacidadesDeRibalta(int capacidad) {
        assertThatNoException().isThrownBy(() -> servicio.validarCapacidad(capacidad));
    }

    @ParameterizedTest(name = "{0} anclajes se rechazan")
    @ValueSource(ints = {-1, 0, 7})
    void rechazaMenosDeOchoAnclajes(int capacidad) {
        assertThatThrownBy(() -> servicio.validarCapacidad(capacidad))
                .isInstanceOf(ReglaNegocioException.class).hasMessageContaining("8");
    }

    @Test
    void devuelveVacioCuandoNoExiste() { assertThat(servicio.buscarPorId(999L)).isEmpty(); }
}

Por qué esta prueba no levanta el contexto, aunque EstacionService sea un @Service:

Con @SpringBootTest Esta prueba
2–6 s de arranque del contexto < 20 ms
Necesita base de datos, o simularla Un ConcurrentHashMap
Un fallo puede venir de cualquier capa El fallo está en EstacionService
Prueba el cableado y la lógica Prueba solo la lógica

La anotación @Service no impide instanciar la clase con new: son metadatos que Spring lee en el arranque. Una prueba unitaria no levanta el contexto porque no está probando el contexto. Que el cableado funciona se comprueba una vez, en las pruebas de integración de 06-04, no en cada una de las doscientas pruebas de lógica.

  1. Datos de prueba: Object Mother y builders

Ha aparecido varias veces EstacionesDePrueba.plazaMayor(). Es el patrón Object Mother: una fábrica en src/test/java que produce objetos del dominio válidos y con nombre de negocio.

/** Fábrica de estaciones de Ribalta para las pruebas. Solo en src/test/java. */
public final class EstacionesDePrueba {

    public static Estacion plazaMayor()    { return new Estacion("Plaza Mayor",    "Plaza Mayor, 1", 24); }
    public static Estacion estacionNorte() { return new Estacion("Estación Norte", "Av. del Norte, 40", 30); }
    public static Estacion parqueDelRio()  { return new Estacion("Parque del Río", "Paseo Fluvial, s/n", 18); }
    public static Estacion universidad()   { return new Estacion("Universidad",    "Campus Ribalta", 36); }

    public static List<Estacion> lasCuatroDeRibalta() {
        return List.of(plazaMayor(), estacionNorte(), parqueDelRio(), universidad());
    }

    /** Variante para el caso que necesite un valor concreto. */
    public static Estacion conCapacidad(int c) { return new Estacion("Prueba", "Calle 1", c); }
}

Cuando los objetos tienen muchos campos y cada prueba varía uno, el builder es más flexible:

public class AlquilerBuilder {

    private Instant inicio = Instant.parse("2026-03-14T09:00:00Z");
    private Instant fin = null;
    private EstadoAlquiler estado = EstadoAlquiler.EN_CURSO;

    public static AlquilerBuilder unAlquiler() { return new AlquilerBuilder(); }

    public AlquilerBuilder finalizadoTras(Duration duracion) {
        this.fin = inicio.plus(duracion);
        this.estado = EstadoAlquiler.FINALIZADO;
        return this;
    }
    public Alquiler construir() { /* monta el objeto */ }
}
// Y la prueba se lee así:
Alquiler alquiler = unAlquiler().finalizadoTras(Duration.ofMinutes(45)).construir();

Lo que se gana es sustancial: la prueba solo menciona lo que le importa. Los otros seis campos existen con valores válidos pero no ensucian la lectura, y si mañana Alquiler gana un campo obligatorio se cambia el builder y no las cuarenta pruebas.

Un consejo sobre la línea que separa esto de un antipatrón: el objeto de la prueba se construye, no se calcula. Si el builder empieza a contener lógica —«si está finalizado, calcula el importe con la tarifa»— has duplicado la implementación dentro de las pruebas, y entonces la prueba pasa aunque el código esté mal.

Errores Comunes y Consejos

Mezclar org.junit.Test con org.junit.jupiter.api.Test. La prueba compila y no se ejecuta jamás; si el número de pruebas del informe no cuadra, revisa los import. En la misma familia está olvidar el static en @BeforeAll, @AfterAll o en el método de @MethodSource: el error de configuración es claro, pero cuesta reconocerlo la primera vez.

isEqualTo sobre BigDecimal. Falla por la escala aunque el valor sea correcto. Con importes, siempre isEqualByComparingTo. Es el fallo que más tiempo hace perder en un dominio con dinero como el de CicloUrbana.

Depender del orden de ejecución. JUnit no lo garantiza y crea una instancia nueva por prueba para impedirlo. Si una prueba solo pasa cuando otra la precede, hay estado compartido —un campo static, un fichero, un singleton—: arréglalo, no lo ordenes con @TestMethodOrder. En la misma familia está el aserto laxo (isNotNull() como única comprobación, o isInstanceOf(RuntimeException.class) esperando una excepción concreta): pasa siempre y no protege de nada.

Lógica condicional dentro de una prueba. Un if (esFinDeSemana) { ... } else { ... } significa que hay dos pruebas escritas en una y que en cada ejecución solo se comprueba una rama. La solución es una prueba parametrizada, o dos métodos.

Consejo: una razón para fallar por prueba. No significa un solo assertThat, sino un solo concepto. Cinco asertos sobre los cinco campos de un AlquilerResponse son un concepto; comprobar el importe y que se publicó el evento son dos, y merecen dos métodos.

Consejo: si necesitas más de tres o cuatro líneas de preparación, mira la clase. Una preparación larga es la queja de la prueba sobre el diseño: demasiadas dependencias, demasiado estado, demasiadas responsabilidades.

Ejercicios

Ejercicio 1

Amplía CalculadoraTarifaTest con una prueba parametrizada que verifique la propiedad transversal de las tres tarifas: ningún importe puede ser negativo y todos deben tener exactamente dos decimales de escala. Usa @MethodSource para combinar las tres tarifas con las duraciones 0, 1, 15, 30, 45 y 120 minutos, y assertSoftly para informar de todos los fallos de un caso a la vez. Explica por qué esta prueba y las del apartado 7 no se solapan.

Ejercicio 2

Escribe ServicioJwtCaducidadTest con dos pruebas que usen Clock para verificar la regla de 05-04 —el token de acceso caduca a los quince minutos— sin esperar quince minutos: una que compruebe que un token generado hace 14 minutos sigue siendo válido y otra que uno de hace 16 ya no lo es. Supón que ServicioJwt recibe JwtProperties y Clock por constructor. Razona por qué la prueba del minuto 16 es más importante, y qué hace clockSkewSeconds(30) con el caso de los 15 minutos y 10 segundos.

Ejercicio 3

Refactoriza esta prueba, que reúne cinco defectos de los vistos en la lección, y enumera cuáles son:

public class TestEstaciones {
    static List<Estacion> estaciones = new ArrayList<>();

    @Test
    public void test1() {
        estaciones.add(new Estacion("Plaza Mayor", "Plaza Mayor, 1", 24));
        if (estaciones.size() > 0) { assertNotNull(estaciones.get(0)); }
        BigDecimal importe = new TarifaEstandar().calcular(Duration.ofMinutes(30));
        assertEquals(new BigDecimal("4.1"), importe);
    }
}

Soluciones

Solución 1

@DisplayName("Propiedades que cumplen TODAS las tarifas de Ribalta")
@ParameterizedTest(name = "{0} con {1} min")
@MethodSource("todasLasTarifasPorTodasLasDuraciones")
void ningunaTarifaProduceImportesInvalidos(String nombre, long minutos) {
    BigDecimal importe = tarifaPorNombre(nombre).calcular(Duration.ofMinutes(minutos));
    assertSoftly(c -> {
        c.assertThat(importe)
         .as("el importe de %s por %d min no puede ser negativo", nombre, minutos)
         .isGreaterThanOrEqualTo(BigDecimal.ZERO);
        c.assertThat(importe.scale())
         .as("los importes en euros se expresan con dos decimales").isEqualTo(2);
    });
}

static Stream<Arguments> todasLasTarifasPorTodasLasDuraciones() {
    List<Long> duraciones = List.of(0L, 1L, 15L, 30L, 45L, 120L);
    return Stream.of("estandar", "estudiante", "jubilado")
            .flatMap(t -> duraciones.stream().map(d -> Arguments.of(t, d)));
}

Comentario: no se solapan porque comprueban cosas distintas. Las del apartado 7 son pruebas basadas en ejemplos: verifican un valor exacto para una entrada exacta y detectan errores de fórmula. Esta es una prueba basada en propiedades: no sabe cuánto debe costar, pero afirma invariantes que se cumplen siempre. Detecta otra clase de errores —un importe negativo por una resta sin Math.max, o una escala perdida por olvidar setScale— y sigue siendo válida cuando el ayuntamiento cambie los precios, cosa que las del apartado 7 no. El as(...) añade contexto al mensaje de fallo, imprescindible con 18 combinaciones. Y .scale() es exigente a propósito: una tarifa que devuelva BigDecimal.ZERO (escala 0) en vez de new BigDecimal("0.00") fallará, y es correcto que falle: un importe de factura debe formatearse como 0,00 €.

Solución 2

class ServicioJwtCaducidadTest {

    private static final Instant EMISION = Instant.parse("2026-03-14T09:00:00Z");
    private static final JwtProperties PROPIEDADES = new JwtProperties(
            "clave-de-prueba-de-al-menos-43-caracteres-para-firmar-hs256",
            Duration.ofMinutes(15), Duration.ofDays(30), "ciclourbana", "app-ribalta");

    private String tokenEmitidoALasNueve() {
        return new ServicioJwt(PROPIEDADES, Clock.fixed(EMISION, ZoneOffset.UTC))
                .generarAcceso(UsuariosDePrueba.marta());
    }

    /** El mismo servicio, con el reloj adelantado el desfase indicado. */
    private ServicioJwt validadorAdelantado(Duration d) {
        return new ServicioJwt(PROPIEDADES, Clock.fixed(EMISION.plus(d), ZoneOffset.UTC));
    }

    @Test
    void aceptaElTokenCatorceMinutosDespuesDeEmitirlo() {
        String token = tokenEmitidoALasNueve();
        String correo = validadorAdelantado(Duration.ofMinutes(14)).extraerCorreo(token);

        assertThat(correo).isEqualTo("[email protected]");
    }

    /*
     * La prueba que de verdad protege la regla. La del minuto 14 pasaría
     * igual con una caducidad de 24 horas, o sin caducidad: comprueba que
     * algo NO ocurre TODAVÍA. Esta comprueba que la caducidad EXISTE. En
     * una regla de "hasta X", el caso valioso está al otro lado del límite.
     */
    @Test
    void rechazaElTokenDieciseisMinutosDespuesDeEmitirlo() {
        String token = tokenEmitidoALasNueve();
        ServicioJwt validador = validadorAdelantado(Duration.ofMinutes(16));

        assertThatThrownBy(() -> validador.extraerCorreo(token))
                .isInstanceOf(ExpiredJwtException.class);
    }
}

Sobre clockSkewSeconds(30): la tolerancia de 05-04 hace que un token de 15 minutos y 10 segundos siga siendo aceptado, porque está dentro del margen previsto para el desfase entre máquinas. Es correcto y deliberado, pero significa que una prueba de caducidad no debe situarse a pocos segundos del límite: sería frágil y ambigua. Por eso los casos elegidos son 14 y 16 minutos, holgadamente a cada lado. Si quisieras verificar la tolerancia en sí, ese es un tercer caso con su propio nombre: aceptaElTokenDentroDelMargenDeTreintaSegundos.

Solución 3

Los cinco defectos:

  1. static List<Estacion> estaciones: estado compartido entre pruebas. Rompe el aislamiento y hace que el resultado dependa del orden.
  2. TestEstaciones y test1: nombres que no describen ninguna regla. Cuando falle en la integración continua habrá que abrir el fichero.
  3. if (estaciones.size() > 0): lógica condicional. Si la lista estuviera vacía, la prueba pasaría sin comprobar nada.
  4. Dos conceptos en un método: la gestión de estaciones y el cálculo de la tarifa no tienen relación. Dos razones para fallar, y ningún nombre puede describirlas.
  5. assertEquals(new BigDecimal("4.1"), importe): falla por la escala, usa aserciones de JUnit en vez de AssertJ y pone los argumentos en el orden esperado-real, propenso a invertirse. Como añadido, assertNotNull sobre un objeto que la propia prueba acaba de crear no verifica nada del código de producción.

Refactorizada, son dos clases:

class TarifaEstandarTest {
    @Test
    void cobraCuatroEurosDiezPorMediaHora() {
        assertThat(new TarifaEstandar().calcular(Duration.ofMinutes(30)))
                .isEqualByComparingTo("4.10");
    }
}

@DisplayName("EstacionService · alta de estaciones")
class EstacionServiceTest {

    private final EstacionService servicio =
            new EstacionService(new EstacionRepositorioEnMemoria());

    @Test
    void guardaLaEstacionYLeAsignaIdentificador() {
        Estacion guardada = servicio.crear(EstacionesDePrueba.plazaMayor());

        assertThat(guardada.id()).isNotNull();
        assertThat(servicio.buscarPorId(guardada.id())).isPresent().get()
                .extracting(Estacion::nombre).isEqualTo("Plaza Mayor");
    }
}

El aserto sobre buscarPorId es lo que convierte la segunda en una prueba de verdad: no comprueba que el objeto recién creado no sea nulo —eso ya lo sabemos—, sino que el servicio lo ha guardado y sabe recuperarlo, que es la responsabilidad real de crear.

Conclusión

La base de la pirámide de CicloUrbana ya tiene cimientos. Conoces la arquitectura de JUnit 5 —Platform, Jupiter y Vintage— y la trampa del import equivocado que hace que una prueba no se ejecute nunca sin decirlo. Dominas el ciclo de vida completo y, sobre todo, la decisión que lo gobierna: una instancia nueva de la clase por cada @Test, la garantía de aislamiento sobre la que descansa que el orden de ejecución no importe. Sabes usar @BeforeEach para preparación incremental, por qué @BeforeAll es static y por qué un @Disabled sin motivo es peor que no tener la prueba.

Tienes el catálogo de AssertJ por tipo de dato con sus casos delicados: usingRecursiveComparison para entidades cuyo equals compara por identificador, la diferencia entre containsExactly, containsExactlyInAnyOrder y contains, extracting con tuplas y, la que más disgustos evita, isEqualByComparingTo para todo BigDecimal, porque el equals compara la escala y en Ribalta se factura en euros. Sabes exigir el tipo concreto de excepción con assertThatThrownBy, comprobar el codigo estable de las excepciones de 03-06 y agrupar los asertos de un objeto compuesto con assertSoftly. Y has escrito el ejemplo estrella del módulo: las tres tarifas de Ribalta con sus franquicias y sus límites exactos —14, 15 y 16 minutos; 30 y 31— en una sola clase parametrizada, donde @CsvSource cubre lo simple y @MethodSource lo complejo, y donde el nombre de cada caso identifica el fallo sin abrir el fichero. Sabes organizar por escenarios con @Nested, poner nombres legibles con @DisplayName, cortar la suite con @Tag sin abusar, aislar ficheros con @TempDir y omitir con criterio mediante suposiciones. Y has cobrado la deuda del módulo 2: el Clock inyectado convierte el tiempo en un argumento, y Clock.fixed hace que un alquiler de dos horas se pruebe en dos milisegundos. Cierras con EstacionService probado sin una sola línea de Spring —veinte milisegundos frente a varios segundos— y con los datos encapsulados en EstacionesDePrueba y un builder de alquileres, para que cada prueba mencione solo lo que le importa.

Queda un límite evidente. EstacionService se pudo probar así porque existe EstacionRepositorioEnMemoria, un fake que escribimos en el módulo 2 y que ya no representa la implementación real. AlquilerService es otra historia: depende de cuatro repositorios JPA, del SelectorTarifa, del Clock y del publicador de eventos, y no hay ningún fake para ninguno de ellos. Escribirlos a mano sería absurdo. La lección siguiente, Simulación con Mockito, resuelve exactamente eso: crear dobles al vuelo, programar sus respuestas, forzar los escenarios difíciles —la bicicleta no disponible, la estación llena, el fallo del repositorio— y verificar que AlquilerService publicó el evento AlquilerIniciado que debía. Con eso, la clase más importante de CicloUrbana quedará bajo prueba.

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