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
- The architecture of JUnit 5
- The core annotations and their lifecycle
- AssertJ: why it is the style of this course
- The assertion catalogue by type
- Exceptions and soft assertions
- Parameterised tests
- The parameterised
FareCalculator - Organising with
@Nested,@DisplayNameand@Tag - Testing time-dependent code
@TempDir, assumptions and@RepeatedTestStationServicewithout Spring- Test data: Object Mother and builders
- Common Mistakes and Tips
- Exercises
- 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.
- 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. @AfterAllWhat 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.
- 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;
- 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 pointBigDecimal: 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 ruleThe 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.
- 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.
- 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}.
- The parameterised
FareCalculator
FareCalculatorThe 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.
@CsvSourceconverts automatically from"4.10"toBigDecimal, from"30"tolongand from"MAINTENANCE"to theenumconstant (for your own types there is@ConvertWith), and@MethodSourceis the source for complex objects: its method must bestatic, except underLifecycle.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).
- Organising with
@Nested, @DisplayName and @Tag
@Nested, @DisplayName and @TagA 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 onesUse 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.
- 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.
@TempDir, assumptions and @RepeatedTest
@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.
StationService without Spring
StationService without SpringPutting 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.
- 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:
static List<Station> stations: state shared between tests. It breaks the isolation and makes the result depend on the order.StationTestsandtest1: names that describe no rule. When it fails in continuous integration you will have to open the file.if (stations.size() > 0): conditional logic. If the list were empty, the test would pass without checking anything.- Two concepts in one method: managing stations and calculating the fare are unrelated. Two reasons to fail, and no name can describe them both.
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,assertNotNullon 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
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
