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
- Why we test
- The kinds of test
- The test pyramid and the ice-cream cone
- What is worth testing and what is not
spring-boot-starter-test: the toolbox- Where tests live: structure and conventions
- Anatomy of a test: Arrange-Act-Assert
- Running the tests
- Surefire and Failsafe:
*Testversus*IT - Coverage with JaCoCo
- TDD: red, green, refactor
- Test doubles
- Common Mistakes and Tips
- Exercises
- 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).
- 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.
- 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 500and 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.
- 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
ProblemDetailresponses 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.
spring-boot-starter-test: the toolbox
spring-boot-starter-test: the toolboxSpring 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:
- 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:
- 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.
src/test/resourcescomes beforesrc/main/resourceson the test classpath. Anapplication.ymlplaced 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.- Nothing from
src/test/javaends 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.
- 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, withoutpublic, in the packagecom.ciclourbana.rentals, an exact mirror of the class under test.new StandardFare(): there is no Spring anywhere.StandardFareis 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 notisEqualTo(new BigDecimal("4.10")).BigDecimal.equalscompares the scale as well, so4.1and4.10are not equal for it even though they are worth the same. With money, alwaysisEqualByComparingTo. It is the first of the AssertJ traps and it is covered in detail in 06-02.- The comment with the expected calculation:
4.10is 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:
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:
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.
- 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 -DskipTestsA 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.
- Surefire and Failsafe:
*Test versus *IT
*Test versus *ITMaven 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.
- 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>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
ifleft 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>
- 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.
- 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 ofwhen(...).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:
- That
SeniorFare.name()returns"senior". - That
GET /api/v1/stations/99returns aProblemDetailwith status 404. - That
StationResponseis arecordwith six components. - That a citizen gets
403when finishing somebody else's rental. - That
findByUserIdAndEndedAtIsNullreturns only rentals with no end date. - That migration
V4creates the index onrentals(started_at). - That
server.portis 8080. - 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,GlobalExceptionHandlerand the wholesecuritypackage. That is the real value of the first report: the map of what is unprotected. - The most worrying yellow is usually
StandardFare.calculate: the lineMath.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
- 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
