The previous module ended with a claim and a question. The claim: the Ribalta network is protected. The question: how do we know? We know a citizen cannot finish somebody else's rental because we tried it by hand with curl one Tuesday afternoon, and because when we read @PreAuthorize it looks like it says the right thing. That is not knowledge: it is confidence. And confidence evaporates the moment somebody reorders a rule in authorizeHttpRequests, bumps the version of a dependency or touches FareSelector to add a new fare.

CicloUrbana today has a complete application —REST, JPA over PostgreSQL, Flyway, JWT security— and exactly zero tests: only the empty contextLoads that Spring Initializr generated back in lesson 01-04. This module fixes that. In this first lesson we will not write a suite yet: we will build the judgement. What gets tested and why, what kinds of test exist and what each one catches, how they are distributed in a healthy pyramid, what spring-boot-starter-test already brings without adding anything, where the files live, how classes and methods are named, what the anatomy of a well-written test looks like, how tests are run, how coverage is measured with JaCoCo and why that metric misleads when it becomes a target. By the end we will have written the project's first real test: small, but true.

Contents

  1. Why we test
  2. The kinds of test
  3. The test pyramid and the ice-cream cone
  4. What is worth testing and what is not
  5. spring-boot-starter-test: the toolbox
  6. Where tests live: structure and conventions
  7. Anatomy of a test: Arrange-Act-Assert
  8. Running the tests
  9. Surefire and Failsafe: *Test versus *IT
  10. Coverage with JaCoCo
  11. TDD: red, green, refactor
  12. Test doubles
  13. Common Mistakes and Tips
  14. Exercises

  1. Why we test

The naive answer is "to find bugs". It is the least important of the four real reasons.

Catching regressions

A regression is a failure introduced in something that used to work. It is the most expensive kind of failure in a program's lifetime, because nobody is looking for it: the change was tested by hand, the change works, and what broke is three layers away.

CicloUrbana has dozens of regressions waiting their turn. These are real and plausible:

Innocent change What breaks silently
Adding SeniorFare with @Component but no name() FareSelector registers it as SeniorFare instead of senior; the fare is never selected
Reordering two rules in authorizeHttpRequests /api/v1/stations/** becomes public before the restrictive rule can reach it
Changing @Transactional to @Transactional(readOnly = true) on finish The amount is calculated but never saved; nobody notices until the monthly billing run
Renaming a field of StationResponse The mobile app stops showing the capacity; the API answers 200
Adding a column in V7 without touching the entity ddl-auto: validate still passes, but an INSERT fails with NOT NULL in production
Bumping the Jackson version An Instant starts being serialised as a number; clients break

None of those changes produces a compilation error. Every one of them is caught by a test written once and run a thousand times.

Refactoring without fear

Refactoring means changing the internal structure without changing the observable behaviour. The definition hides a trap: to know that the behaviour has not changed you have to be able to check it. Without tests, "refactoring" is a euphemism for "rewrite and pray", and the practical outcome is that nobody touches the ugly code: it piles up, gets worked around and is passed on.

With a decent suite, the mapper from 03-05 that queries repositories —which we admitted was badly designed— can be rewritten in an afternoon. Without one, it stays there forever.

Living documentation

A comment lies the moment somebody changes the code and does not update it. A test that lies fails. The name of a well-written test method is a sentence from the domain:

returns404WhenTheStationDoesNotExist
rejectsTheRentalWhenTheBatteryIsBelowTheThreshold
aCitizenCannotFinishSomeoneElsesRental
appliesTheStudentFareWithFifteenFreeMinutes

Read in the report of a run, those names are the specification of CicloUrbana, and it is a specification that compiles and verifies itself.

Design pressure

This is the least obvious reason and the most valuable. Code that is hard to test is badly designed code, and the test tells you so before any reviewer does.

If RentalService called LocalDateTime.now() directly, testing "a two-hour rental" would mean waiting two hours or tampering with the system clock. Injecting a Clock back in lesson 02-01 was not gratuitous elegance: it was designing for testability. The same goes for the constructor injection of 02-02 (it lets you build the object in a test with fake collaborators), for the StationRepository interface of 02-01 (it lets you swap the implementation) and for RentalSecurity of 05-05 (a security rule turned into an ordinary bean, checkable with a unit test).

  1. The kinds of test

"Test" is not a single category. These are the ones we care about, ordered from fastest and cheapest to slowest and most expensive:

Kind What it verifies Scope Typical speed Cost to write Cost to maintain What it catches that the previous ones do not
Unit One class or method in isolation One unit, fake collaborators 1–10 ms Low Low Logic, calculation and edge-case errors
Component / slice One layer with part of the framework Controller or repository + Spring 0.1–2 s Medium Medium Annotation failures, HTTP mappings, JSON, queries
Integration Several layers genuinely cooperating Full context + database 1–10 s Medium-high Medium Wiring, transaction, schema and security failures
End-to-end (E2E) A complete use case over HTTP Deployed application 5–60 s High High Environment configuration and deployment failures
Contract That two services still understand each other The boundary between two systems 0.1–2 s Medium Medium Breaking changes in a published API
Load / performance Behaviour under concurrency and volume Whole system Minutes High High Degradation, leaks, pool limits, N+1

Two nuances that save a lot of sterile argument:

  • The boundary between unit and integration is a continuum, not a line. What matters is not the label but two properties: whether the test is fast and whether it is deterministic. A test that takes 40 ms and touches neither network nor disk behaves like a unit test even if it instantiates three real classes.
  • Load and contract tests are out of scope for this module. Performance tests appear in 09-01, and contract tests make sense once CicloUrbana is split into several services (07-05 and 07-06). Here we build the first four rows.

  1. The test pyramid and the ice-cream cone

The test pyramid describes the healthy proportion between kinds: many fast, cheap tests at the base, very few slow and brittle ones at the top.

flowchart TB
    subgraph HEALTHY["Pyramid (healthy)"]
        direction TB
        E1["E2E · few · slow"]
        I1["Integration and slices · some"]
        U1["Unit · many · milliseconds"]
        E1 --- I1 --- U1
    end
    subgraph BAD["Ice-cream cone (anti-pattern)"]
        direction TB
        E2["E2E and manual · loads of them"]
        I2["Integration · some"]
        U2["Unit · four"]
        E2 --- I2 --- U2
    end

The logic behind the shape is economic. A unit test costs milliseconds, so you can have thousands of them and run them every time you save a file. An E2E test costs seconds and sometimes fails for no reason (the network, a timeout, some leftover data), so every one you add makes the suite more expensive and erodes trust in it.

The ice-cream cone is the inverted pyramid: almost everything is checked by starting the whole application —or worse, by hand— and there are barely any unit tests. Its symptoms are unmistakable:

  • The suite takes 25 minutes, so nobody runs it locally.
  • Some tests fail "sometimes" and the team retries them instead of fixing them: those are flaky tests, and a single one poisons trust in all the rest.
  • When something fails, the message is Expected 200 but was 500 and you have to read 300 lines of log to find out what broke. A unit test tells you which method and which value.

The recommended proportion for CicloUrbana, given its size and shape:

Level Target proportion What gets tested here Time budget
Unit ~70 % FareCalculator and its three implementations, FareSelector, rules in StationService and RentalService, RentalSecurity, mappers < 5 s in total
Slices (@WebMvcTest, @DataJpaTest) ~20 % StationController and RentalController, RentalRepository queries, DTO serialisation < 30 s
Integration with context and real PostgreSQL ~9 % The full rent-and-return flow, Flyway migrations, security rules end to end < 2 min
E2E against the deployed environment ~1 % A handful of critical paths: register, log in, rent, return Outside the development cycle

These are not percentages to be measured with a spreadsheet. They are a reminder: if writing a new test always forces you to start the Spring context, the problem is not the test, it is the design of the class.

  1. What is worth testing and what is not

Writing tests costs time and maintaining them costs more. Spending that well means saying no.

Worth testing:

  • Business logic with branches: the fare calculation with its free minutes, the minimum-docks rule, the battery threshold check, the surcharge for exceeding the maximum duration.
  • Edge cases and exact boundaries: 0 minutes, 1 minute, exactly 15 minutes on the student fare, exactly the battery threshold, the station with one free dock and with none.
  • Error paths: what happens when the station does not exist, when the bike is under maintenance, when the rental is already finished.
  • Security rules, without exception. They are the ones that fail silently and the ones that cost most.
  • The public contract of the API: status codes, the shape of the JSON, the ProblemDetail responses of 03-06.
  • Every bug found in production: before fixing it, a test that reproduces it. That is the rule that stops the same failure happening twice.

Not worth testing:

What Why not
Getters, setters and records with no logic There is no behaviour to verify; the test just repeats the code
Auto-generated toString, equals Except the equals of a JPA entity, which does have rules of its own (04-03) and deserves a test
That Spring injects a bean You would be testing Spring, not CicloUrbana
That @GetMapping maps a trivial route with no logic A single controller slice test covers it, not one per method
That Flyway applies migrations With a nuance: we do not test Flyway, we test that our migrations leave the schema the entities expect (06-05)
Trivial configuration (server.port) If it is wrong the application does not start; startup is already the test
Third-party code If you doubt a library, the right test is one of your integration with it, not of the library itself

The question that settles almost every doubtful case: "if this breaks, do I find out before a citizen of Ribalta does?" If the answer is "only with a test", write it.

  1. spring-boot-starter-test: the toolbox

Spring Initializr already added this dependency back in 01-03, and it is the only one we need for the whole module apart from two specific additions (security in 06-04 and containers in 06-05):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

The <scope>test</scope> is essential: these libraries are used when compiling and running src/test/java, and they are not packaged into the production JAR. An assertThat must never end up in the deployed artefact.

What it brings inside:

Library What it is for Will we use it?
JUnit 5 (Jupiter) The test engine: @Test, lifecycle, parameterisation Constantly (06-02)
Spring Test and Spring Boot Test @SpringBootTest, slices, MockMvc, context caching 06-04 and 06-05
AssertJ Fluent assertions: assertThat(x).isEqualTo(y) It is the official style of this course
Hamcrest Matcher-based assertions: assertThat(x, is(y)) Only inside jsonPath(...) in MockMvc
Mockito 5 Test doubles: mock, when, verify 06-03
JSONassert Comparing two JSON documents ignoring order Occasionally in 06-04
JsonPath Extracting values from JSON with expressions like $.content[0].name In every controller test
Awaitility Waiting for an asynchronous condition without Thread.sleep In 07-03, with @Async
XMLUnit Comparing XML documents No: CicloUrbana only speaks JSON
JUnit Vintage Running legacy JUnit 4 tests No: the project was born on JUnit 5

Why you must not add loose dependencies. The Spring Boot BOM pins mutually compatible versions of all of them. If you add org.mockito:mockito-core with a version of your own, you end up with two Mockitos on the classpath and extension start-up errors that take half a morning to diagnose. The project rule: if something is already in the starter, do not declare it; and if you need to change its version, do it with <mockito.version> in <properties>, which is the lever the BOM offers for exactly that.

Check what you actually have with:

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

  1. Where tests live: structure and conventions

Maven separates production code from test code into two parallel trees:

ciclourbana/
├── src/main/java/com/ciclourbana/rentals/StandardFare.java
├── src/main/resources/application.yml
├── src/test/java/com/ciclourbana/rentals/StandardFareTest.java
└── src/test/resources/application-test.yml

Three rules that are followed without exception:

  1. The test lives in the same package as the class under test, albeit in a different tree. That way it can reach package-visible members without opening up the production class, and the test tree is a navigable mirror of the production one.
  2. src/test/resources comes before src/main/resources on the test classpath. An application.yml placed there does not merge with the production one: it replaces it entirely. It is a classic mistake; the right way to configure tests is with a profile (application-test.yml), which we will see in 06-04.
  3. Nothing from src/test/java ends up in the JAR. You can create as many support classes there as you like.

Naming conventions

Element Convention Example in CicloUrbana
Unit or slice test class <ClassUnderTest>Test StandardFareTest, StationControllerTest
Integration test class <Case>IT RentalFullFlowIT
Support class (data, shared base) Descriptive name, no Test or IT TestStations, IntegrationTestBase
Test method A descriptive English sentence, with no test prefix calculatesFourTenForThirtyMinutes

On method names, the course policy deserves a justification. testCalculate1() says nothing: when it fails in continuous integration at three in the afternoon, you have to open the file. rejectsTheStationWhenTheCapacityIsBelowEight describes the Ribalta business rule, in the project's own language, and the failure report reads like a list of broken requirements. Three valid styles; pick one and be consistent:

// 1. Plain sentence (the one this course uses)
void returns404WhenTheStationDoesNotExist()

// 2. Explicit given-when-then structure
void givenAFullStation_whenABikeIsReturned_thenItThrowsStationFullException()

// 3. Short name + @DisplayName for the report (seen in 06-02)
@DisplayName("Returns 404 when the station does not exist")
void stationDoesNotExist()

And a note that avoids a baffling failure: in JUnit 5, test classes and methods do not need to be public. Package visibility is enough and is the current convention. What is still forbidden is for them to be private or static.

  1. Anatomy of a test: Arrange-Act-Assert

Every well-written test has three parts, visually separated. The pattern is known as Arrange-Act-Assert (AAA) or Given-When-Then; in this course we will call it Arrange-Act-Assert:

Phase What it does Rule
Arrange Builds the state and the input data Only what this test needs
Act Executes one single operation: the one under test One line, almost always
Assert Verifies the result One concept verified

CicloUrbana's first real test. We test StandardFare from 02-02: €0.50 unlock plus €0.12 per minute.

package com.ciclourbana.rentals;

import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.time.Duration;

import static org.assertj.core.api.Assertions.assertThat;

class StandardFareTest {

    @Test
    void chargesTheUnlockPlusTwelveCentsPerMinute() {
        // Arrange
        StandardFare fare = new StandardFare();
        Duration duration = Duration.ofMinutes(30);

        // Act
        BigDecimal amount = fare.calculate(duration);

        // Assert: 0.50 + (0.12 × 30) = 4.10
        assertThat(amount).isEqualByComparingTo("4.10");
    }
}

There is a decision behind every line:

  • class StandardFareTest, without public, in the package com.ciclourbana.rentals, an exact mirror of the class under test.
  • new StandardFare(): there is no Spring anywhere. StandardFare is an ordinary Java class that happens to be annotated with @Component; the annotation does not stop you instantiating it. This test runs in under a millisecond, and that is exactly the property that makes it useful.
  • import static ...Assertions.assertThat: the AssertJ static import, present in every test in this course.
  • isEqualByComparingTo("4.10") and not isEqualTo(new BigDecimal("4.10")). BigDecimal.equals compares the scale as well, so 4.1 and 4.10 are not equal for it even though they are worth the same. With money, always isEqualByComparingTo. It is the first of the AssertJ traps and it is covered in detail in 06-02.
  • The comment with the expected calculation: 4.10 is not a magic number when the operation that produces it sits right next to it. Whoever reads the test a year from now will know whether the failure is in the code or in the expectation.

Let us run it:

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

CicloUrbana has its first test. It is tiny, and yet it already states something that until now lived only in the head of whoever wrote the class.

Now let us see how it fails, which is what really matters. If somebody changes PER_MINUTE to 0.15:

[ERROR] StandardFareTest.chargesTheUnlockPlusTwelveCentsPerMinute:19
expected: 4.10
 but was: 5.00

The message names the rule that was broken, the expected value and the actual one. The quality of a failure message is a feature of the test, not an accident, and it is the main reason this course uses AssertJ.

  1. Running the tests

# Every test in the project
./mvnw test

# One specific class
./mvnw test -Dtest=StandardFareTest

# One specific method
./mvnw test -Dtest=StandardFareTest#chargesTheUnlockPlusTwelveCentsPerMinute

# Several classes, with wildcards
./mvnw test -Dtest='*Fare*Test,FareSelectorTest'

# Compile and package skipping the tests (to debug the packaging)
./mvnw package -DskipTests

A warning about that last line, because the difference comes up in every code review:

Option Effect
-DskipTests Compiles the tests but does not run them
-Dmaven.test.skip=true Does not even compile them: it can hide the fact that the test code no longer compiles

Always use the first one. The second lets broken tests through without a word.

From the IDE the day-to-day flow is different and better: the green triangle next to the method runs that test in milliseconds, and the "repeat the last run" shortcut (Ctrl+Shift+F10 in IntelliJ, Ctrl+F11 in Eclipse) is the one you use most while coding. The IDE runs JUnit directly, without going through Maven: it is much faster, but it does not apply the Surefire configuration, so a test can pass in the IDE and fail under ./mvnw test if it depends on JVM arguments or profiles configured in the pom.xml. Before pushing anything, ./mvnw verify.

  1. Surefire and Failsafe: *Test versus *IT

Maven has two testing plugins, and confusing them is why so many suites never run their integration tests.

Surefire Failsafe
Lifecycle phase test integration-test and verify
What it runs *Test, Test*, *Tests, *TestCase *IT, IT*, *ITCase
If a test fails Stops the build immediately Records the failure and carries on to post-integration-test
When it runs Before package After package, against the built artefact
Intended use Unit and fast tests Tests that need external resources

The difference in behaviour on failure has a practical reason: if an integration test starts a PostgreSQL container (06-05), Failsafe must always reach the phase that shuts it down; that is why it does not abort on the first failure and leaves it to verify to break the build.

Failsafe is not enabled by default. You add it like this:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-failsafe-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>integration-test</goal>
                <goal>verify</goal>
            </goals>
        </execution>
    </executions>
</plugin>

And from there on, the CicloUrbana convention:

./mvnw test      # seconds: unit tests and slices. Run constantly.
./mvnw verify    # minutes: plus the *IT tests with real PostgreSQL. Before pushing and in CI.

Why separate them. If the slow tests are mixed in with the fast ones, the whole suite takes minutes and stops being run during development; and once a suite stops being run, it stops being useful. Separating them allows the short cycle —save, ./mvnw test, five seconds— without giving up the full check before pushing the change.

  1. Coverage with JaCoCo

Code coverage measures what percentage of the code is executed during the test suite. JaCoCo instruments it at run time and generates an HTML report. It is configured like this:

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.12</version>
    <executions>
        <execution>
            <id>prepare-agent</id>
            <goals>
                <goal>prepare-agent</goal>   <!-- hooks the agent in before the tests -->
            </goals>
        </execution>
        <execution>
            <id>generate-report</id>
            <phase>verify</phase>
            <goals>
                <goal>report</goal>          <!-- generates the HTML after the tests -->
            </goals>
        </execution>
    </executions>
    <configuration>
        <excludes>
            <!-- Classes with no logic of their own: including them only dilutes the number -->
            <exclude>com/ciclourbana/**/dto/**</exclude>
            <exclude>com/ciclourbana/CicloUrbanaApplication.class</exclude>
        </excludes>
    </configuration>
</plugin>
./mvnw verify
# The report lands in: target/site/jacoco/index.html

The report colours every line of source: green if it was executed, red if it was not, yellow if it is a branch where only one exit has been taken (an if that always evaluated to true). Yellow is the most informative colour: it points at exactly the edge cases still to be tested.

Two metrics live side by side in the report, and only one is interesting:

Metric What it measures Usefulness
Line coverage Lines executed / total lines Low: a line with an && counts as covered having tested a single case
Branch coverage Condition exits taken / total High: it reveals the half-tested ifs

The warning, which is the point of this section

Coverage measures what code has been executed, not what behaviour has been verified. These two things are nothing alike, and here is the proof:

@Test
void thisTestGivesFullCoverageAndVerifiesAbsolutelyNothing() {
    new StandardFare().calculate(Duration.ofMinutes(30));
    new StudentFare().calculate(Duration.ofMinutes(30));
    new SeniorFare().calculate(Duration.ofMinutes(30));
    // Not a single assert.
}

JaCoCo will report 100 % coverage of the three fares. If tomorrow somebody changes the price per minute from €0.12 to €1.20, this test will still pass, and the city of Ribalta will bill ten times too much with the build showing green.

Hence Goodhart's law applied to software: when a measure becomes a target, it ceases to be a good measure. If the team sets itself "85 % coverage or the build fails", what you get is assertion-free tests written the afternoon before the deadline. The CicloUrbana policy:

  • Coverage is looked at, not chased. Its correct use is hunting for red: a whole business if left uncovered is a legitimate question.
  • A low minimum threshold (40–50 %) that only prevents gross backsliding is defensible; a high one is counterproductive.
  • Far more valuable than the overall percentage is the percentage of new code, which is what modern review systems show on every change.

If you still want a threshold that breaks the build, jacoco:check with a rule:

<execution>
    <id>check-coverage</id>
    <phase>verify</phase>
    <goals><goal>check</goal></goals>
    <configuration>
        <rules>
            <rule>
                <element>BUNDLE</element>
                <limits>
                    <limit>
                        <counter>BRANCH</counter>
                        <value>COVEREDRATIO</value>
                        <minimum>0.50</minimum>
                    </limit>
                </limits>
            </rule>
        </rules>
    </configuration>
</execution>

  1. TDD: red, green, refactor

Test-driven development inverts the usual order: the test first, the code afterwards. Its cycle has three steps and repeats in minutes, not hours.

flowchart LR
    R["RED<br/>Write a test<br/>that fails"] --> G["GREEN<br/>The simplest code<br/>that makes it pass"]
    G --> F["REFACTOR<br/>Improve the design<br/>with the test green"]
    F --> R

Applied to a real Ribalta rule —the council requires that no station have fewer than 8 docks, the minimumCapacity of NetworkProperties from 02-05:

Red. The test is written before the method exists:

@Test
void rejectsTheStationWhenTheCapacityIsBelowEight() {
    StationService service = new StationService(new InMemoryStationRepository());

    assertThatThrownBy(() -> service.validateCapacity(6))
            .isInstanceOf(BusinessRuleException.class)
            .hasMessageContaining("8 docks");
}

It does not compile: validateCapacity does not exist. That is already the red, and it is informative: by writing the call you have designed the method signature before writing it.

Green. The minimum that makes it pass:

public void validateCapacity(int capacity) {
    if (capacity < 8) {
        throw new BusinessRuleException("INSUFFICIENT_CAPACITY",
                "A Ribalta station needs at least 8 docks");
    }
}

Refactor. With the test green, the literal 8 is replaced by networkProperties.minimumCapacity(). If the test still passes afterwards, the refactoring is correct; if it breaks, you found out in five seconds.

What it really gives you, beyond the slogan:

Benefit Where you notice it
Design from the outside in You write the call before the implementation, so the signature comes out readable
Only the code that is needed It is hard to write functionality nobody asked for when you first have to justify it with a test
Coverage as a consequence It is not chased: it appears
Feedback in seconds The cycle lasts minutes; the bug is never far away

And an honest position: TDD is not compulsory and this course does not impose it. It is especially comfortable with algorithmic logic and clear rules —fares, validations, calculations— and rather uncomfortable when you are exploring an API you do not know. What is non-negotiable is that the test exists before the task is called done, whether it was written before or after.

  1. Test doubles

To test RentalService in isolation you need to give it a RentalRepository that does not touch PostgreSQL. Objects that stand in for a real collaborator are generically called test doubles, and they are not all the same:

Double What it does Example in CicloUrbana What is verified
Dummy Passed in to fill a parameter, never used A Clock in a method that never reads it Nothing
Stub Returns fixed, pre-programmed answers A BikeRepository that always returns RB-0142 The state of the result
Spy A real object that also records how it was called A real FareSelector that notes which fare was requested The behaviour, partially
Mock A double with defined interaction expectations An ApplicationEventPublisher that must receive RentalStarted The behaviour
Fake A real but simplified implementation InMemoryStationRepository from 02-01, with its ConcurrentHashMap The state

Two observations that guide the whole of module 6:

  • In everyday practice "mock" is used for everything, and Mockito feeds that: its method is called mock(...) even though the usual use is as a stub. The conceptual distinction still matters, because verifying state produces robust tests and verifying interactions produces brittle ones, coupled to how the method is written inside.
  • CicloUrbana already has a fake written: InMemoryStationRepository. In many cases it is preferable to five lines of when(...).thenReturn(...), and in 06-03 we will compare both options against a clear criterion.

Lesson 06-03 is devoted entirely to Mockito. Here it is enough to recognise the names.

Common Mistakes and Tips

Writing tests with no assertions. The case from section 10: the code runs, nothing is checked, JaCoCo says 100 %. A mechanical review rule: every test has at least one assertThat or one assertThatThrownBy; if it does not, it is not a test.

Confusing "does not fail" with "works". Calling a method and checking it does not throw is the weakest possible assertion. Check the returned value, the resulting state or the specific interaction.

Tests that depend on each other. If testB needs testA to have inserted a row, the suite is a house of cards: JUnit does not guarantee the order and changing one breaks the other. Every test arranges its own data and leaves no trace. If you feel tempted to pin the order with @TestMethodOrder, that is almost always the sign of a design problem.

Putting an application.yml in src/test/resources. It does not merge with the production one: it replaces it, and the tests start failing over properties that are "definitely set". Use application-test.yml with @ActiveProfiles("test") (06-04).

Using @SpringBootTest for everything. This is the structural mistake that builds the ice-cream cone. Starting the context to test a fare formula multiplies the run time by a thousand and does not catch a single extra failure. The context goes up when what you are testing is the integration.

Logic inside the test. An if, a for or a calculation inside the assertion block introduces the possibility that the test has a bug of its own, and then who tests the test? Expected values are written as literals; for multiple cases there are the parameterised tests of 06-02.

Tip: a test is written with the day it fails in mind. A name that describes the rule, a failure message you can understand without opening the code, and a single reason to fail. The day a test saves you half an hour of debugging at eleven at night, you will understand why.

Tip: when a production bug arrives, the test comes first. Reproduce the bug with a failing test, fix it and watch it turn green. That way you know you fixed what you thought you fixed, and you guarantee it will not come back.

Tip: if testing is hard, do not force the test: fix the design. Needing to mock static methods, to instantiate six collaborators or to tamper with the system clock are symptoms, not obstacles.

Exercises

Exercise 1

Write StudentFareTest with three tests covering the 15-free-minutes rule from 02-02: a 10-minute rental (free), one of exactly 15 minutes (free: the limit is inclusive) and one of 45 minutes. Apply the Arrange-Act-Assert pattern with the phase comments, use isEqualByComparingTo and name the methods as sentences describing the rule. Justify in a comment why the exactly-15-minutes case is the most important of the three.

Exercise 2

Configure JaCoCo in CicloUrbana's pom.xml following section 10, run ./mvnw verify with the fare tests already written and open target/site/jacoco/index.html. Answer in writing: what branch coverage does the package com.ciclourbana.rentals have? Which class in the project sits at 0 %? And what is the most worrying yellow if you can find? Then write a deliberately useless test (with no assertions) against FareSelector, regenerate the report and note how much the percentage went up.

Exercise 3

Classify the following eight pending CicloUrbana checks by kind of test (unit, slice, integration, E2E) and decide for each whether it is worth writing, justifying the decision in one sentence:

  1. That SeniorFare.name() returns "senior".
  2. That GET /api/v1/stations/99 returns a ProblemDetail with status 404.
  3. That StationResponse is a record with six components.
  4. That a citizen gets 403 when finishing somebody else's rental.
  5. That findByUserIdAndEndedAtIsNull returns only rentals with no end date.
  6. That migration V4 creates the index on rentals(started_at).
  7. That server.port is 8080.
  8. That a JWT token expires after fifteen minutes.

Solutions

Solution 1

package com.ciclourbana.rentals;

import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.time.Duration;

import static org.assertj.core.api.Assertions.assertThat;

class StudentFareTest {

    private final StudentFare fare = new StudentFare();

    @Test
    void chargesNothingBelowTheFifteenFreeMinutes() {
        // Arrange
        Duration duration = Duration.ofMinutes(10);
        // Act
        BigDecimal amount = fare.calculate(duration);
        // Assert: 10 < 15, entirely within the university agreement
        assertThat(amount).isEqualByComparingTo("0.00");
    }

    /*
     * The decisive case. The code is Math.max(0, minutes - 15):
     * at exactly 15 minutes the result is 0 and the rental is free.
     * If somebody changed the condition to "minutes > 15 ? ... : charge",
     * or turned the limit into an exclusive one, this is the ONLY one of the
     * three tests that would go red. Exact boundaries are where off-by-one
     * errors live, and that is why they are the cases that must never be missing.
     */
    @Test
    void atExactlyFifteenMinutesTheRentalIsStillFree() {
        BigDecimal amount = fare.calculate(Duration.ofMinutes(15));

        assertThat(amount).isEqualByComparingTo("0.00");
    }

    @Test
    void chargesEightCentsForEveryMinuteBeyondTheFreeAllowance() {
        // Arrange: 45 minutes -> 30 billable
        Duration duration = Duration.ofMinutes(45);
        // Act
        BigDecimal amount = fare.calculate(duration);
        // Assert: 0.08 × (45 - 15) = 2.40
        assertThat(amount).isEqualByComparingTo("2.40");
    }
}

Comment: the field private final StudentFare fare as shared state is acceptable here precisely because the class has no mutable state —the same property that made it safe as a singleton back in 02-03. If it had any, each test would need to create its own instance in a @BeforeEach. Note too that all three tests assert with isEqualByComparingTo("0.00") and not with isZero(): isZero() would work, but it stops reading like an amount of money.

Solution 2

With only the fare tests written, the report shows something like this:

Package Instruction coverage Branch coverage
com.ciclourbana.rentals ~35 % ~40 % (the fares only)
com.ciclourbana.stations 0 % 0 %
com.ciclourbana.security 0 % 0 %
com.ciclourbana.common 0 % 0 %
  • Almost everything sits at 0 %, including the three things that matter most: RentalService, GlobalExceptionHandler and the whole security package. That is the real value of the first report: the map of what is unprotected.
  • The most worrying yellow is usually StandardFare.calculate: the line Math.max(1, duration.toMinutes()) contains a branch that the 30- and 45-minute tests never take, the one for a rental of less than a minute. There is a hidden business rule there —"a minimum of one minute is charged"— that nobody has ever verified.
  • After adding the assertion-free test against FareSelector, its coverage jumps to nearly 100 % and the package total rises several points. Not a single new behaviour is verified. That is the conclusion of the exercise: the number went up, the safety did not. Coverage is useful read as a map of the red, and misleading read as a score.

Solution 3

# Kind Worth it? Justification
1 Unit Yes, trivial as it is You are not testing the return but the contract with FareSelector: if somebody deletes name(), the fare stops being found and the failure is silent
2 Slice (@WebMvcTest) Yes It is public contract: status code and the shape of the ProblemDetail (06-04)
3 — No That is the class signature: the compiler checks it
4 Security slice or integration Yes, top priority It is the rule from 05-05, it protects other people's data and it fails silently (06-04)
5 Slice (@DataJpaTest) Yes A derived query is code generated from a name: renaming it changes the query without warning (06-04)
6 Integration with PostgreSQL Yes This is exactly what H2 cannot validate; it is the case for 06-05
7 — No If the port is wrong the application does not start: startup already checks it
8 Unit with a fixed Clock Yes A security rule with a time dependency; the injected Clock in JwtService exists for precisely this (06-02)

The pattern that emerges from the table: you test what can fail silently. Whatever breaks the compilation or stops the application starting already has someone watching it.

Conclusion

This module starts where the previous one ended: with the suspicion that CicloUrbana works and no automatic way of proving it. Now you have the judgement to build one. You know we test for four reasons —regressions, safe refactoring, living documentation and design pressure— and that the last one explains decisions we have been carrying since module 2: the injected Clock, constructor injection, the StationRepository interface and the RentalSecurity bean were not academic elegance, they were design for testability. You know the six kinds of test with their cost and what each catches, the pyramid and the ice-cream cone, and the concrete proportion we aim for in CicloUrbana: seventy per cent unit tests that run in seconds, twenty per cent slices, nine per cent integration against a real database and one per cent end to end.

You know how to say no: we do not test getters, nor somebody else's framework, nor trivial configuration, and we do test everything that can fail silently, starting with the security rules and ending with every bug found in production. You have an inventory of what spring-boot-starter-test already brings —JUnit 5, Spring Test, AssertJ, Hamcrest, Mockito, JSONassert, JsonPath and Awaitility— and the reason not to add a single loose dependency alongside it. You know the mirror structure of src/test/java, the trap of placing an application.yml in src/test/resources, the course naming conventions and the Arrange-Act-Assert pattern, already applied to StandardFareTest: CicloUrbana's first real test, with its isEqualByComparingTo and its comment showing the expected calculation. You know how to run tests with ./mvnw test and its filters, to tell Surefire from Failsafe and why *Test and *IT live apart, to configure JaCoCo and —above all— to read it as a map of the red and never as a score, after watching an assertion-free test reach 100 %. And you have seen the red-green-refactor cycle applied to the eight-docks rule, and the table of the five test doubles.

With the judgement in place, it is time for the technique. The next lesson, Unit Testing with JUnit, goes deep into the tool: the architecture of JUnit 5, the full lifecycle of its annotations, the AssertJ assertion catalogue by data type —with its BigDecimal trap and soft assertions—, parameterised tests applied to Ribalta's three fares in a single class, organisation with @Nested and @DisplayName, the Clock.fixed that makes a rental duration calculation deterministic, and good practice for test data with the Object Mother pattern. We are going to fill the base of the pyramid.

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