La lliçó anterior va deixar CicloUrbana amb una prova: TarifaEstandardTest, dotze línies que afirmen que trenta minuts costen 4,10 €. Va servir per il·lustrar el patró Preparar-Actuar-Comprovar, però és evident que no n'hi ha prou. Ribalta té tres tarifes, cadascuna amb la seva franquícia de minuts gratuïts, i els casos que importen són els límits: zero minuts, exactament quinze, exactament trenta. Escriure dotze classes gairebé idèntiques seria el pitjor camí possible.

Aquesta lliçó omple la base de la piràmide amb l'eina adequada. Veurem l'arquitectura de JUnit 5, el catàleg d'anotacions amb el seu cicle de vida demostrat en execució, AssertJ a fons —l'estil d'asserció de tot el curs— amb els seus paranys de BigDecimal i les seves assercions toves, les proves parametritzades aplicades a les tarifes de Ribalta en una sola classe llegible, l'organització per escenaris amb @Nested, el Clock.fixed que fa determinista el càlcul de la durada d'un lloguer i les bones pràctiques de dades de prova. Tot sense aixecar el context d'Spring ni una sola vegada: aquí no cal, i aquesta és precisament la lliçó.

Contingut

  1. L'arquitectura de JUnit 5
  2. Les anotacions fonamentals i el seu cicle de vida
  3. AssertJ: per què és l'estil del curs
  4. El catàleg d'assercions per tipus
  5. Excepcions i assercions toves
  6. Proves parametritzades
  7. La CalculadoraTarifa parametritzada
  8. Organitzar amb @Nested, @DisplayName i @Tag
  9. Provar codi dependent del temps
  10. @TempDir, suposicions i @RepeatedTest
  11. EstacioService sense Spring
  12. Dades de prova: Object Mother i builders
  13. Errors Comuns i Consells
  14. Exercicis

  1. L'arquitectura de JUnit 5

JUnit 5 no és una biblioteca, són tres mòduls amb responsabilitats separades:

Mòdul Què és Qui l'usa
JUnit Platform Motor de descobriment i execució; defineix l'API que consumeixen les eines Maven Surefire, IntelliJ, Eclipse
JUnit Jupiter El model de programació modern: @Test, @BeforeEach, extensions Tu, en escriure proves
JUnit Vintage Motor que executa proves de JUnit 3 i 4 sobre la Platform Projectes amb història

La separació té una conseqüència pràctica: la Platform pot executar diversos motors alhora, de manera que un projecte en migració fa córrer Jupiter i Vintage a la mateixa construcció. CicloUrbana neix en JUnit 5, així que Vintage no és al classpath i no s'hi ha d'afegir: només serveix per arrossegar deute.

flowchart TB
    IDE["IntelliJ / Eclipse / Maven Surefire"] --> P["JUnit Platform<br/>(descobreix i executa)"]
    P --> J["Jupiter Engine<br/>@Test de JUnit 5"]
    P --> V["Vintage Engine<br/>@Test de JUnit 4"]
    J --> T["Les teves proves<br/>TarifaEstandardTest"]

L'error associat més habitual: barrejar org.junit.Test (JUnit 4) amb org.junit.jupiter.api.Test (JUnit 5) al mateix fitxer. La prova compila i no s'executa mai, sense avís. Si un mètode «no apareix» a l'informe, mira l'import abans que cap altra cosa.

  1. Les anotacions fonamentals i el seu cicle de vida

Anotació Quan s'executa Mètode Ús típic a CicloUrbana
@Test És la prova D'instància Tots els casos
@BeforeEach Abans de cada @Test D'instància Construir l'objecte sota prova
@AfterEach Després de cada @Test D'instància Tancar recursos oberts
@BeforeAll Una vegada, abans de totes static Carregar una cosa costosa i immutable
@AfterAll Una vegada, després de totes static Alliberar aquest recurs
@DisplayName — Classe o mètode Frase llegible a l'informe
@Disabled — Classe o mètode Desactivar, sempre amb motiu
@Nested / @Tag — Classe interna no static / qualsevol Agrupar per escenari / etiquetar
flowchart TB
    A["@BeforeAll (static) · una vegada"] --> B["Nova instància de la classe"]
    B --> C["@BeforeEach"] --> D["@Test"] --> E["@AfterEach"]
    E --> F{"Queden proves?"}
    F -- "Sí" --> B
    F -- "No" --> G["@AfterAll (static) · una vegada"]

El punt que sorprèn qui ve de JUnit 4 és el segon requadre: JUnit crea una instància nova de la classe de prova per a cada mètode @Test. És aïllament deliberat: un camp que una prova modifiqui no el veurà la següent, perquè la següent corre sobre un altre objecte. Aquesta prova ho demostra:

class CicleDeVidaTest {

    private int comptador = 0;   // camp d'instància: es reinicia a cada prova

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

    @BeforeEach
    void abansDeCadascuna(TestInfo info) {   // JUnit injecta TestInfo si el demanes
        comptador++;
        System.out.println("  2. @BeforeEach — " + info.getDisplayName()
                + " · comptador=" + comptador + " · instancia=" + hashCode());
    }

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

L'important és en dos números: comptador val 1 a totes dues proves, no 1 i 2, i el hashCode és diferent, que n'és la causa. Aquesta és la garantia d'independència sobre la qual descansa tota la resta; l'ordre d'execució és determinista però no el del fitxer, i mai no ha d'importar.

Dos detalls amb conseqüències. @BeforeAll i @AfterAll han de ser static, justament perquè no hi ha una instància que duri més que una prova; existeix l'alternativa @TestInstance(Lifecycle.PER_CLASS), que aquest curs no fa servir perquè obre la porta a que l'estat d'una prova contamini la següent. I @Disabled sense motiu és brossa acumulada: escriu @Disabled("Bloquejat per CU-412; reactivar en corregir-ho"), o la prova es quedarà desactivada per sempre donant la impressió que alguna cosa està coberta.

El paràmetre TestInfo de l'exemple no és casual: JUnit 5 injecta paràmetres als mètodes mitjançant resoledors, i és el mateix mecanisme amb el qual @ExtendWith(MockitoExtension.class) injectarà els mocks a 06-03.

  1. AssertJ: per què és l'estil del curs

JUnit porta les seves pròpies assercions i funcionen. AssertJ, inclòs també a spring-boot-starter-test, fa la mateixa feina molt millor:

Aspecte JUnit Assertions AssertJ
Sintaxi assertEquals(esperat, real) assertThat(real).isEqualTo(esperat)
Ordre d'arguments Esperat primer: s'inverteix constantment Subjecte primer: es llegeix com una frase
Descobriment Cal recordar el nom del mètode El punt després d'assertThat(...) ofereix el que és aplicable al tipus
Encadenament No Sí, diverses comprovacions sobre el mateix subjecte
Col·leccions Molt pobre contains, extracting, filteredOn, allMatch
Missatge de fallada expected: <4.10> but was: <5.00> A més assenyala el camp i l'element concrets

La inversió d'arguments produeix missatges que menteixen: assertEquals(importTotal, new BigDecimal("4.10")) informa «expected: 5.00 but was: 4.10», al revés, i a depurar. Amb AssertJ l'error és impossible. Regla del curs: totes les assercions s'escriuen amb AssertJ; l'única excepció és Hamcrest dins de jsonPath(...) a MockMvc (06-04), perquè aquella API ho exigeix.

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

  1. El catàleg d'assercions per tipus

Objectes generals:

Asserció Què comprova
isEqualTo(x) / isNotEqualTo(x) Igualtat per equals
isSameAs(x) Identitat de referència (==)
isNull() / isNotNull() / isInstanceOf(C.class) Nul·litat i tipus
usingRecursiveComparison().isEqualTo(altre) Igualtat camp a camp, sense usar equals
extracting(Estacio::getNom, Estacio::getCapacitat) Extreu diversos camps per comparar-los junts

usingRecursiveComparison() resol un problema real: les entitats JPA de 04-03 defineixen equals per identificador, així que isEqualTo diria que dues estacions amb el mateix id són iguals encara que una tingui el nom canviat. La forma habitual és assertThat(desada).usingRecursiveComparison().ignoringFields("id", "versio", "creatEl", "modificatEl").isEqualTo(esperada), ignorant el que assigna la base de dades.

Cadenes i números:

assertThat(bicicleta.getMatricula())
        .isNotBlank()                       // ni null, ni buida, ni només espais
        .startsWith("RB-").hasSize(7)
        .matches("RB-\\d{4}");              // el format de Ribalta

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

BigDecimal: el parany que cal memoritzar.

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

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

BigDecimal.equals compara valor i escala: 4.1 té escala 1 i 4.10 escala 2, per tant no són equals encara que compareTo retorni 0. Com que isEqualTo usa equals, una prova correcta pot fallar només perquè el càlcul va produir un decimal més. Amb diners, sempre isEqualByComparingTo. A CicloUrbana afecta tots els imports i tarifes.

Optional i col·leccions, on AssertJ és incomparable:

assertThat(estacioRepositori.cercarPerId(1L))
        .isPresent().get()
        .extracting(Estacio::getNom).isEqualTo("Plaça Major");
assertThat(estacioRepositori.cercarPerId(99L)).isEmpty();

assertThat(estacions).hasSize(4)
        .extracting(Estacio::getNom, Estacio::getCapacitat)         // tuples
        .containsExactlyInAnyOrder(
                tuple("Plaça Major", 24), tuple("Estació Nord", 30),
                tuple("Parc del Riu", 18), tuple("Universitat", 36));

assertThat(estacions).allMatch(e -> e.getCapacitat() >= 8);        // regla de Ribalta

La diferència entre els mètodes de contingut s'oblida sempre:

Mètode Ordre Elements extra permesos
containsExactly Importa No
containsExactlyInAnyOrder No importa No
contains No importa Sí

I sobre mapes, aplicat al SelectorTarifa de 02-02, assertThat(selector.disponibles()).hasSize(3).containsKeys("estandard", "estudiant", "jubilat").doesNotContainKey("TarifaEstandard") verifica d'una sola vegada el registre complet i, amb l'última clàusula, la fallada d'oblidar nom() en una tarifa nova.

  1. Excepcions i assercions toves

Tres formes de verificar una fallada, en ordre de preferència:

// 1. assertThatThrownBy — l'habitual
assertThatThrownBy(() -> lloguerService.iniciar(peticioAmbBateriaEsgotada))
        .isInstanceOf(BicicletaNoDisponibleException.class)
        .hasMessageContaining("RB-0142")
        .hasFieldOrPropertyWithValue("codi", "BICICLETA_NO_DISPONIBLE");

// 2. assertThatExceptionOfType — més explícita sobre el tipus
assertThatExceptionOfType(EstacioPlenaException.class)
        .isThrownBy(() -> lloguerService.finalitzar(1L, aPlacaMajorPlena))
        .withMessageContaining("no té ancoratges lliures");

// 3. catchThrowable — quan cal inspeccionar l'objecte en profunditat
Throwable error = catchThrowable(() -> serveiJwt.extreureCorreu(tokenCaducat));
assertThat(error).isInstanceOf(ExpiredJwtException.class);

assertThatNoException().isThrownBy(() -> estacioService.validarCapacitat(24));

Comprovar el codi —el camp estable de CicloUrbanaException de 03-06— és el que converteix l'asserció en una verificació del contracte amb el client de l'API. L'asserció perillosament feble és isInstanceOf(RuntimeException.class): qualsevol NullPointerException accidental la satisfà i tenyeix la prova de verd.

Assercions toves. Quan una prova té diverses assercions, la primera que falla amaga les altres: n'arregles una, executes, falla la segona. assertSoftly les avalua totes:

@Test
void elLloguerRetornatTeTotsElsSeusCampsCorrectes() {
    LloguerResponse resposta = lloguerService.iniciar(peticioValida);

    assertSoftly(c -> {
        c.assertThat(resposta.matriculaBicicleta()).isEqualTo("RB-0142");
        c.assertThat(resposta.estacioOrigen()).isEqualTo("Plaça Major");
        c.assertThat(resposta.estat()).isEqualTo(EstatLloguer.EN_CURS);
        c.assertThat(resposta.fi()).isNull();
        c.assertThat(resposta.importTotal()).isNull();
    });
}

Són ideals per verificar un sol objecte compost: un concepte expressat en diversos camps. No són una llicència per ficar cinc comprovacions inconnexes en una prova.

  1. Proves parametritzades

Una prova parametritzada executa el mateix mètode amb dades diferents. És la resposta al problema amb què obria la lliçó, i junit-jupiter-params ja ve al starter.

Font Què aporta Exemple
@ValueSource Un array de literals d'un sol tipus ints = {0, 1, 15, 30}
@CsvSource Diverses columnes escrites en línia "30, 4.10"
@CsvFileSource Les mateixes columnes des d'un fitxer de src/test/resources Centenars de casos
@EnumSource Tots (o alguns) valors d'un enum EstatBicicleta.class
@NullSource, @EmptySource, @NullAndEmptySource Els casos degenerats Validacions de cadenes
@MethodSource Un mètode static que retorna Stream<Arguments> Objectes complexos
@ArgumentsSource Un proveïdor propi, reutilitzable entre classes Catàlegs del domini
@ParameterizedTest(name = "una capacitat de {0} ancoratges s'ha de rebutjar")
@ValueSource(ints = {-5, 0, 1, 7})
void rebutjaCapacitatsPerSotaDelMinim(int capacitat) {
    assertThatThrownBy(() -> estacioService.validarCapacitat(capacitat))
            .isInstanceOf(ReglaNegociException.class);
}
@ParameterizedTest
@NullAndEmptySource @ValueSource(strings = {"  ", "\t"})
void rebutjaNomsDEstacioBuits(String nom) {
    assertThatThrownBy(() -> estacioService.validarNom(nom))
            .isInstanceOf(ReglaNegociException.class);
}

@ParameterizedTest
@EnumSource(value = EstatBicicleta.class, names = {"MANTENIMENT", "RETIRADA", "EN_US"})
void capEstatDiferentDeDisponiblePermetLlogar(EstatBicicleta estat) {
    assertThat(BicicletesDeProva.ambEstat(estat).esPotLlogar(20)).isFalse();
}

L'atribut name mereix atenció: sense ell l'informe mostra [1], [2], [3]; amb ell, «una capacitat de -5 ancoratges s'ha de rebutjar». Els marcadors són {0}, {1}... per als arguments, {index} per al número de cas i {displayName}.

  1. La CalculadoraTarifa parametritzada

L'exemple central de la lliçó: les tres tarifes de Ribalta, amb les seves regles i els seus límits, en una sola classe llegible. Les regles de 02-02:

Tarifa Desbloqueig €/minut Minuts gratis Mínim facturable
estandard 0,50 € 0,12 € 0 1 minut
estudiant 0 € 0,08 € 15 —
jubilat 0 € 0,05 € 30 —
package com.ciclourbana.lloguers;

@DisplayName("Càlcul de tarifes de la xarxa de Ribalta")
class CalculadoraTarifaTest {

    @DisplayName("Tarifa estàndard: 0,50 € de desbloqueig + 0,12 €/minut")
    @ParameterizedTest(name = "{0} min -> {1} €")
    @CsvSource({"0, 0.62",   // es factura el mínim d'1 minut: 0,50 + 0,12
                "1, 0.62", "10, 1.70", "30, 4.10",
                "120, 14.90"})   // el màxim permès per XarxaProperties
    void tarifaEstandard(long minuts, BigDecimal esperat) {
        assertThat(new TarifaEstandard().calcular(Duration.ofMinutes(minuts)))
                .isEqualByComparingTo(esperat);
    }

    @DisplayName("Cada tarifa aplica la seva franquícia i el seu preu per minut")
    @ParameterizedTest(name = "[{index}] {0}: {1} min -> {2} €")
    @MethodSource("casosDeTarifa")
    void cadaTarifaCalculaElSeuImport(String nom, long minuts, BigDecimal esperat) {
        assertThat(tarifaPerNom(nom).calcular(Duration.ofMinutes(minuts)))
                .isEqualByComparingTo(esperat);
    }

    /** El proveïdor: static, sense arguments, retorna Stream<Arguments>. */
    static Stream<Arguments> casosDeTarifa() {
        return Stream.of(
                Arguments.of("estandard",  1, new BigDecimal("0.62")),   // sense franquícia
                Arguments.of("estandard", 30, new BigDecimal("4.10")),
                Arguments.of("estudiant", 14, new BigDecimal("0.00")),   // 15 min gratis,
                Arguments.of("estudiant", 15, new BigDecimal("0.00")),   // LÍMIT INCLUSIU
                Arguments.of("estudiant", 16, new BigDecimal("0.08")),
                Arguments.of("estudiant", 45, new BigDecimal("2.40")),
                Arguments.of("jubilat",   30, new BigDecimal("0.00")),   // 30 min gratis
                Arguments.of("jubilat",   31, new BigDecimal("0.05")),
                Arguments.of("jubilat",   90, new BigDecimal("3.00")));
    }

    private static CalculadoraTarifa tarifaPerNom(String nom) {
        return switch (nom) {
            case "estandard" -> new TarifaEstandard();
            case "estudiant" -> new TarifaEstudiant();
            case "jubilat" -> new TarifaJubilat();
            default -> throw new IllegalArgumentException(nom);
        };
    }
}

Què fa bo aquest disseny:

  • Els casos límit de cada franquícia són junts i a la vista: 14, 15 i 16 minuts per a l'estudiant; 30 i 31 per al jubilat. Llegint la llista es veu que el límit és inclusiu. És la documentació viva de 06-01 en la seva forma més pura.
  • @CsvSource converteix automàticament "4.10" a BigDecimal, "30" a long i "MANTENIMENT" a la constant de l'enum (per a tipus propis existeix @ConvertWith), i @MethodSource és la font per a objectes complexos: el seu mètode ha de ser static, tret que s'usi Lifecycle.PER_CLASS.
  • Una fallada assenyala el cas exacte: l'informe dirà [5] estudiant: 16 min -> 0.08 €, no «ha fallat la prova de tarifes».

Quan la llista creix i es comparteix entre classes, @ArgumentsSource l'extreu a un ArgumentsProvider reutilitzable, amb la mateixa forma que casosDeTarifa però implementant provideArguments(ExtensionContext).

  1. Organitzar amb @Nested, @DisplayName i @Tag

Una classe amb vint mètodes plans és tan illegible com un mètode de dues-centes línies. @Nested agrupa per escenari, i cada grup té el seu propi @BeforeEach:

@DisplayName("EstacioService")
class EstacioServiceTest {

    private EstacioService servei;

    @BeforeEach
    void prepararServei() {
        servei = new EstacioService(new EstacioRepositoriEnMemoria());
    }

    @Nested @DisplayName("quan es dona d'alta una estació")
    class AlDonarDAlta {
        @Test @DisplayName("l'accepta si compleix el mínim d'ancoratges")
        void acceptaCapacitatSuficient() { /* ... */ }
    }

    @Nested @DisplayName("quan l'estació ja existeix")
    class AmbEstacioExistent {

        @BeforeEach   // s'executa DESPRÉS del @BeforeEach extern
        void donarDAltaPlacaMajor() { servei.crear(EstacionsDeProva.placaMajor()); }

        @Test @DisplayName("rebutja un nom duplicat amb 409")
        void rebutjaNomDuplicat() { /* ... */ }
    }
}

Regles que cal conèixer: la classe interna no pot ser static —necessita la instància externa—, i els @BeforeEach s'acumulen de fora cap a dins, que és el que permet la preparació incremental. L'informe es mostra jeràrquic i es llegeix com una especificació:

EstacioService
├─ quan es dona d'alta una estació · ✔ l'accepta si compleix el mínim d'ancoratges
└─ quan l'estació ja existeix · ✔ rebutja un nom duplicat amb 409

@DisplayName no substitueix un bon nom de mètode: el nom és el que apareix a les traces de fallada i als filtres de -Dtest=.

@Tag talla la suite per criteris transversals al nom del fitxer:

./mvnw test -Dgroups=tarifes             # només les etiquetades
./mvnw test -DexcludedGroups=lent        # tot menys les lentes

Fes servir poques etiquetes i amb significat clar: a CicloUrbana n'hi ha prou amb lent i seguretat. I recorda que per separar unitàries d'integració ja tenim un mecanisme millor, el sufix *IT amb Failsafe de 06-01, que no depèn que ningú es recordi d'anotar.

  1. Provar codi dependent del temps

Aquí es cobra una decisió presa a 02-01. ConfiguracioComuna declara @Bean Clock rellotgeRibalta(), i en aquell moment va semblar una formalitat. Si LloguerService calculés la durada amb Instant.now(), provar un lloguer de dues hores exigiria esperar dues hores, canviar el rellotge del sistema o simular un mètode estàtic (06-03: es pot, i gairebé mai no s'ha de fer). Amb el Clock injectat —Instant.now(clock)— el temps és un argument més:

class DuradaDelLloguerTest {

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

    @Test
    void calculaDuesHoresDeLloguerSenseEsperarDuesHores() {
        // Preparar: un rellotge congelat dues hores després de l'inici
        Clock rellotge = Clock.fixed(INICI.plus(Duration.ofHours(2)), ZoneOffset.UTC);
        Lloguer lloguer = LloguersDeProva.enCursDesDe(INICI);
        // Actuar
        Duration durada = Duration.between(lloguer.getInici(), Instant.now(rellotge));
        // Comprovar
        assertThat(durada.toMinutes()).isEqualTo(120);
    }
}

Les dues fàbriques que es fan servir en proves:

Fàbrica Què fa Quan
Clock.fixed(instant, zona) El temps no avança: sempre el mateix instant El 99 % dels casos
Clock.offset(base, durada) Desplaça un rellotge una quantitat fixa Simular «d'aquí a 16 minuts»

La segona és la que permet provar que un token JWT caduca als quinze minuts: es genera amb un rellotge i es valida amb un altre avançat setze. Sense esperar setze minuts.

La regla general: qualsevol font de no determinisme ha de ser injectable. El rellotge, el generador aleatori —el Random amb llavor fixa de 02-01 respon a la mateixa idea— i el generador d'identificadors. Un new Random() o un UUID.randomUUID() incrustats a la lògica fan impossible una asserció exacta.

  1. @TempDir, suposicions i @RepeatedTest

@TempDir dona un directori que JUnit crea abans i esborra després, sense codi de neteja:

@Test
void exportaLInformeDOcupacioEnCsv(@TempDir Path directori) throws IOException {
    Path fitxer = directori.resolve("ocupacio-ribalta.csv");
    exportador.exportar(EstacionsDeProva.lesQuatreDeRibalta(), fitxer);

    assertThat(Files.readAllLines(fitxer)).hasSize(5)     // capçalera + 4 estacions
            .first().isEqualTo("estacio;capacitat;lliures");
}

Les suposicions avorten la prova sense marcar-la com a fallida quan no es donen les condicions: assumeTrue(DockerClientFactory.instance().isDockerAvailable(), "Docker no disponible") l'omet sencera, i assumingThat(condicio, () -> assertThat(...)) omet només un bloc d'assercions. La diferència amb @Disabled és que aquest desactiva sempre i la suposició decideix en execució. El matís que cal vigilar: una prova omesa apareix en verd al resum, així que una suposició laxa amaga que alguna cosa no s'executa mai. Posa'ls sempre missatge.

@RepeatedTest(100) executa la mateixa prova diverses vegades; el seu ús legítim és comprovar alguna cosa amb aleatorietat interna, com un generador de matrícules RB-\d{4}. No serveix per detectar problemes de concurrència: repetir cent vegades al mateix fil no crea contenció. I si una prova passa 99 vegades de 100, no tens una prova escamosa: tens un error.

  1. EstacioService sense Spring

Posant-ho tot junt sobre una classe real. EstacioService rep el seu repositori per constructor (02-02), així que li lliurem l'EstacioRepositoriEnMemoria de 02-01 —el fake de la taula de dobles de 06-01— i no cal res més:

@DisplayName("EstacioService · regles de la xarxa de Ribalta")
class EstacioServiceTest {

    private EstacioService servei;

    @BeforeEach
    void prepararServeiNet() {
        // Instància nova a CADA prova: sense estat compartit, sense ordre implícit
        servei = new EstacioService(new EstacioRepositoriEnMemoria());
    }

    @ParameterizedTest(name = "{0} ancoratges s'accepten")
    @ValueSource(ints = {8, 18, 24, 30, 36, 60})
    void acceptaLesCapacitatsDeRibalta(int capacitat) {
        assertThatNoException().isThrownBy(() -> servei.validarCapacitat(capacitat));
    }

    @ParameterizedTest(name = "{0} ancoratges es rebutgen")
    @ValueSource(ints = {-1, 0, 7})
    void rebutjaMenysDeVuitAncoratges(int capacitat) {
        assertThatThrownBy(() -> servei.validarCapacitat(capacitat))
                .isInstanceOf(ReglaNegociException.class).hasMessageContaining("8");
    }

    @Test
    void retornaBuitQuanNoExisteix() { assertThat(servei.cercarPerId(999L)).isEmpty(); }
}

Per què aquesta prova no aixeca el context, encara que EstacioService sigui un @Service:

Amb @SpringBootTest Aquesta prova
2–6 s d'arrencada del context < 20 ms
Necessita base de dades, o simular-la Un ConcurrentHashMap
Una fallada pot venir de qualsevol capa La fallada és a EstacioService
Prova el cablejat i la lògica Prova només la lògica

L'anotació @Service no impedeix instanciar la classe amb new: són metadades que Spring llegeix a l'arrencada. Una prova unitària no aixeca el context perquè no està provant el context. Que el cablejat funciona es comprova una vegada, a les proves d'integració de 06-04, no a cadascuna de les dues-centes proves de lògica.

  1. Dades de prova: Object Mother i builders

Ha aparegut diverses vegades EstacionsDeProva.placaMajor(). És el patró Object Mother: una fàbrica a src/test/java que produeix objectes del domini vàlids i amb nom de negoci.

/** Fàbrica d'estacions de Ribalta per a les proves. Només a src/test/java. */
public final class EstacionsDeProva {

    public static Estacio placaMajor()  { return new Estacio("Plaça Major",  "Plaça Major, 1", 24); }
    public static Estacio estacioNord() { return new Estacio("Estació Nord", "Avinguda Estació 3", 30); }
    public static Estacio parcDelRiu()  { return new Estacio("Parc del Riu", "Passeig Fluvial 12", 18); }
    public static Estacio universitat() { return new Estacio("Universitat",  "Campus Sud, accés B", 36); }

    public static List<Estacio> lesQuatreDeRibalta() {
        return List.of(placaMajor(), estacioNord(), parcDelRiu(), universitat());
    }

    /** Variant per al cas que necessiti un valor concret. */
    public static Estacio ambCapacitat(int c) { return new Estacio("Prova", "Carrer 1", c); }
}

Quan els objectes tenen molts camps i cada prova en varia un, el builder és més flexible:

public class LloguerBuilder {

    private Instant inici = Instant.parse("2026-03-14T09:00:00Z");
    private Instant fi = null;
    private EstatLloguer estat = EstatLloguer.EN_CURS;

    public static LloguerBuilder unLloguer() { return new LloguerBuilder(); }

    public LloguerBuilder finalitzatDespresDe(Duration durada) {
        this.fi = inici.plus(durada);
        this.estat = EstatLloguer.FINALITZAT;
        return this;
    }
    public Lloguer construir() { /* munta l'objecte */ }
}
// I la prova es llegeix així:
Lloguer lloguer = unLloguer().finalitzatDespresDe(Duration.ofMinutes(45)).construir();

El que s'hi guanya és substancial: la prova només menciona el que li importa. Els altres sis camps existeixen amb valors vàlids però no embruten la lectura, i si demà Lloguer guanya un camp obligatori es canvia el builder i no les quaranta proves.

Un consell sobre la línia que separa això d'un antipatró: l'objecte de la prova es construeix, no es calcula. Si el builder comença a contenir lògica —«si està finalitzat, calcula l'import amb la tarifa»— has duplicat la implementació dins de les proves, i llavors la prova passa encara que el codi estigui malament.

Errors Comuns i Consells

Barrejar org.junit.Test amb org.junit.jupiter.api.Test. La prova compila i no s'executa mai; si el nombre de proves de l'informe no quadra, revisa els import. A la mateixa família hi ha oblidar el static a @BeforeAll, @AfterAll o al mètode de @MethodSource: l'error de configuració és clar, però costa reconèixer-lo la primera vegada.

isEqualTo sobre BigDecimal. Falla per l'escala encara que el valor sigui correcte. Amb imports, sempre isEqualByComparingTo. És la fallada que més temps fa perdre en un domini amb diners com el de CicloUrbana.

Dependre de l'ordre d'execució. JUnit no el garanteix i crea una instància nova per prova per impedir-ho. Si una prova només passa quan una altra la precedeix, hi ha estat compartit —un camp static, un fitxer, un singleton—: arregla-ho, no ho ordenis amb @TestMethodOrder. A la mateixa família hi ha l'asserció laxa (isNotNull() com a única comprovació, o isInstanceOf(RuntimeException.class) esperant una excepció concreta): passa sempre i no protegeix de res.

Lògica condicional dins d'una prova. Un if (esCapDeSetmana) { ... } else { ... } significa que hi ha dues proves escrites en una i que a cada execució només es comprova una branca. La solució és una prova parametritzada, o dos mètodes.

Consell: una raó per fallar per prova. No vol dir un sol assertThat, sinó un sol concepte. Cinc assercions sobre els cinc camps d'un LloguerResponse són un concepte; comprovar l'import i que es va publicar l'esdeveniment són dos, i mereixen dos mètodes.

Consell: si necessites més de tres o quatre línies de preparació, mira la classe. Una preparació llarga és la queixa de la prova sobre el disseny: massa dependències, massa estat, massa responsabilitats.

Exercicis

Exercici 1

Amplia CalculadoraTarifaTest amb una prova parametritzada que verifiqui la propietat transversal de les tres tarifes: cap import no pot ser negatiu i tots han de tenir exactament dos decimals d'escala. Fes servir @MethodSource per combinar les tres tarifes amb les durades 0, 1, 15, 30, 45 i 120 minuts, i assertSoftly per informar de totes les fallades d'un cas alhora. Explica per què aquesta prova i les de l'apartat 7 no se superposen.

Exercici 2

Escriu ServeiJwtCaducitatTest amb dues proves que facin servir Clock per verificar la regla de 05-04 —el token d'accés caduca als quinze minuts— sense esperar quinze minuts: una que comprovi que un token generat fa 14 minuts continua sent vàlid i una altra que un de fa 16 ja no ho és. Suposa que ServeiJwt rep JwtProperties i Clock per constructor. Raona per què la prova del minut 16 és més important, i què fa clockSkewSeconds(30) amb el cas dels 15 minuts i 10 segons.

Exercici 3

Refactoritza aquesta prova, que reuneix cinc defectes dels vistos a la lliçó, i enumera quins són:

public class TestEstacions {
    static List<Estacio> estacions = new ArrayList<>();

    @Test
    public void test1() {
        estacions.add(new Estacio("Plaça Major", "Plaça Major, 1", 24));
        if (estacions.size() > 0) { assertNotNull(estacions.get(0)); }
        BigDecimal importTotal = new TarifaEstandard().calcular(Duration.ofMinutes(30));
        assertEquals(new BigDecimal("4.1"), importTotal);
    }
}

Solucions

Solució 1

@DisplayName("Propietats que compleixen TOTES les tarifes de Ribalta")
@ParameterizedTest(name = "{0} amb {1} min")
@MethodSource("totesLesTarifesPerTotesLesDurades")
void capTarifaProdueixImportsInvalids(String nom, long minuts) {
    BigDecimal importTotal = tarifaPerNom(nom).calcular(Duration.ofMinutes(minuts));
    assertSoftly(c -> {
        c.assertThat(importTotal)
         .as("l'import de %s per %d min no pot ser negatiu", nom, minuts)
         .isGreaterThanOrEqualTo(BigDecimal.ZERO);
        c.assertThat(importTotal.scale())
         .as("els imports en euros s'expressen amb dos decimals").isEqualTo(2);
    });
}

static Stream<Arguments> totesLesTarifesPerTotesLesDurades() {
    List<Long> durades = List.of(0L, 1L, 15L, 30L, 45L, 120L);
    return Stream.of("estandard", "estudiant", "jubilat")
            .flatMap(t -> durades.stream().map(d -> Arguments.of(t, d)));
}

Comentari: no se superposen perquè comproven coses diferents. Les de l'apartat 7 són proves basades en exemples: verifiquen un valor exacte per a una entrada exacta i detecten errors de fórmula. Aquesta és una prova basada en propietats: no sap quant ha de costar, però afirma invariants que es compleixen sempre. Detecta una altra classe d'errors —un import negatiu per una resta sense Math.max, o una escala perduda per oblidar setScale— i continua sent vàlida quan l'ajuntament canviï els preus, cosa que les de l'apartat 7 no. L'as(...) afegeix context al missatge de fallada, imprescindible amb 18 combinacions. I .scale() és exigent a propòsit: una tarifa que retorni BigDecimal.ZERO (escala 0) en lloc de new BigDecimal("0.00") fallarà, i és correcte que falli: un import de factura s'ha de formatar com a 0,00 €.

Solució 2

class ServeiJwtCaducitatTest {

    private static final Instant EMISSIO = Instant.parse("2026-03-14T09:00:00Z");
    private static final JwtProperties PROPIETATS = new JwtProperties(
            "clau-de-prova-de-com-a-minim-43-caracters-per-signar-hs256",
            Duration.ofMinutes(15), Duration.ofDays(30), "ciclourbana", "app-ribalta");

    private String tokenEmesALesNou() {
        return new ServeiJwt(PROPIETATS, Clock.fixed(EMISSIO, ZoneOffset.UTC))
                .generarAcces(UsuarisDeProva.marta());
    }

    /** El mateix servei, amb el rellotge avançat el desfasament indicat. */
    private ServeiJwt validadorAvancat(Duration d) {
        return new ServeiJwt(PROPIETATS, Clock.fixed(EMISSIO.plus(d), ZoneOffset.UTC));
    }

    @Test
    void acceptaElTokenCatorzeMinutsDespresDeEmetreLo() {
        String token = tokenEmesALesNou();
        String correu = validadorAvancat(Duration.ofMinutes(14)).extreureCorreu(token);

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

    /*
     * La prova que de veritat protegeix la regla. La del minut 14 passaria
     * igual amb una caducitat de 24 hores, o sense caducitat: comprova que
     * una cosa NO passa ENCARA. Aquesta comprova que la caducitat EXISTEIX. En
     * una regla de "fins a X", el cas valuós és a l'altra banda del límit.
     */
    @Test
    void rebutjaElTokenSetzeMinutsDespresDeEmetreLo() {
        String token = tokenEmesALesNou();
        ServeiJwt validador = validadorAvancat(Duration.ofMinutes(16));

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

Sobre clockSkewSeconds(30): la tolerància de 05-04 fa que un token de 15 minuts i 10 segons continuï sent acceptat, perquè està dins del marge previst per al desfasament entre màquines. És correcte i deliberat, però significa que una prova de caducitat no s'ha de situar a pocs segons del límit: seria fràgil i ambigua. Per això els casos triats són 14 i 16 minuts, folgadament a cada banda. Si volguessis verificar la tolerància en si, aquest és un tercer cas amb el seu propi nom: acceptaElTokenDinsDelMargeDeTrentaSegons.

Solució 3

Els cinc defectes:

  1. static List<Estacio> estacions: estat compartit entre proves. Trenca l'aïllament i fa que el resultat depengui de l'ordre.
  2. TestEstacions i test1: noms que no descriuen cap regla. Quan falli a la integració contínua caldrà obrir el fitxer.
  3. if (estacions.size() > 0): lògica condicional. Si la llista fos buida, la prova passaria sense comprovar res.
  4. Dos conceptes en un mètode: la gestió d'estacions i el càlcul de la tarifa no tenen relació. Dues raons per fallar, i cap nom no pot descriure-les.
  5. assertEquals(new BigDecimal("4.1"), importTotal): falla per l'escala, fa servir assercions de JUnit en lloc d'AssertJ i posa els arguments en l'ordre esperat-real, propens a invertir-se. A més, assertNotNull sobre un objecte que la mateixa prova acaba de crear no verifica res del codi de producció.

Refactoritzada, són dues classes:

class TarifaEstandardTest {
    @Test
    void cobraQuatreEurosDeuPerMitjaHora() {
        assertThat(new TarifaEstandard().calcular(Duration.ofMinutes(30)))
                .isEqualByComparingTo("4.10");
    }
}

@DisplayName("EstacioService · alta d'estacions")
class EstacioServiceTest {

    private final EstacioService servei =
            new EstacioService(new EstacioRepositoriEnMemoria());

    @Test
    void desaEstacioILiAssignaIdentificador() {
        Estacio desada = servei.crear(EstacionsDeProva.placaMajor());

        assertThat(desada.id()).isNotNull();
        assertThat(servei.cercarPerId(desada.id())).isPresent().get()
                .extracting(Estacio::nom).isEqualTo("Plaça Major");
    }
}

L'asserció sobre cercarPerId és el que converteix la segona en una prova de veritat: no comprova que l'objecte acabat de crear no sigui nul —això ja ho sabem—, sinó que el servei l'ha desat i el sap recuperar, que és la responsabilitat real de crear.

Conclusió

La base de la piràmide de CicloUrbana ja té fonaments. Coneixes l'arquitectura de JUnit 5 —Platform, Jupiter i Vintage— i el parany de l'import equivocat que fa que una prova no s'executi mai sense dir-ho. Domines el cicle de vida complet i, sobretot, la decisió que el governa: una instància nova de la classe per cada @Test, la garantia d'aïllament sobre la qual descansa que l'ordre d'execució no importi. Saps fer servir @BeforeEach per a preparació incremental, per què @BeforeAll és static i per què un @Disabled sense motiu és pitjor que no tenir la prova.

Tens el catàleg d'AssertJ per tipus de dada amb els seus casos delicats: usingRecursiveComparison per a entitats l'equals de les quals compara per identificador, la diferència entre containsExactly, containsExactlyInAnyOrder i contains, extracting amb tuples i, la que més disgustos evita, isEqualByComparingTo per a tot BigDecimal, perquè l'equals compara l'escala i a Ribalta es factura en euros. Saps exigir el tipus concret d'excepció amb assertThatThrownBy, comprovar el codi estable de les excepcions de 03-06 i agrupar les assercions d'un objecte compost amb assertSoftly. I has escrit l'exemple estrella del mòdul: les tres tarifes de Ribalta amb les seves franquícies i els seus límits exactes —14, 15 i 16 minuts; 30 i 31— en una sola classe parametritzada, on @CsvSource cobreix el que és simple i @MethodSource el que és complex, i on el nom de cada cas identifica la fallada sense obrir el fitxer. Saps organitzar per escenaris amb @Nested, posar noms llegibles amb @DisplayName, tallar la suite amb @Tag sense abusar-ne, aïllar fitxers amb @TempDir i ometre amb criteri mitjançant suposicions. I has cobrat el deute del mòdul 2: el Clock injectat converteix el temps en un argument, i Clock.fixed fa que un lloguer de dues hores es provi en dos mil·lisegons. Tanques amb EstacioService provat sense una sola línia d'Spring —vint mil·lisegons davant de diversos segons— i amb les dades encapsulades a EstacionsDeProva i un builder de lloguers, perquè cada prova mencioni només el que li importa.

Queda un límit evident. EstacioService s'ha pogut provar així perquè existeix EstacioRepositoriEnMemoria, un fake que vam escriure al mòdul 2 i que ja no representa la implementació real. LloguerService és una altra història: depèn de quatre repositoris JPA, del SelectorTarifa, del Clock i del publicador d'esdeveniments, i no hi ha cap fake per a cap d'ells. Escriure'ls a mà seria absurd. La lliçó següent, Simulació amb Mockito, resol exactament això: crear dobles al vol, programar-ne les respostes, forçar els escenaris difícils —la bicicleta no disponible, l'estació plena, la fallada del repositori— i verificar que LloguerService va publicar l'esdeveniment LloguerIniciat que tocava. Amb això, la classe més important de CicloUrbana quedarà sota prova.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats