The previous lesson left CicloUrbana with one test: StandardFareTest, twelve lines stating that thirty minutes cost €4.10. It served to illustrate the Arrange-Act-Assert pattern, but it is plainly not enough. Ribalta has three fares, each with its own allowance of free minutes, and the cases that matter are the boundaries: zero minutes, exactly fifteen, exactly thirty. Writing twelve almost identical classes would be the worst possible route.

This lesson fills the base of the pyramid with the right tool. We will look at the architecture of JUnit 5, the annotation catalogue with its lifecycle demonstrated at run time, AssertJ in depth —the assertion style of the whole course— with its BigDecimal traps and its soft assertions, parameterised tests applied to Ribalta's fares in a single readable class, organising by scenario with @Nested, the Clock.fixed that makes a rental duration calculation deterministic, and good practice for test data. All of it without starting the Spring context even once: it is not needed here, and that is precisely the lesson.

Contents

  1. The architecture of JUnit 5
  2. The core annotations and their lifecycle
  3. AssertJ: why it is the style of this course
  4. The assertion catalogue by type
  5. Exceptions and soft assertions
  6. Parameterised tests
  7. The parameterised FareCalculator
  8. Organising with @Nested, @DisplayName and @Tag
  9. Testing time-dependent code
  10. @TempDir, assumptions and @RepeatedTest
  11. StationService without Spring
  12. Test data: Object Mother and builders
  13. Common Mistakes and Tips
  14. Exercises

  1. The architecture of JUnit 5

JUnit 5 is not a library, it is three modules with separate responsibilities:

Module What it is Who uses it
JUnit Platform Discovery and execution engine; defines the API that tools consume Maven Surefire, IntelliJ, Eclipse
JUnit Jupiter The modern programming model: @Test, @BeforeEach, extensions You, when writing tests
JUnit Vintage Engine that runs JUnit 3 and 4 tests on the Platform Projects with history

The separation has a practical consequence: the Platform can run several engines at once, so a project mid-migration runs Jupiter and Vintage in the same build. CicloUrbana was born on JUnit 5, so Vintage is not on the classpath and must not be added: all it does is drag debt along.

flowchart TB
    IDE["IntelliJ / Eclipse / Maven Surefire"] --> P["JUnit Platform<br/>(discovers and runs)"]
    P --> J["Jupiter Engine<br/>JUnit 5 @Test"]
    P --> V["Vintage Engine<br/>JUnit 4 @Test"]
    J --> T["Your tests<br/>StandardFareTest"]

The associated mistake you will meet most often: mixing org.junit.Test (JUnit 4) with org.junit.jupiter.api.Test (JUnit 5) in the same file. The test compiles and is never run, without warning. If a method "does not show up" in the report, look at the import before anything else.

  1. The core annotations and their lifecycle

Annotation When it runs Method Typical use in CicloUrbana
@Test It is the test Instance Every case
@BeforeEach Before each @Test Instance Building the object under test
@AfterEach After each @Test Instance Closing open resources
@BeforeAll Once, before all of them static Loading something expensive and immutable
@AfterAll Once, after all of them static Releasing that resource
@DisplayName — Class or method Readable sentence in the report
@Disabled — Class or method Disabling, always with a reason
@Nested / @Tag — Non-static inner class / anything Grouping by scenario / labelling
flowchart TB
    A["@BeforeAll (static) · once"] --> B["New instance of the class"]
    B --> C["@BeforeEach"] --> D["@Test"] --> E["@AfterEach"]
    E --> F{"Any tests left?"}
    F -- "Yes" --> B
    F -- "No" --> G["@AfterAll (static) · once"]

The point that surprises anyone coming from JUnit 4 is the second box: JUnit creates a new instance of the test class for every @Test method. It is deliberate isolation: a field one test modifies will not be seen by the next, because the next one runs on a different object. This test proves it:

class LifecycleTest {

    private int counter = 0;   // instance field: reset on every test

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

    @BeforeEach
    void beforeEachTest(TestInfo info) {     // JUnit injects TestInfo if you ask for it
        counter++;
        System.out.println("  2. @BeforeEach — " + info.getDisplayName()
                + " · counter=" + counter + " · instance=" + hashCode());
    }

    @Test void firstTest()  { System.out.println("    3. first, c=" + counter); }
    @Test void secondTest() { System.out.println("    3. second, c=" + counter); }
}
1. @BeforeAll
  2. @BeforeEach — firstTest() · counter=1 · instance=1829164700
    3. first, c=1 · 4. @AfterEach
  2. @BeforeEach — secondTest() · counter=1 · instance=1259475182
    3. second, c=1 · 4. @AfterEach
5. @AfterAll

What matters is in two numbers: counter is 1 in both tests, not 1 and 2, and the hashCode is different, which is the cause. That is the independence guarantee everything else rests on; the execution order is deterministic but it is not the order in the file, and it must never matter.

Two details with consequences. @BeforeAll and @AfterAll must be static, precisely because there is no instance that outlives a single test; there is an alternative, @TestInstance(Lifecycle.PER_CLASS), which this course does not use because it opens the door to one test's state contaminating the next. And a @Disabled with no reason is accumulated rubbish: write @Disabled("Blocked by CU-412; re-enable once it is fixed"), or the test will stay switched off forever while giving the impression that something is covered.

The TestInfo parameter in the example is no accident: JUnit 5 injects method parameters through resolvers, and it is the same mechanism with which @ExtendWith(MockitoExtension.class) will inject the mocks in 06-03.

  1. AssertJ: why it is the style of this course

JUnit brings its own assertions and they work. AssertJ, also included in spring-boot-starter-test, does the same job far better:

Aspect JUnit Assertions AssertJ
Syntax assertEquals(expected, actual) assertThat(actual).isEqualTo(expected)
Argument order Expected first: constantly swapped by mistake Subject first: reads like a sentence
Discoverability You have to remember the method name The dot after assertThat(...) offers what applies to the type
Chaining No Yes, several checks on the same subject
Collections Very poor contains, extracting, filteredOn, allMatch
Failure message expected: <4.10> but was: <5.00> Also points at the specific field and element

Swapping the arguments produces messages that lie: assertEquals(amount, new BigDecimal("4.10")) reports "expected: 5.00 but was: 4.10", the wrong way round, and off you go debugging. With AssertJ the mistake is impossible. Course rule: every assertion is written with AssertJ; the only exception is Hamcrest inside jsonPath(...) in MockMvc (06-04), because that API demands it.

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

  1. The assertion catalogue by type

General objects:

Assertion What it checks
isEqualTo(x) / isNotEqualTo(x) Equality via equals
isSameAs(x) Reference identity (==)
isNull() / isNotNull() / isInstanceOf(C.class) Nullness and type
usingRecursiveComparison().isEqualTo(other) Field-by-field equality, without using equals
extracting(Station::getName, Station::getCapacity) Extracts several fields to compare them together

usingRecursiveComparison() solves a real problem: the JPA entities of 04-03 define equals by identifier, so isEqualTo would say two stations with the same id are equal even if one has had its name changed. The usual form is assertThat(saved).usingRecursiveComparison().ignoringFields("id", "version", "createdAt", "updatedAt").isEqualTo(expected), ignoring whatever the database assigns.

Strings and numbers:

assertThat(bike.getPlate())
        .isNotBlank()                       // not null, not empty, not just whitespace
        .startsWith("RB-").hasSize(7)
        .matches("RB-\\d{4}");              // the Ribalta format

assertThat(problem.getDetail()).doesNotContain("SQLException");  // 05-05
assertThat(station.getCapacity()).isPositive().isBetween(8, 60);
assertThat(0.1 + 0.2).isCloseTo(0.3, within(0.0001));   // floating point

BigDecimal: the trap to commit to memory.

BigDecimal amount = fare.calculate(Duration.ofMinutes(30));   // 4.10

assertThat(amount).isEqualTo(new BigDecimal("4.1"));        // ❌ FAILS
assertThat(amount).isEqualByComparingTo("4.10");            // ✅

BigDecimal.equals compares value and scale: 4.1 has scale 1 and 4.10 has scale 2, so they are not equals even though compareTo returns 0. Since isEqualTo uses equals, a correct test can fail merely because the calculation produced one more decimal place. With money, always isEqualByComparingTo. In CicloUrbana that affects every amount and every fare.

Optional and collections, where AssertJ is peerless:

assertThat(stationRepository.findById(1L))
        .isPresent().get()
        .extracting(Station::getName).isEqualTo("Main Square");
assertThat(stationRepository.findById(99L)).isEmpty();

assertThat(stations).hasSize(4)
        .extracting(Station::getName, Station::getCapacity)        // tuples
        .containsExactlyInAnyOrder(
                tuple("Main Square", 24), tuple("North Station", 30),
                tuple("River Park", 18), tuple("University", 36));

assertThat(stations).allMatch(s -> s.getCapacity() >= 8);           // Ribalta rule

The difference between the content methods is always forgotten:

Method Order Extra elements allowed
containsExactly Matters No
containsExactlyInAnyOrder Does not matter No
contains Does not matter Yes

And on maps, applied to the FareSelector of 02-02, assertThat(selector.available()).hasSize(3).containsKeys("standard", "student", "senior").doesNotContainKey("StandardFare") verifies the whole registry at once and, with that last clause, the failure of forgetting name() on a new fare.

  1. Exceptions and soft assertions

Three ways of verifying a failure, in order of preference:

// 1. assertThatThrownBy — the usual one
assertThatThrownBy(() -> rentalService.start(requestWithFlatBattery))
        .isInstanceOf(BikeUnavailableException.class)
        .hasMessageContaining("RB-0142")
        .hasFieldOrPropertyWithValue("code", "BIKE_UNAVAILABLE");

// 2. assertThatExceptionOfType — more explicit about the type
assertThatExceptionOfType(StationFullException.class)
        .isThrownBy(() -> rentalService.finish(1L, atFullMainSquare))
        .withMessageContaining("has no free docks");

// 3. catchThrowable — when the object has to be inspected in depth
Throwable error = catchThrowable(() -> jwtService.extractEmail(expiredToken));
assertThat(error).isInstanceOf(ExpiredJwtException.class);

assertThatNoException().isThrownBy(() -> stationService.validateCapacity(24));

Checking the code —the stable field of CicloUrbanaException from 03-06— is what turns the assertion into a verification of the contract with the API client. The dangerously weak assertion is isInstanceOf(RuntimeException.class): any accidental NullPointerException satisfies it and paints the test green.

Soft assertions. When a test has several assertions, the first one that fails hides the rest: you fix one, run again, the second fails. assertSoftly evaluates them all:

@Test
void theReturnedRentalHasEveryFieldCorrect() {
    RentalResponse response = rentalService.start(validRequest);

    assertSoftly(softly -> {
        softly.assertThat(response.bikePlate()).isEqualTo("RB-0142");
        softly.assertThat(response.originStation()).isEqualTo("Main Square");
        softly.assertThat(response.status()).isEqualTo(RentalStatus.IN_PROGRESS);
        softly.assertThat(response.endedAt()).isNull();
        softly.assertThat(response.totalAmount()).isNull();
    });
}

They are ideal for verifying a single composite object: one concept expressed across several fields. They are not a licence to cram five unrelated checks into one test.

  1. Parameterised tests

A parameterised test runs the same method with different data. It is the answer to the problem the lesson opened with, and junit-jupiter-params already comes in the starter.

Source What it gives you Example
@ValueSource An array of literals of a single type ints = {0, 1, 15, 30}
@CsvSource Several columns written inline "30, 4.10"
@CsvFileSource The same columns from a file in src/test/resources Hundreds of cases
@EnumSource All (or some) values of an enum BikeStatus.class
@NullSource, @EmptySource, @NullAndEmptySource The degenerate cases String validations
@MethodSource A static method returning Stream<Arguments> Complex objects
@ArgumentsSource A provider of your own, reusable across classes Domain catalogues
@ParameterizedTest(name = "a capacity of {0} docks must be rejected")
@ValueSource(ints = {-5, 0, 1, 7})
void rejectsCapacitiesBelowTheMinimum(int capacity) {
    assertThatThrownBy(() -> stationService.validateCapacity(capacity))
            .isInstanceOf(BusinessRuleException.class);
}
@ParameterizedTest
@NullAndEmptySource @ValueSource(strings = {"  ", "\t"})
void rejectsEmptyStationNames(String name) {
    assertThatThrownBy(() -> stationService.validateName(name))
            .isInstanceOf(BusinessRuleException.class);
}

@ParameterizedTest
@EnumSource(value = BikeStatus.class, names = {"MAINTENANCE", "RETIRED", "IN_USE"})
void noStatusOtherThanAvailableAllowsRenting(BikeStatus status) {
    assertThat(TestBikes.withStatus(status).canBeRented(20)).isFalse();
}

The name attribute deserves attention: without it the report shows [1], [2], [3]; with it, "a capacity of -5 docks must be rejected". The placeholders are {0}, {1}... for the arguments, {index} for the case number and {displayName}.

  1. The parameterised FareCalculator

The lesson's central example: Ribalta's three fares, with their rules and their boundaries, in one single readable class. The rules from 02-02:

Fare Unlock €/minute Free minutes Minimum billed
standard €0.50 €0.12 0 1 minute
student €0 €0.08 15 —
senior €0 €0.05 30 —
package com.ciclourbana.rentals;

@DisplayName("Fare calculation for the Ribalta network")
class FareCalculatorTest {

    @DisplayName("Standard fare: €0.50 unlock + €0.12/minute")
    @ParameterizedTest(name = "{0} min -> €{1}")
    @CsvSource({"0, 0.62",   // the 1-minute minimum is billed: 0.50 + 0.12
                "1, 0.62", "10, 1.70", "30, 4.10",
                "120, 14.90"})   // the maximum allowed by NetworkProperties
    void standardFare(long minutes, BigDecimal expected) {
        assertThat(new StandardFare().calculate(Duration.ofMinutes(minutes)))
                .isEqualByComparingTo(expected);
    }

    @DisplayName("Every fare applies its own allowance and price per minute")
    @ParameterizedTest(name = "[{index}] {0}: {1} min -> €{2}")
    @MethodSource("fareCases")
    void eachFareCalculatesItsAmount(String name, long minutes, BigDecimal expected) {
        assertThat(fareByName(name).calculate(Duration.ofMinutes(minutes)))
                .isEqualByComparingTo(expected);
    }

    /** The provider: static, no arguments, returns Stream<Arguments>. */
    static Stream<Arguments> fareCases() {
        return Stream.of(
                Arguments.of("standard",  1, new BigDecimal("0.62")),   // no allowance
                Arguments.of("standard", 30, new BigDecimal("4.10")),
                Arguments.of("student",  14, new BigDecimal("0.00")),   // 15 free minutes,
                Arguments.of("student",  15, new BigDecimal("0.00")),   // INCLUSIVE BOUNDARY
                Arguments.of("student",  16, new BigDecimal("0.08")),
                Arguments.of("student",  45, new BigDecimal("2.40")),
                Arguments.of("senior",   30, new BigDecimal("0.00")),   // 30 free minutes
                Arguments.of("senior",   31, new BigDecimal("0.05")),
                Arguments.of("senior",   90, new BigDecimal("3.00")));
    }

    private static FareCalculator fareByName(String name) {
        return switch (name) {
            case "standard" -> new StandardFare();
            case "student" -> new StudentFare();
            case "senior" -> new SeniorFare();
            default -> throw new IllegalArgumentException(name);
        };
    }
}

What makes this design good:

  • The boundary cases of each allowance sit together and in plain sight: 14, 15 and 16 minutes for the student; 30 and 31 for the senior. Reading the list you can see the boundary is inclusive. It is the living documentation of 06-01 in its purest form.
  • @CsvSource converts automatically from "4.10" to BigDecimal, from "30" to long and from "MAINTENANCE" to the enum constant (for your own types there is @ConvertWith), and @MethodSource is the source for complex objects: its method must be static, except under Lifecycle.PER_CLASS.
  • A failure pinpoints the exact case: the report will say [5] student: 16 min -> €0.08, not "the fare test failed".

When the list grows and is shared between classes, @ArgumentsSource extracts it into a reusable ArgumentsProvider, shaped just like fareCases but implementing provideArguments(ExtensionContext).

  1. Organising with @Nested, @DisplayName and @Tag

A class with twenty flat methods is as unreadable as a two-hundred-line method. @Nested groups by scenario, and each group has its own @BeforeEach:

@DisplayName("StationService")
class StationServiceTest {

    private StationService service;

    @BeforeEach
    void prepareService() {
        service = new StationService(new InMemoryStationRepository());
    }

    @Nested @DisplayName("when a station is registered")
    class WhenRegistering {
        @Test @DisplayName("accepts it if it meets the minimum number of docks")
        void acceptsSufficientCapacity() { /* ... */ }
    }

    @Nested @DisplayName("when the station already exists")
    class WithAnExistingStation {

        @BeforeEach   // runs AFTER the outer @BeforeEach
        void registerMainSquare() { service.create(TestStations.mainSquare()); }

        @Test @DisplayName("rejects a duplicate name with 409")
        void rejectsDuplicateName() { /* ... */ }
    }
}

Rules you need to know: the inner class cannot be static —it needs the outer instance— and the @BeforeEach methods stack from the outside in, which is what makes incremental arrangement possible. The report is shown hierarchically and reads like a specification:

StationService
├─ when a station is registered · ✔ accepts it if it meets the minimum number of docks
└─ when the station already exists · ✔ rejects a duplicate name with 409

@DisplayName is not a substitute for a good method name: the name is what appears in failure stack traces and in -Dtest= filters.

@Tag slices the suite by criteria that cut across the file name:

./mvnw test -Dgroups=fares               # only the tagged ones
./mvnw test -DexcludedGroups=slow        # everything except the slow ones

Use few tags, with clear meaning: in CicloUrbana slow and security are enough. And remember that for separating unit from integration tests we already have a better mechanism, the *IT suffix with Failsafe from 06-01, which does not depend on anyone remembering to annotate.

  1. Testing time-dependent code

Here a decision taken back in 02-01 pays off. CommonConfig declares @Bean Clock ribaltaClock(), and at the time it looked like a formality. If RentalService calculated the duration with Instant.now(), testing a two-hour rental would mean waiting two hours, changing the system clock or mocking a static method (06-03: you can, and you almost never should). With the injected Clock —Instant.now(clock)— time is just another argument:

class RentalDurationTest {

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

    @Test
    void calculatesTwoHoursOfRentalWithoutWaitingTwoHours() {
        // Arrange: a clock frozen two hours after the start
        Clock clock = Clock.fixed(STARTED_AT.plus(Duration.ofHours(2)), ZoneOffset.UTC);
        Rental rental = TestRentals.inProgressSince(STARTED_AT);
        // Act
        Duration duration = Duration.between(rental.getStartedAt(), Instant.now(clock));
        // Assert
        assertThat(duration.toMinutes()).isEqualTo(120);
    }
}

The two factories used in tests:

Factory What it does When
Clock.fixed(instant, zone) Time does not advance: always the same instant 99 % of cases
Clock.offset(base, duration) Shifts a clock by a fixed amount Simulating "sixteen minutes from now"

The second is what lets you test that a JWT token expires after fifteen minutes: it is generated with one clock and validated with another one sixteen minutes ahead. Without waiting sixteen minutes.

The general rule: any source of non-determinism must be injectable. The clock, the random generator —the Random with a fixed seed from 02-01 answers the same idea— and the identifier generator. A new Random() or a UUID.randomUUID() embedded in the logic makes an exact assertion impossible.

  1. @TempDir, assumptions and @RepeatedTest

@TempDir gives you a directory that JUnit creates beforehand and deletes afterwards, with no clean-up code:

@Test
void exportsTheOccupancyReportAsCsv(@TempDir Path directory) throws IOException {
    Path file = directory.resolve("ribalta-occupancy.csv");
    exporter.export(TestStations.theFourRibaltaStations(), file);

    assertThat(Files.readAllLines(file)).hasSize(5)     // header + 4 stations
            .first().isEqualTo("station;capacity;free");
}

Assumptions abort a test without marking it as failed when the conditions are not met: assumeTrue(DockerClientFactory.instance().isDockerAvailable(), "Docker not available") skips it entirely, and assumingThat(condition, () -> assertThat(...)) skips only one block of assertions. The difference from @Disabled is that the latter disables always while an assumption decides at run time. The nuance to watch: a skipped test shows up green in the summary, so a lax assumption hides the fact that something is never run. Always give them a message.

@RepeatedTest(100) runs the same test several times; its legitimate use is checking something with internal randomness, such as a generator of RB-\d{4} plates. It is no use for detecting concurrency problems: repeating a hundred times on the same thread creates no contention. And if a test passes 99 times out of 100, you do not have a flaky test: you have a bug.

  1. StationService without Spring

Putting it all together on a real class. StationService receives its repository through the constructor (02-02), so we hand it the InMemoryStationRepository from 02-01 —the fake from the table of doubles in 06-01— and nothing else is needed:

@DisplayName("StationService · rules of the Ribalta network")
class StationServiceTest {

    private StationService service;

    @BeforeEach
    void prepareCleanService() {
        // A new instance in EVERY test: no shared state, no implicit ordering
        service = new StationService(new InMemoryStationRepository());
    }

    @ParameterizedTest(name = "{0} docks are accepted")
    @ValueSource(ints = {8, 18, 24, 30, 36, 60})
    void acceptsTheRibaltaCapacities(int capacity) {
        assertThatNoException().isThrownBy(() -> service.validateCapacity(capacity));
    }

    @ParameterizedTest(name = "{0} docks are rejected")
    @ValueSource(ints = {-1, 0, 7})
    void rejectsFewerThanEightDocks(int capacity) {
        assertThatThrownBy(() -> service.validateCapacity(capacity))
                .isInstanceOf(BusinessRuleException.class).hasMessageContaining("8");
    }

    @Test
    void returnsEmptyWhenItDoesNotExist() { assertThat(service.findById(999L)).isEmpty(); }
}

Why this test does not start the context, even though StationService is a @Service:

With @SpringBootTest This test
2–6 s of context start-up < 20 ms
Needs a database, or a simulated one A ConcurrentHashMap
A failure can come from any layer The failure is in StationService
Tests the wiring and the logic Tests only the logic

The @Service annotation does not prevent instantiating the class with new: it is metadata that Spring reads at start-up. A unit test does not start the context because it is not testing the context. That the wiring works is checked once, in the integration tests of 06-04, not in each of the two hundred logic tests.

  1. Test data: Object Mother and builders

TestStations.mainSquare() has now appeared several times. It is the Object Mother pattern: a factory in src/test/java that produces valid domain objects with business names.

/** Factory of Ribalta stations for the tests. Only in src/test/java. */
public final class TestStations {

    public static Station mainSquare()   { return new Station("Main Square",   "Main Square 1",       24); }
    public static Station northStation() { return new Station("North Station", "Station Avenue 3",    30); }
    public static Station riverPark()    { return new Station("River Park",    "Riverside Walk 12",   18); }
    public static Station university()   { return new Station("University",    "South Campus, Gate B", 36); }

    public static List<Station> theFourRibaltaStations() {
        return List.of(mainSquare(), northStation(), riverPark(), university());
    }

    /** Variant for the case that needs a specific value. */
    public static Station withCapacity(int c) { return new Station("Test", "Street 1", c); }
}

When objects have many fields and each test varies one of them, the builder is more flexible:

public class RentalBuilder {

    private Instant startedAt = Instant.parse("2026-03-14T09:00:00Z");
    private Instant endedAt = null;
    private RentalStatus status = RentalStatus.IN_PROGRESS;

    public static RentalBuilder aRental() { return new RentalBuilder(); }

    public RentalBuilder finishedAfter(Duration duration) {
        this.endedAt = startedAt.plus(duration);
        this.status = RentalStatus.FINISHED;
        return this;
    }
    public Rental build() { /* assembles the object */ }
}
// And the test reads like this:
Rental rental = aRental().finishedAfter(Duration.ofMinutes(45)).build();

What you gain is substantial: the test only mentions what it cares about. The other six fields exist with valid values but do not clutter the reading, and if tomorrow Rental gains a mandatory field you change the builder, not the forty tests.

A word of advice about the line that separates this from an anti-pattern: the test object is built, not calculated. If the builder starts to contain logic —"if it is finished, work out the amount from the fare"— you have duplicated the implementation inside the tests, and then the test passes even when the code is wrong.

Common Mistakes and Tips

Mixing org.junit.Test with org.junit.jupiter.api.Test. The test compiles and is never run; if the number of tests in the report does not add up, check the imports. In the same family is forgetting the static on @BeforeAll, @AfterAll or on the @MethodSource method: the configuration error is clear, but it is hard to recognise the first time.

isEqualTo on BigDecimal. It fails because of the scale even when the value is correct. With amounts of money, always isEqualByComparingTo. It is the failure that wastes the most time in a money-handling domain like CicloUrbana's.

Depending on the execution order. JUnit does not guarantee it and creates a new instance per test precisely to prevent it. If a test only passes when another one runs first, there is shared state —a static field, a file, a singleton—: fix it, do not order it with @TestMethodOrder. In the same family is the lax assertion (isNotNull() as the only check, or isInstanceOf(RuntimeException.class) when expecting a specific exception): it always passes and protects against nothing.

Conditional logic inside a test. An if (isWeekend) { ... } else { ... } means there are two tests written as one and that each run only checks one branch. The solution is a parameterised test, or two methods.

Tip: one reason to fail per test. That does not mean a single assertThat, but a single concept. Five assertions on the five fields of a RentalResponse are one concept; checking the amount and that the event was published are two, and they deserve two methods.

Tip: if you need more than three or four lines of arrangement, look at the class. A long arrangement is the test complaining about the design: too many dependencies, too much state, too many responsibilities.

Exercises

Exercise 1

Extend FareCalculatorTest with a parameterised test that verifies the cross-cutting property of all three fares: no amount can be negative and all of them must have a scale of exactly two decimal places. Use @MethodSource to combine the three fares with durations of 0, 1, 15, 30, 45 and 120 minutes, and assertSoftly to report every failure of a case at once. Explain why this test and the ones in section 7 do not overlap.

Exercise 2

Write JwtServiceExpiryTest with two tests that use Clock to verify the rule from 05-04 —the access token expires after fifteen minutes— without waiting fifteen minutes: one checking that a token issued 14 minutes ago is still valid and another that one issued 16 minutes ago is not. Assume JwtService receives JwtProperties and Clock through the constructor. Reason why the sixteen-minute test is the more important one, and what clockSkewSeconds(30) does with the case of 15 minutes and 10 seconds.

Exercise 3

Refactor this test, which gathers five of the defects seen in the lesson, and list what they are:

public class StationTests {
    static List<Station> stations = new ArrayList<>();

    @Test
    public void test1() {
        stations.add(new Station("Main Square", "Main Square 1", 24));
        if (stations.size() > 0) { assertNotNull(stations.get(0)); }
        BigDecimal amount = new StandardFare().calculate(Duration.ofMinutes(30));
        assertEquals(new BigDecimal("4.1"), amount);
    }
}

Solutions

Solution 1

@DisplayName("Properties that EVERY Ribalta fare satisfies")
@ParameterizedTest(name = "{0} with {1} min")
@MethodSource("allFaresByAllDurations")
void noFareProducesInvalidAmounts(String name, long minutes) {
    BigDecimal amount = fareByName(name).calculate(Duration.ofMinutes(minutes));
    assertSoftly(softly -> {
        softly.assertThat(amount)
         .as("the amount for %s over %d min cannot be negative", name, minutes)
         .isGreaterThanOrEqualTo(BigDecimal.ZERO);
        softly.assertThat(amount.scale())
         .as("amounts in euros are expressed with two decimal places").isEqualTo(2);
    });
}

static Stream<Arguments> allFaresByAllDurations() {
    List<Long> durations = List.of(0L, 1L, 15L, 30L, 45L, 120L);
    return Stream.of("standard", "student", "senior")
            .flatMap(f -> durations.stream().map(d -> Arguments.of(f, d)));
}

Comment: they do not overlap because they check different things. The ones in section 7 are example-based tests: they verify an exact value for an exact input and catch formula errors. This one is a property-based test: it does not know what the price should be, but it states invariants that always hold. It catches a different class of bug —a negative amount from a subtraction without Math.max, or a lost scale from forgetting setScale— and it stays valid when the council changes the prices, which the section 7 ones do not. The as(...) adds context to the failure message, indispensable with 18 combinations. And .scale() is demanding on purpose: a fare returning BigDecimal.ZERO (scale 0) instead of new BigDecimal("0.00") will fail, and it is right that it should: an invoiced amount must be formatted as €0.00.

Solution 2

class JwtServiceExpiryTest {

    private static final Instant ISSUED_AT = Instant.parse("2026-03-14T09:00:00Z");
    private static final JwtProperties PROPERTIES = new JwtProperties(
            "test-signing-key-of-at-least-43-characters-for-hs256",
            "ciclourbana", "ribalta-app", Duration.ofMinutes(15), Duration.ofDays(30));

    private String tokenIssuedAtNine() {
        return new JwtService(PROPERTIES, Clock.fixed(ISSUED_AT, ZoneOffset.UTC))
                .generateAccess(TestUsers.marta());
    }

    /** The same service, with the clock moved forward by the given offset. */
    private JwtService validatorAdvancedBy(Duration d) {
        return new JwtService(PROPERTIES, Clock.fixed(ISSUED_AT.plus(d), ZoneOffset.UTC));
    }

    @Test
    void acceptsTheTokenFourteenMinutesAfterIssuingIt() {
        String token = tokenIssuedAtNine();
        String email = validatorAdvancedBy(Duration.ofMinutes(14)).extractEmail(token);

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

    /*
     * The test that really protects the rule. The fourteen-minute one would
     * pass just the same with a 24-hour expiry, or none at all: it checks
     * that something does NOT happen YET. This one checks that the expiry
     * EXISTS. In an "up to X" rule, the valuable case is beyond the boundary.
     */
    @Test
    void rejectsTheTokenSixteenMinutesAfterIssuingIt() {
        String token = tokenIssuedAtNine();
        JwtService validator = validatorAdvancedBy(Duration.ofMinutes(16));

        assertThatThrownBy(() -> validator.extractEmail(token))
                .isInstanceOf(ExpiredJwtException.class);
    }
}

About clockSkewSeconds(30): the tolerance from 05-04 means a token of 15 minutes and 10 seconds is still accepted, because it falls inside the margin allowed for drift between machines. That is correct and deliberate, but it means an expiry test must not sit within a few seconds of the boundary: it would be brittle and ambiguous. That is why the chosen cases are 14 and 16 minutes, comfortably either side. If you wanted to verify the tolerance itself, that is a third case with a name of its own: acceptsTheTokenWithinTheThirtySecondSkew.

Solution 3

The five defects:

  1. static List<Station> stations: state shared between tests. It breaks the isolation and makes the result depend on the order.
  2. StationTests and test1: names that describe no rule. When it fails in continuous integration you will have to open the file.
  3. if (stations.size() > 0): conditional logic. If the list were empty, the test would pass without checking anything.
  4. Two concepts in one method: managing stations and calculating the fare are unrelated. Two reasons to fail, and no name can describe them both.
  5. assertEquals(new BigDecimal("4.1"), amount): it fails because of the scale, it uses JUnit assertions instead of AssertJ and it puts the arguments in expected-actual order, which is easy to swap. On top of that, assertNotNull on an object the test itself has just created verifies nothing about the production code.

Refactored, it becomes two classes:

class StandardFareTest {
    @Test
    void chargesFourTenForHalfAnHour() {
        assertThat(new StandardFare().calculate(Duration.ofMinutes(30)))
                .isEqualByComparingTo("4.10");
    }
}

@DisplayName("StationService · registering stations")
class StationServiceTest {

    private final StationService service =
            new StationService(new InMemoryStationRepository());

    @Test
    void savesTheStationAndAssignsItAnId() {
        Station saved = service.create(TestStations.mainSquare());

        assertThat(saved.id()).isNotNull();
        assertThat(service.findById(saved.id())).isPresent().get()
                .extracting(Station::name).isEqualTo("Main Square");
    }
}

The assertion on findById is what turns the second one into a real test: it does not check that the object just created is not null —we already knew that— but that the service has saved it and knows how to retrieve it, which is the real responsibility of create.

Conclusion

The base of CicloUrbana's pyramid now has foundations. You know the architecture of JUnit 5 —Platform, Jupiter and Vintage— and the wrong-import trap that stops a test ever running without saying so. You have mastered the full lifecycle and, above all, the decision that governs it: a new instance of the class for every @Test, the isolation guarantee on which the irrelevance of the execution order rests. You know how to use @BeforeEach for incremental arrangement, why @BeforeAll is static and why a @Disabled with no reason is worse than not having the test at all.

You have the AssertJ catalogue by data type with its delicate cases: usingRecursiveComparison for entities whose equals compares by identifier, the difference between containsExactly, containsExactlyInAnyOrder and contains, extracting with tuples and, the one that saves the most grief, isEqualByComparingTo for every BigDecimal, because equals compares the scale and Ribalta invoices in euros. You know how to demand the specific exception type with assertThatThrownBy, to check the stable code of the exceptions from 03-06 and to group the assertions of a composite object with assertSoftly. And you have written the module's flagship example: Ribalta's three fares with their allowances and their exact boundaries —14, 15 and 16 minutes; 30 and 31— in a single parameterised class, where @CsvSource covers the simple cases and @MethodSource the complex ones, and where the name of each case identifies the failure without opening the file. You know how to organise by scenario with @Nested, give readable names with @DisplayName, slice the suite with @Tag without overdoing it, isolate files with @TempDir and skip with judgement using assumptions. And you have collected the debt from module 2: the injected Clock turns time into an argument, and Clock.fixed makes a two-hour rental testable in two milliseconds. You close with StationService tested without a single line of Spring —twenty milliseconds against several seconds— and with the data encapsulated in TestStations and a rental builder, so that each test mentions only what it cares about.

One limit remains obvious. StationService could be tested this way because InMemoryStationRepository exists, a fake we wrote in module 2 that no longer represents the real implementation. RentalService is another story: it depends on four JPA repositories, on FareSelector, on the Clock and on the event publisher, and there is no fake for any of them. Writing them by hand would be absurd. The next lesson, Mocking with Mockito, solves exactly that: creating doubles on the fly, programming their answers, forcing the difficult scenarios —the unavailable bike, the full station, the repository failure— and verifying that RentalService published the RentalStarted event it was supposed to. With that, the most important class in CicloUrbana will be under test.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved