Eleven lessons in module 10, three in module 11, a complete migration from CSV to a relational database, a rewritten domain, generics, streams, java.time, sealed classes, virtual threads, Spring and Hibernate.

And not a single automated test.

Every refactoring of the last two modules has been done without a safety net. Every time you changed the fine calculation, the only way to know whether it still worked was to start the application, lend a book, wait, and look at the console. Every time you touched the loan limit per employee, you trusted that you had not broken anything.

This lesson puts an end to that.

An automated test is a program that runs your code with known inputs and checks that the result is the expected one, with nobody watching. JUnit 5 is the standard framework for writing them in Java: it discovers your methods, runs them, reports failures and integrates with Maven, with the IDE and with continuous integration.

And here a design decision you have been dragging along for two modules finally pays off: that Clock you injected in 10-05 "so the due date could be tested without waiting fifteen days", which went through Spring as a @Bean in 11-02 and has appeared in every LocalDate.now(clock) of 11-03. Today you use it.

By the end you will know why we test and what you get in return; you will know JUnit 5's architecture and the anatomy of a test; you will master JUnit's assertions and AssertJ's; you will know how to organise tests with life cycle, nesting and tags; you will write parameterised tests that cover twelve cases in six lines; and —most importantly— you will understand what makes a design testable and what code simply cannot be tested.

BiblioTech will have its first test suite.

Contents

  1. Why test: the cost of having no tests
  2. What you get in return
  3. What exactly an automated test is
  4. The test pyramid
  5. JUnit 5: architecture
  6. The dependencies in the pom.xml
  7. BiblioTech's first test
  8. Anatomy: Arrange-Act-Assert
  9. Test names and @DisplayName
  10. JUnit's assertions
  11. assertThrows and exceptions
  12. assertAll: grouping checks
  13. AssertJ: why it is recommended
  14. The life cycle: @BeforeEach and friends
  15. One instance per test and @TestInstance
  16. @Disabled and the conditional annotations
  17. Parameterised tests
  18. @MethodSource and the complex cases
  19. @Nested: organising by scenario
  20. @Tag and selective execution
  21. @TempDir: testing file code
  22. What makes a design testable
  23. The injectable Clock, finally cashed in
  24. The code that CANNOT be tested
  25. What a good test suite looks like
  26. Antipatterns
  27. Testing concurrent code
  28. Running the tests with Maven
  29. Coverage, mentioned
  30. @SpringBootTest and @DataJpaTest, introduced
  31. BiblioTech's test suite
  32. Common Mistakes and Tips
  33. Exercises

  1. Why test: the cost of having no tests

Let us start with the uncomfortable side, using BiblioTech as a real example.

In 11-03 you turned Material from a sealed record into an abstract class with @Entity. You swapped Optional<LocalDate> returnDate for a nullable field with a wrapping getter. You replaced CsvReader with JpaRepository. You introduced @Version and a retry loop.

Question: does the fine calculation still give the same result as before the migration?

The honest answer today is "I think so". You do not know. Nobody knows.

That "I think so" has five concrete costs:

Cost What it means in practice
Fear of change The code rots because nobody dares touch it. "It works, don't touch it"
Regressions A fix breaks something that worked, and it is discovered in production
Slow debugging With no tests, locating a failure means starting the application and reproducing it by hand
Manual checking Every change requires repeating the same 20 checks by hand
Updating dependencies Going from Spring Boot 3.3 to 3.4 turns into a three-week project

That last one deserves attention, because it links straight back to Log4Shell (11-01): the ability to respond quickly to a vulnerability depends on having tests. Without them, changing a version requires a manual testing campaign, and your exposure window is measured in weeks.

And there is a subtler cost: code without tests tends to be worse code. Not because the author is worse, but because nothing forces them to make their classes constructible in isolation. A static that reads the system clock, a new inside a method, a class with nine dependencies: none of that stands out until you try to test it. Tests are a detector of design problems.

  1. What you get in return

Benefit Explanation
Refactoring without fear You change the implementation, run the tests, and know whether you broke something. In seconds
Executable documentation fineForFiveDaysLateIsTwoEurosFifty documents the rule and cannot go stale, because if it lies, it fails
Regressions caught The failure shows up instantly, not in production
Faster debugging A failing test narrows the problem down to one method
Better design Testing forces you to inject dependencies, isolate effects and separate responsibilities
Confidence to deploy Continuous integration runs 400 tests in 20 seconds before every deployment

On executable documentation, an example from BiblioTech. This:

@Test
void theFineIsCappedAtTheConfiguredMaximum() {
    Loan loan = loanOverdueBy(200);   // 200 days × €0.50 = €100
    assertThat(calculator.calculate(loan)).isEqualByComparingTo("20.00");
}

communicates a business rule better than any comment, and with a property no comment has: if somebody changes the behaviour without changing the rule, the test goes red.

  1. What exactly an automated test is

An automated test is code that:

  1. Arranges a known scenario.
  2. Runs the code under verification.
  3. Checks that the result is the expected one.
  4. Fails loudly if it is not.

Compared with what you have done so far:

Manual checking (modules 1-11) Automated test
Who checks A human looking at the console The framework
When When somebody remembers On every build
Cost of repeating it Minutes of a person's time Milliseconds
Edge cases The ones somebody thinks of Every one you wrote, always
Can it be forgotten? Yes No
Result "Looks about right" Green or red

  1. The test pyramid

Not all tests are alike. The test pyramid describes the healthy proportion between the types.

graph TD
    E2E["END TO END<br/>Few · Slow (seconds-minutes) · Brittle<br/>The whole application deployed"]
    INT["INTEGRATION<br/>Some · Medium (hundreds of ms)<br/>Several pieces together: DB, HTTP, Spring context"]
    UNIT["UNIT<br/>MANY · Fast (milliseconds) · Stable<br/>One class, no external dependencies"]
    E2E --- INT
    INT --- UNIT
Type What it tests Speed How many In BiblioTech
Unit One class in isolation 1-10 ms Many (70-80 %) FineCalculator, Loan rules
Integration Several real pieces together 100 ms - 2 s Some (15-25 %) LoanRepository against H2, @SpringBootTest
End to end The whole system Seconds or minutes Few (5 %) Lending a book through the REST API (12-05)

Why the shape matters: if you invert the pyramid and nearly all your tests are integration tests, your suite takes twenty minutes, fails intermittently and nobody runs it. If it is pyramid-shaped, it takes twenty seconds and runs on every save.

This lesson focuses on the base: unit tests of BiblioTech's domain. Integration is introduced at the end and developed in 11-06 and 12-05.

  1. JUnit 5: architecture

JUnit 5 is not one artifact but three subprojects. Knowing that avoids a lot of confusion with dependencies.

graph TD
    A["JUnit 5 (JUnit Platform + Jupiter + Vintage)"] --> B["JUnit Platform<br/>Discovery and execution engine.<br/>It is what Maven, Gradle and the IDEs use"]
    A --> C["JUnit Jupiter<br/>The API you write against:<br/>@Test, @BeforeEach, Assertions...<br/>plus its execution engine"]
    A --> D["JUnit Vintage<br/>Engine that runs JUnit 3 and 4 tests<br/>(legacy code only)"]
    B --- C
    B --- D
Component What it is You use it
Platform Discovery and execution infrastructure Indirectly
Jupiter JUnit 5's API and engine Directly: it is what you write
Vintage JUnit 4 compatibility Only if you migrate old code

Differences from JUnit 4, useful if you come across old code:

JUnit 4 JUnit 5 (Jupiter)
org.junit.Test org.junit.jupiter.api.Test
@Before / @After @BeforeEach / @AfterEach
@BeforeClass / @AfterClass @BeforeAll / @AfterAll
@Ignore @Disabled
@RunWith / @Rule @ExtendWith (unified extension model)
expected = X.class assertThrows(X.class, ...)
public classes and methods mandatory Package visibility is enough

  1. The dependencies in the pom.xml

With Spring Boot, a single dependency brings everything:

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

mvn dependency:tree reveals what it drags in:

Artifact What for
junit-jupiter JUnit 5: API and engine
assertj-core Fluent assertions (section 13)
mockito-core, mockito-junit-jupiter Test doubles (11-06)
spring-test, spring-boot-test @SpringBootTest, MockMvc
hamcrest Matchers (less used today)
jsonassert, json-path Assertions over JSON
xmlunit-core Assertions over XML

Notice <scope>test</scope>: those libraries are not packaged into the final jar. That is Maven's test scope, explained in 11-05.

Without Spring, the minimum dependencies would be:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>

And the directory layout Maven imposes (11-05):

src/
├── main/java/com/nexussoftware/bibliotech/     <- production code
├── main/resources/                             <- application.yml
├── test/java/com/nexussoftware/bibliotech/     <- THE TESTS, same package
└── test/resources/                             <- application-test.yml, test fixtures

The convention of putting the test in the same package as the class under test has a useful consequence: the test can reach package-visible members without having to make them public.

  1. BiblioTech's first test

The class we are going to test, as it stood after 11-02:

package com.nexussoftware.bibliotech.service;

import java.math.BigDecimal;
import java.time.*;
import java.time.temporal.ChronoUnit;
import org.springframework.stereotype.Service;

@Service
public class FineCalculator {

    private final BiblioTechProperties properties;
    private final Clock clock;

    public FineCalculator(BiblioTechProperties properties, Clock clock) {
        this.properties = properties;
        this.clock = clock;
    }

    public BigDecimal calculate(Loan loan) {
        if (loan.getReturnDate().isPresent()) {
            return BigDecimal.ZERO;   // already returned: no fine
        }
        LocalDate today = LocalDate.now(clock);
        long daysLate = ChronoUnit.DAYS.between(loan.getDueDate(), today);
        if (daysLate <= 0) {
            return BigDecimal.ZERO;   // still within term
        }
        BigDecimal fine = properties.fine().eurosPerDay()
                .multiply(BigDecimal.valueOf(daysLate));
        return fine.min(properties.fine().max());
    }
}

And its first test, in src/test/java/com/nexussoftware/bibliotech/service/FineCalculatorTest.java:

package com.nexussoftware.bibliotech.service;

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

import java.math.BigDecimal;
import java.time.*;
import org.junit.jupiter.api.Test;

class FineCalculatorTest {

    @Test
    void aLoanFiveDaysLateGeneratesTwoEurosFifty() {

        // ARRANGE: a FIXED clock. "Today" is always 20 March 2026.
        Clock fixedClock = Clock.fixed(
                Instant.parse("2026-03-20T10:00:00Z"), ZoneId.of("Europe/Madrid"));

        BiblioTechProperties properties = testProperties(
                new BigDecimal("0.50"), new BigDecimal("20.00"));

        FineCalculator calculator = new FineCalculator(properties, fixedClock);

        // Due on the 15th -> 5 days late relative to the 20th
        Loan loan = new Loan(aBook(), anEmployee(),
                LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 15));

        // ACT
        BigDecimal fine = calculator.calculate(loan);

        // ASSERT
        assertThat(fine).isEqualByComparingTo("2.50");
    }
}

Stop at something important: there is no Spring anywhere. No @SpringBootTest, no context, no database. The class is built with new, its dependencies are handed to it, a method is called and the result is checked. It takes less than a millisecond.

That is possible precisely because in 11-02 constructor injection was chosen and in 10-05 the Clock was injected instead of calling LocalDate.now(). Every design decision of the last few modules is cashed in on this line.

Running it:

mvn test
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

  1. Anatomy: Arrange-Act-Assert

Every well-written test has three phases, and it pays to separate them visually. The pattern is known as AAA (Arrange-Act-Assert) or Given-When-Then.

@Test
void anEmployeeCannotExceedTheLoanLimit() {

    // ARRANGE: set up the scenario
    Employee marta = new Employee("[email protected]", "Marta Ruiz");
    LoanManager manager = new LoanManager(repository, calculator, notices, properties, clock);
    manager.lend("978-0000000001", marta.getEmail());
    manager.lend("978-0000000002", marta.getEmail());
    manager.lend("978-0000000003", marta.getEmail());

    // ACT: ONE single action, the one being tested
    ThrowingCallable fourthLoan =
            () -> manager.lend("978-0000000004", marta.getEmail());

    // ASSERT: verify the result
    assertThatThrownBy(fourthLoan)
            .isInstanceOf(LoanLimitExceededException.class)
            .hasMessageContaining("[email protected]");
}

Rules of the pattern:

  1. One single action per test. If there are two, they are two tests.
  2. The phases are separated by a blank line or a comment.
  3. No logic in the test: no improvised if, for or try/catch. A test with logic is code that can also have bugs, and there are no tests for the tests.
  4. A test checks one behaviour, not one method. A method with three rules needs three tests.

  1. Test names and @DisplayName

A test's name is documentation. When it fails in continuous integration, the first thing —sometimes the only thing— anyone reads is its name.

Bad name Good name
test1 theFineIsZeroIfTheLoanIsWithinTerm
testCalculate theFineIsCappedAtTheConfiguredMaximum
testFine aReturnedLoanGeneratesNoFine
testException lendingAMaterialWithNoCopiesThrowsNoCopiesAvailableException

A useful formula: condition + expectedResult, or the should style: shouldCapTheFineAtTheMaximum.

And for full prose, @DisplayName:

@DisplayName("Fine calculation for late returns")
class FineCalculatorTest {

    @Test
    @DisplayName("A loan 5 days late generates a fine of €2.50")
    void fineForFiveDays() { ... }

    @Test
    @DisplayName("The fine never exceeds the configured maximum (€20)")
    void fineCappedAtTheMaximum() { ... }
}

The IDE output and the reports become readable by anyone:

Fine calculation for late returns
  ✔ A loan 5 days late generates a fine of €2.50
  ✔ The fine never exceeds the configured maximum (€20)

  1. JUnit's assertions

org.junit.jupiter.api.Assertions (usually with import static):

Assertion What it checks Example
assertEquals(expected, actual) Equality using equals assertEquals(3, manager.countActive())
assertNotEquals(a, b) Inequality
assertTrue(cond) / assertFalse(cond) Boolean condition assertTrue(loan.isOverdue(today))
assertNull(o) / assertNotNull(o) Nullness
assertSame(a, b) / assertNotSame(a, b) Reference identity (==)
assertArrayEquals(a, b) Arrays element by element
assertIterableEquals(a, b) Iterables in order
assertLinesMatch(a, b) Lines, with regular expressions
assertThrows(C.class, exec) That an exception is thrown
assertDoesNotThrow(exec) That no exception is thrown
assertTimeout(dur, exec) That it finishes in time
assertAll(...) Groups several assertions
fail("reason") Fails unconditionally A branch that should never be reached

Careful with argument order: the expected value goes first. Swapping it does not change the outcome, but it produces misleading error messages ("expected 5 but was 3" when it was the other way round).

And always add a message when the assertion is not obvious:

assertEquals(3, manager.countActive(marta),
             "Marta should have 3 active loans after lending three materials");

That message can be a Supplier<String> so that it is only built if the test fails:

assertTrue(loan.isOverdue(today),
           () -> "The loan was due on " + loan.getDueDate() + " and today is " + today);

  1. assertThrows and exceptions

Testing that something fails as it should is as important as testing that it works.

@Test
void lendingAMaterialWithNoCopiesThrowsAnException() {

    Material soldOut = new Book("978-0000000003", "Refactoring", 0, "M. Fowler");

    // assertThrows RETURNS the exception: it can be inspected
    NoCopiesAvailableException exception = assertThrows(
            NoCopiesAvailableException.class,
            () -> soldOut.lendOneCopy());

    assertEquals("978-0000000003", exception.getIsbn());
    assertTrue(exception.getMessage().contains("978-0000000003"));
}

Three details that matter:

  1. It returns the exception, so you can check its message, its cause and its own fields. Take advantage of it: checking only the type is a weak test.
  2. The second argument is an Executable, a functional interface. That is why a lambda is passed (module 4).
  3. It accepts subclasses. assertThrows(BiblioTechException.class, ...) passes if NoCopiesAvailableException is thrown. If you need the exact type, use assertThrowsExactly.

And the mistake to avoid:

// BAD: if the exception is NOT thrown, the test passes anyway
@Test
void bad() {
    try {
        soldOut.lendOneCopy();
    } catch (NoCopiesAvailableException e) {
        assertEquals("978-0000000003", e.getIsbn());
    }
    // If nothing is thrown, no catch runs... and the test is green.
}

  1. assertAll: grouping checks

Problem: if a test has five assertions and the first one fails, you never see the other four. You fix one thing, run again, the next one fails. Five iterations to see all the information.

assertAll runs every assertion and reports every failure:

@Test
void aNewLoanHasTheCorrectData() {

    LocalDate today = LocalDate.of(2026, 3, 1);
    Loan loan = new Loan(aBook(), anEmployee(), today, 15);

    assertAll("data of the newly created loan",
            () -> assertEquals(today, loan.getLoanDate()),
            () -> assertEquals(today.plusDays(15), loan.getDueDate()),
            () -> assertEquals(LoanStatus.ACTIVE, loan.getStatus()),
            () -> assertTrue(loan.getReturnDate().isEmpty()),
            () -> assertNotNull(loan.getMaterial()));
}

If two fail, the report shows both. Use it when you check several properties of the same result; do not use it to cram three different behaviours into one test.

  1. AssertJ: why it is recommended

AssertJ offers fluent assertions, and it comes bundled in spring-boot-starter-test. The main difference is not cosmetic: it is the error messages.

// JUnit
assertEquals(3, loans.size());
org.opentest4j.AssertionFailedError: expected: <3> but was: <2>
// AssertJ
assertThat(loans).hasSize(3);
Expected size: 3 but was: 2 in:
  [Loan{isbn='978-0000000001', employee='Marta Ruiz', due=2026-03-15},
   Loan{isbn='978-0000000002', employee='Marta Ruiz', due=2026-03-16}]

The second tells you what was there, not just how many. In a test that fails in continuous integration at eleven at night, that difference is enormous.

Side by side:

Check JUnit AssertJ
Equality assertEquals(a, b) assertThat(b).isEqualTo(a)
Collection size assertEquals(3, l.size()) assertThat(l).hasSize(3)
Contains assertTrue(l.contains(x)) assertThat(l).contains(x)
Contains exactly Hand-written loop assertThat(l).containsExactly(a, b, c)
Empty assertTrue(l.isEmpty()) assertThat(l).isEmpty()
String contains assertTrue(s.contains("x")) assertThat(s).contains("x")
BigDecimal ignoring scale assertEquals(0, a.compareTo(b)) assertThat(a).isEqualByComparingTo("2.50")
Optional with a value assertTrue(o.isPresent()) && ... assertThat(o).contains(x)
Exception assertThrows(...) assertThatThrownBy(...).isInstanceOf(...)
Extracting a field Hand-written loop assertThat(l).extracting("title").contains("...")

Examples that show how expressive it is:

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

// Chaining over collections
assertThat(overdueLoans)
        .hasSize(2)
        .extracting(Loan::getEmployee)
        .extracting(Employee::getName)
        .containsExactlyInAnyOrder("Marta Ruiz", "Diego Alonso");

// BigDecimal: it compares VALUE, not scale. 2.50 vs 2.5 does not fail.
assertThat(fine).isEqualByComparingTo("2.50");

// Optional (10-04)
assertThat(repository.findByIsbn("978-0000000001"))
        .isPresent()
        .get()
        .extracting(Material::getTitle)
        .isEqualTo("Effective Java");

// Exceptions, in more detail
assertThatThrownBy(() -> manager.lend("978-9999999999", "[email protected]"))
        .isInstanceOf(MaterialNotFoundException.class)
        .hasMessageContaining("978-9999999999")
        .hasNoCause();

// Comparing objects field by field, ignoring the generated id
assertThat(savedLoan)
        .usingRecursiveComparison()
        .ignoringFields("id", "version")
        .isEqualTo(expectedLoan);

// Predicates over every element
assertThat(activeLoans)
        .isNotEmpty()
        .allMatch(l -> l.getReturnDate().isEmpty())
        .allSatisfy(l -> assertThat(l.getDueDate()).isAfter(l.getLoanDate()));

That isEqualByComparingTo line solves a very real problem: new BigDecimal("2.50").equals(new BigDecimal("2.5")) is false, because BigDecimal's equals also compares the scale. With assertEquals that produces baffling failures. AssertJ has the right method.

Clear recommendation: use AssertJ. It is already on your classpath, the messages are better and the IDE's autocompletion guides you along (type assertThat(list). and look at what it offers). Use JUnit's for assertThrows if you prefer to inspect the returned exception, although assertThatThrownBy covers nearly everything.

  1. The life cycle: @BeforeEach and friends

When several tests share setup, there are life-cycle hooks.

class LoanManagerTest {

    private Clock fixedClock;
    private FineCalculator calculator;
    private LoanManager manager;
    private Employee marta;

    @BeforeAll
    static void prepareTheWholeClass() {
        // ONCE, before every test. static (except with @TestInstance PER_CLASS)
        // For expensive and SHARED resources: starting a container, loading a big file.
    }

    @BeforeEach
    void prepareEachTest() {
        // BEFORE EACH test: a clean state, no contamination between tests
        fixedClock = Clock.fixed(Instant.parse("2026-03-20T10:00:00Z"), ZoneId.of("Europe/Madrid"));
        calculator = new FineCalculator(testProperties(), fixedClock);
        manager = new LoanManager(new InMemoryRepository(), calculator,
                                  new InMemoryNotices(), testProperties(), fixedClock);
        marta = new Employee("[email protected]", "Marta Ruiz");
    }

    @AfterEach
    void cleanUpEachTest() {
        // After each test. With managed resources, rarely necessary.
    }

    @AfterAll
    static void cleanUpTheWholeClass() {
        // Once, at the end. Close whatever @BeforeAll opened.
    }
}
Annotation When How often static
@BeforeAll Before everything Once Yes (by default)
@BeforeEach Before each test N times No
@AfterEach After each test N times No
@AfterAll At the very end Once Yes (by default)

With inheritance the order is: superclass @BeforeAll → subclass @BeforeAll → superclass @BeforeEach → subclass @BeforeEach → test → subclass @AfterEach → superclass @AfterEach → ...

Important tip: prefer @BeforeEach over @BeforeAll. State shared between tests is the number-one cause of tests that depend on execution order. @BeforeAll is only for expensive and immutable resources.

And a warning about overdoing it: if @BeforeEach is thirty lines long and each test uses a different part of it, the setup becomes unreadable (the "mystery guest": nobody knows where the data came from). In that case, prefer factory methods in the test class itself:

private Loan loanOverdueBy(int days) {
    LocalDate today = LocalDate.now(fixedClock);
    return new Loan(aBook(), marta, today.minusDays(15 + days), today.minusDays(days));
}

private Material aBook() {
    return new Book("978-0000000001", "Effective Java", 3, "J. Bloch");
}

That way each test says exactly which scenario it uses: loanOverdueBy(5).

  1. One instance per test and @TestInstance

A default behaviour that surprises a lot of people: JUnit creates a new instance of the test class for every @Test method.

class CounterTest {

    private int counter = 0;   // reset on EVERY test

    @Test void first()   { counter++; assertEquals(1, counter); }   // passes
    @Test void second()  { counter++; assertEquals(1, counter); }   // ALSO passes
}

It is deliberate: it guarantees that tests are independent. A field modified in one test cannot affect another.

Consequence: @BeforeAll and @AfterAll must be static, because they belong to no instance. If you need them not to be:

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CatalogTest {

    private List<Material> catalogue;   // shared by ALL the tests

    @BeforeAll
    void loadCatalogue() {              // no longer needs to be static
        catalogue = loadLargeCatalogue();
    }
}

Use it carefully: by sharing the instance you can once again contaminate one test with another. Its legitimate use is avoiding the reload of an expensive, immutable resource.

  1. @Disabled and the conditional annotations

@Test
@Disabled("Pending a decision on the fine policy for magazines (BIB-142)")
void theMagazineFineIsHalf() { ... }

Professional rule: @Disabled always with a reason and a reference. A disabled test with no explanation is rubbish nobody will dare delete a year from now. And a test disabled for months is a test that must be deleted: it is giving a false sense of coverage.

Conditionals, for tests that only make sense in certain environments:

@Test
@EnabledOnOs(OS.LINUX)
void posixPermissionPaths() { ... }

@Test
@DisabledOnOs(OS.WINDOWS)
void symbolicLinks() { ... }

@Test
@EnabledOnJre(JRE.JAVA_21)
void virtualThreads() { ... }                       // picks up 10-06

@Test
@EnabledIfSystemProperty(named = "integration.tests", matches = "true")
void againstTheRealDatabase() { ... }

@Test
@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
void onlyOnContinuousIntegration() { ... }

The difference from @Disabled matters: a conditional test runs when the conditions are met; a @Disabled one never runs.

  1. Parameterised tests

Here is one of JUnit 5's best features. This code smells:

@Test void fineForOneDay()   { assertThat(calculate(1)).isEqualByComparingTo("0.50"); }
@Test void fineForTwoDays()  { assertThat(calculate(2)).isEqualByComparingTo("1.00"); }
@Test void fineForFiveDays() { assertThat(calculate(5)).isEqualByComparingTo("2.50"); }
@Test void fineForTenDays()  { assertThat(calculate(10)).isEqualByComparingTo("5.00"); }
// ... eight more

With @ParameterizedTest:

@ParameterizedTest(name = "{0} days late -> €{1}")
@CsvSource({
        "  1,  0.50",
        "  2,  1.00",
        "  5,  2.50",
        " 10,  5.00",
        " 39, 19.50",
        " 40, 20.00",   // exactly the maximum
        " 41, 20.00",   // past the maximum: capped
        "200, 20.00",   // far past it: still capped
        "  0,  0.00",   // due today: not late
        " -1,  0.00",   // due tomorrow
        " -5,  0.00",
        "-30,  0.00"
})
void theFineIsCalculatedFromTheDaysLate(int daysLate, BigDecimal expectedFine) {
    Loan loan = loanOverdueBy(daysLate);
    assertThat(calculator.calculate(loan)).isEqualByComparingTo(expectedFine);
}

Twelve cases in six lines, including the three edge cases that really matter (39, 40, 41 around the maximum) and the negative ones. And the output is readable:

theFineIsCalculatedFromTheDaysLate
  ✔ 1 days late -> €0.50
  ✔ 5 days late -> €2.50
  ✔ 40 days late -> €20.00
  ✔ 41 days late -> €20.00
  ...

The data sources available:

Annotation Provides Example
@ValueSource An array of literals @ValueSource(ints = {1, 5, 10})
@CsvSource Several inline columns @CsvSource({"5, 2.50"})
@CsvFileSource A CSV from src/test/resources @CsvFileSource(resources = "/fines.csv")
@EnumSource An enum's constants @EnumSource(LoanStatus.class)
@MethodSource A method returning a Stream Complex objects
@NullSource, @EmptySource, @NullAndEmptySource null and empty Input validation
@ArgumentsSource Your own provider Very elaborate cases

Examples of the most useful ones:

// Every state of the enum (04-07)
@ParameterizedTest
@EnumSource(LoanStatus.class)
void everyStatusHasANonBlankDescription(LoanStatus status) {
    assertThat(status.getDescription()).isNotBlank();
}

// Only some of them
@ParameterizedTest
@EnumSource(value = LoanStatus.class, names = {"ACTIVE", "OVERDUE"})
void unfinishedStatusesAllowReturning(LoanStatus status) {
    assertThat(status.allowsReturn()).isTrue();
}

// Validating invalid input: null, empty and rubbish
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = { " ", "abc", "978", "978-000000000X", "1234567890123" })
void anInvalidIsbnIsRejected(String invalidIsbn) {
    assertThatThrownBy(() -> new Isbn(invalidIsbn))
            .isInstanceOf(InvalidIsbnException.class);
}

That last pattern —combining @NullAndEmptySource with @ValueSource— is the canonical way to test input validation, and in four lines it covers the cases that normally get forgotten.

  1. @MethodSource and the complex cases

When the parameters are objects rather than literals:

class LoanManagerTest {

    // The method must be static (or the class, @TestInstance(PER_CLASS))
    static Stream<Arguments> loanScenarios() {
        return Stream.of(
            Arguments.of("Employee with no loans, material available",
                         0, 3, true,  null),
            Arguments.of("Employee at the limit",
                         3, 3, false, LoanLimitExceededException.class),
            Arguments.of("Material with no copies",
                         1, 0, false, NoCopiesAvailableException.class),
            Arguments.of("Employee one loan short of the limit",
                         2, 5, true,  null)
        );
    }

    @ParameterizedTest(name = "{0}")
    @MethodSource("loanScenarios")
    void biblioTechLoanScenarios(String description,
                                 int activeLoans,
                                 int availableCopies,
                                 boolean shouldSucceed,
                                 Class<? extends Exception> expectedException) {

        // ARRANGE
        Material material = new Book("978-0000000001", "Effective Java",
                                     availableCopies, "J. Bloch");
        Employee marta = new Employee("[email protected]", "Marta Ruiz");
        repository.save(material);
        repository.save(marta);
        for (int i = 0; i < activeLoans; i++) {
            repository.saveLoan(new Loan(anotherBook(i), marta, today(), 15));
        }

        // ACT and ASSERT
        if (shouldSucceed) {
            assertThat(manager.lend("978-0000000001", marta.getEmail())).isNotNull();
        } else {
            assertThatThrownBy(() -> manager.lend("978-0000000001", marta.getEmail()))
                    .isInstanceOf(expectedException);
        }
    }
}

There is a trade-off worth acknowledging here: this test has an if, and section 8 said tests should have no logic. It is an acceptable case because the logic is trivial and the gain in scenario coverage is large, but if it grows it must be split into two parameterised tests, one for success and one for failure.

@MethodSource also accepts a plain Stream when there is only one parameter:

static Stream<Material> testMaterials() {
    return Stream.of(
        new Book("978-0000000001", "Effective Java", 3, "J. Bloch"),
        new Magazine("978-0000000010", "Java Magazine", 5, 42),
        new Dvd("978-0000000020", "Java Course", 1, 180));
}

@ParameterizedTest
@MethodSource("testMaterials")
void everyMaterialCanBeLentIfThereAreCopies(Material material) {
    int before = material.getAvailableCopies();
    material.lendOneCopy();
    assertThat(material.getAvailableCopies()).isEqualTo(before - 1);
}

  1. @Nested: organising by scenario

When a test class grows, @Nested lets you group by scenario with its own setup:

@DisplayName("LoanManager")
class LoanManagerTest {

    private LoanManager manager;
    private Employee marta;

    @BeforeEach
    void commonSetup() {
        manager = createManager();
        marta = new Employee("[email protected]", "Marta Ruiz");
    }

    @Nested
    @DisplayName("when the employee has no active loans")
    class WithNoActiveLoans {

        @Test
        @DisplayName("can lend an available material")
        void canLend() {
            Loan loan = manager.lend("978-0000000001", marta.getEmail());
            assertThat(loan).isNotNull();
        }

        @Test
        @DisplayName("the due date is 15 days later")
        void correctDueDate() {
            Loan loan = manager.lend("978-0000000001", marta.getEmail());
            assertThat(loan.getDueDate())
                    .isEqualTo(LocalDate.now(fixedClock).plusDays(15));
        }
    }

    @Nested
    @DisplayName("when the employee is at the loan limit")
    class AtTheLimit {

        @BeforeEach
        void lendUpToTheLimit() {              // setup OF ITS OWN for this scenario
            manager.lend("978-0000000001", marta.getEmail());
            manager.lend("978-0000000002", marta.getEmail());
            manager.lend("978-0000000003", marta.getEmail());
        }

        @Test
        @DisplayName("cannot lend any more")
        void cannotLendAnyMore() {
            assertThatThrownBy(() -> manager.lend("978-0000000004", marta.getEmail()))
                    .isInstanceOf(LoanLimitExceededException.class);
        }

        @Test
        @DisplayName("can lend again after returning one")
        void canLendAfterReturning() {
            manager.returnItem("978-0000000001", marta.getEmail());
            assertThat(manager.lend("978-0000000004", marta.getEmail())).isNotNull();
        }
    }
}

The @BeforeEach methods stack up: first the outer class's, then the nested one's. The output is a report that reads like a specification:

LoanManager
  when the employee has no active loans
    ✔ can lend an available material
    ✔ the due date is 15 days later
  when the employee is at the loan limit
    ✔ cannot lend any more
    ✔ can lend again after returning one

Technical note: @Nested classes must be non-static inner classes (inner classes, 04-03), precisely so they can reach the outer class's state.

  1. @Tag and selective execution

Tagging tests lets you run subsets:

@Tag("fast")
class FineCalculatorTest { ... }

@Tag("slow")
@Tag("integration")
class LoanRepositoryIT { ... }
mvn test -Dgroups="fast"                      # only the fast ones
mvn test -DexcludedGroups="slow,integration"  # everything except the slow stuff

Or in the pom.xml (11-05):

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <excludedGroups>integration</excludedGroups>
    </configuration>
</plugin>

Typical use: on every save the unit tests run (seconds); in continuous integration, everything. It is the split between surefire and failsafe that you will see in 11-05.

  1. @TempDir: testing file code

BiblioTech has all the I/O code from module 7: CsvReader, CsvWriter, AtomicWrite, CatalogImporter. None of it was ever tested. @TempDir makes that trivial:

class AtomicWriteTest {

    @TempDir
    Path tempDir;              // JUnit creates it beforehand and DELETES it afterwards, alone

    @Test
    void writesTheWholeFileOrDoesNotWriteIt() throws IOException {

        Path target = tempDir.resolve("catalogue.csv");
        AtomicWrite atomicWrite = new AtomicWrite();

        atomicWrite.write(target, List.of(
                "isbn;title;copies",
                "978-0000000001;Effective Java;3"));

        assertThat(target).exists();
        assertThat(Files.readAllLines(target, StandardCharsets.UTF_8))
                .containsExactly(
                        "isbn;title;copies",
                        "978-0000000001;Effective Java;3");
    }

    @Test
    void leavesNoTemporaryFilesAfterACorrectWrite() throws IOException {
        Path target = tempDir.resolve("catalogue.csv");
        new AtomicWrite().write(target, List.of("line"));

        try (Stream<Path> files = Files.list(tempDir)) {
            assertThat(files).containsExactly(target);   // no .tmp, no .bak
        }
    }

    @Test
    void theOriginalFileSurvivesIfTheWriteFails() throws IOException {
        Path target = tempDir.resolve("catalogue.csv");
        Files.writeString(target, "original content", StandardCharsets.UTF_8);

        assertThatThrownBy(() -> new AtomicWrite().write(target, linesThatFail()))
                .isInstanceOf(PersistenceException.class);

        // The guarantee of the atomic write: the original is untouched
        assertThat(Files.readString(target, StandardCharsets.UTF_8))
                .isEqualTo("original content");
    }
}

Advantages of @TempDir over writing to /tmp by hand:

  1. It cleans itself up, even if the test fails.
  2. It is unique per test: two tests do not tread on each other.
  3. It works on any operating system, with no hard-coded paths.
  4. It can be shared per class with @TempDir static Path.

That third test is interesting: it verifies the property that motivated writing AtomicWrite in 07-06 —that a failure halfway through must not corrupt the existing file—. That is testing behaviour, not implementation.

  1. What makes a design testable

This is the section that will change how you write code the most.

Testability is not added afterwards: it is a property of the design. And you have spent two modules making decisions that pointed here without it being spelled out.

  1. Dependency injection

// NOT TESTABLE: it creates its dependencies
public class LoanManager {
    private final LoanRepository repo = new JpaLoanRepository();  // needs a database
    private final NoticeService notices = new SmtpNoticeService(); // needs an SMTP server
}

// TESTABLE: it receives them
public class LoanManager {
    public LoanManager(LoanRepository repo, NoticeService notices) { ... }
}

With the second one, in the test you pass an in-memory repository and a notice service that only records calls. No database, no network, no Spring, in one millisecond.

  1. Interfaces at the boundaries

Every dependency on something external —database, network, file system, clock, email— must sit behind an interface. That way you can replace it in the test. Those interfaces are your domain's boundaries.

  1. Pure functions

A pure function (same inputs → same outputs, no side effects) is trivial to test:

// Pure: tested in one line
public static BigDecimal calculateFine(long daysLate, BigDecimal perDay, BigDecimal max) {
    if (daysLate <= 0) return BigDecimal.ZERO;
    return perDay.multiply(BigDecimal.valueOf(daysLate)).min(max);
}

Hence a very powerful architectural principle: separate calculation from effects. Put all the business logic into pure functions and leave the effects (saving, sending, logging) in a thin layer around them. The pure part is tested exhaustively; the impure part is tested with doubles (11-06).

  1. State is received, not looked up

// BAD: it looks its state up in a global place
public BigDecimal calculate(Loan l) {
    BigDecimal perDay = BusinessRules.finePerDay();    // static: cannot be replaced
    LocalDate today = LocalDate.now();                 // the system clock
    ...
}

// GOOD: it receives it
public BigDecimal calculate(Loan l) {
    BigDecimal perDay = properties.fine().eurosPerDay();     // injected
    LocalDate today = LocalDate.now(clock);                  // injected
    ...
}

  1. Small methods with one responsibility

A 200-line method with eight branches needs dozens of tests to be covered and none of them is readable. Five 15-line methods are tested five at a time.

Summary:

Design property Effect on the tests Where you applied it
Constructor injection Replacing collaborators 11-02
Interfaces at the boundaries Simulating DB, network, email 10-03, 11-03
Injected Clock Controlling time 10-05
Pure functions Testing with no setup 10-04 (streams)
No mutable global state Independent tests 11-02 (stateless singletons)
Domain free of framework annotations Testing without starting Spring 11-02
Small methods Readable tests Module 3

  1. The injectable Clock, finally cashed in

In 10-05 the Clock was introduced with this justification: "designed precisely so it could be tested". Two modules later, here is the payoff.

The problem without Clock:

public BigDecimal calculate(Loan loan) {
    LocalDate today = LocalDate.now();   // the SYSTEM clock
    ...
}

To test that a loan that fell due 5 days ago generates a €2.50 fine, you would have to:

  • Wait five real days (absurd), or
  • Create the loan with a past date and trust that the calculation is right (brittle: the test gives different results depending on the day it runs), or
  • Change the operating system's clock (unacceptable).

And there is a worse problem: the test would pass today and fail on 1 January, or when the clocks change, or on a server in another time zone. That is a non-deterministic test, the worst kind.

The solution with Clock:

public BigDecimal calculate(Loan loan) {
    LocalDate today = LocalDate.now(clock);   // the INJECTED clock
    ...
}
class FineCalculatorTest {

    // "Today" is ALWAYS 20 March 2026. On any machine, on any day.
    private static final Clock FIXED_CLOCK = Clock.fixed(
            Instant.parse("2026-03-20T10:00:00Z"), ZoneId.of("Europe/Madrid"));

    private FineCalculator calculator;

    @BeforeEach
    void setUp() {
        calculator = new FineCalculator(testProperties(), FIXED_CLOCK);
    }

    @Test
    void aLoanThatFellDueFiveDaysAgoGeneratesTwoEurosFifty() {
        Loan loan = loanThatWasDueOn(LocalDate.of(2026, 3, 15));
        assertThat(calculator.calculate(loan)).isEqualByComparingTo("2.50");
    }

    @Test
    void aLoanThatFallsDueTodayGeneratesNoFine() {
        Loan loan = loanThatWasDueOn(LocalDate.of(2026, 3, 20));
        assertThat(calculator.calculate(loan)).isEqualByComparingTo("0.00");
    }

    @Test
    void aLoanThatFallsDueTomorrowGeneratesNoFine() {
        Loan loan = loanThatWasDueOn(LocalDate.of(2026, 3, 21));
        assertThat(calculator.calculate(loan)).isEqualByComparingTo("0.00");
    }
}

And you can travel in time within a single test:

@Test
void theFineGrowsByFiftyCentsForEveryDayThatPasses() {

    LocalDate dueDate = LocalDate.of(2026, 3, 15);
    Loan loan = loanThatWasDueOn(dueDate);

    // Day 16: 1 day late
    var day16 = new FineCalculator(properties, clockAt(LocalDate.of(2026, 3, 16)));
    assertThat(day16.calculate(loan)).isEqualByComparingTo("0.50");

    // Day 20: 5 days
    var day20 = new FineCalculator(properties, clockAt(LocalDate.of(2026, 3, 20)));
    assertThat(day20.calculate(loan)).isEqualByComparingTo("2.50");

    // Day 100: capped at the maximum
    var day100 = new FineCalculator(properties, clockAt(LocalDate.of(2026, 6, 23)));
    assertThat(day100.calculate(loan)).isEqualByComparingTo("20.00");
}

private static Clock clockAt(LocalDate date) {
    return Clock.fixed(date.atStartOfDay(ZoneId.of("Europe/Madrid")).toInstant(),
                       ZoneId.of("Europe/Madrid"));
}

Three different days, three verified results, in five milliseconds. That is what that 10-05 decision was worth.

Other useful clocks from java.time:

Factory Behaviour
Clock.fixed(instant, zone) Frozen at one instant
Clock.systemDefaultZone() The system's (production)
Clock.offset(base, duration) Shifted: "three days from now"
Clock.tick(base, duration) Advances in jumps (useful to reduce precision)

And the general lesson, which goes far beyond the clock:

Everything you do not control —time, randomness, the network, the file system, the environment— must come in through an injected dependency. Otherwise your code is not deterministic and cannot be tested.

The same applies to Random (inject one with a fixed seed), to UUID.randomUUID() (inject a generator) and to environment variables (inject the configuration).

  1. The code that CANNOT be tested

Recognising these patterns will save you hours.

static that reads global state

// CANNOT BE TESTED
public class FineCalculator {
    public static BigDecimal calculate(Loan l) {
        BigDecimal perDay = BusinessRules.finePerDay();    // static: not replaceable
        LocalDate today = LocalDate.now();                 // the system clock
        ...
    }
}

There is no insertion point: you cannot replace BusinessRules nor control the clock. It is exactly the BusinessRules of 07-07, and that is why 11-02 turned it into an injectable @ConfigurationProperties.

new inside the method

// CANNOT BE TESTED WITHOUT A NETWORK
public class CatalogEnricher {
    public Material enrich(Material material) {
        MetadataClient client = new MetadataClient();        // calls a real API
        return client.enrich(material);
    }
}

Every run of the test would make a real HTTP request: slow, non-deterministic and dependent on the API being available. The solution is to inject MetadataClient behind an interface, and in 11-06 you will simulate it.

Hidden side effects

public void process(Loan l) {
    calculate(l);
    sendEmail(l);                       // sends a REAL email
    System.out.println("Processed");     // cannot be checked
    logFile.append(...);                 // writes to disk
}

Every test would send a real email. Separate calculation from effects.

Private methods with complex logic

If you feel the urge to test a private method, that is a sign that the logic wants to be a class. Extract it into a class of its own with its interface and test it directly. Using reflection to test private methods is an antipattern: it couples the test to the implementation.

Pattern Why it cannot be tested Solution
static with global state Nowhere to insert the double An instance with injected dependencies
new inside the method Cannot be replaced Inject through the constructor
LocalDate.now(), new Random() Non-deterministic Inject Clock, Random
System.out.println Cannot be checked An injected logger or a return value
Effects mixed with calculation Every test causes real effects Separate calculation from effects
Complex private methods Only reachable by reflection Extract into another class
Constructor doing heavy work Merely constructing causes effects Move it to an initialisation method

  1. What a good test suite looks like

The properties to aim for, with their usual acronym (FIRST):

Property What it means How you get it
Fast Milliseconds per unit test No DB, no network, no sleep
Independent Every test can run on its own No shared state; @BeforeEach
Repeatable The same result always, on any machine Fixed Clock, no environment dependencies
Self-validating Green or red, with no human interpretation Assertions, not System.out.println
Timely Written along with the code, not a year later Team discipline

And two more criteria that get used a lot:

  • Order-independent: if running the tests in random order makes one fail, there is shared state. JUnit lets you force it with @TestMethodOrder(MethodOrderer.Random.class) to detect it.
  • No logic: zero improvised if, for and try/catch. A test must be so obvious that it needs no tests.

And the most important property of all: a test that never fails is worth nothing. Check it by breaking the code on purpose once —change a <= into a <— and verify that the test goes red. If it does not, it is not testing what you think.

  1. Antipatterns

Brittle tests. They break when you refactor even though the behaviour does not change. They usually test implementation instead of behaviour:

// BRITTLE: it checks HOW it is done
verify(repository).findById(1L);
verify(repository).save(any());
verify(calculator).calculate(any());
// If tomorrow findByIsbn is used, the test fails even though the result is identical.

// ROBUST: it checks WHAT result is obtained
assertThat(manager.lend("978-0000000001", "[email protected]"))
        .extracting(Loan::getDueDate)
        .isEqualTo(LocalDate.of(2026, 3, 16));

This subject is explored further in 11-06, because too much verify is Mockito's cardinal sin.

Tests that test the framework. Checking that @Column(nullable = false) produces a NOT NULL column, or that Spring injects a bean, is testing third-party code that is already tested. Test your logic.

Thread.sleep in tests.

// BAD
service.processAsynchronously();
Thread.sleep(1000);              // what if it takes 1100 ms in continuous integration?
assertThat(result).isNotNull();

It is slow (multiply it by fifty tests) and intermittent: it passes on your laptop and fails on the integration server. The alternatives: CountDownLatch (08-05), CompletableFuture.get() with a timeout (08-07), or the Awaitility library.

Flaky tests. They fail sometimes for no apparent reason. Typical causes: dependence on the clock, on execution order, on concurrency or on the network. They are worse than having no tests, because the team gets used to ignoring the failures. A flaky test is fixed or deleted the same day.

Giant tests. A hundred lines of setup and thirty assertions. When it fails, nobody knows what broke. Split it.

Empty or useless assertions.

@Test
void loanTest() {
    Loan l = manager.lend("978-0000000001", "[email protected]");
    assertNotNull(l);       // so what? It checks no rule at all
}

Copying the implementation into the test.

// Useless: if the formula is wrong, the test is wrong in the same way
BigDecimal expected = perDay.multiply(BigDecimal.valueOf(days)).min(max);
assertThat(calculator.calculate(loan)).isEqualByComparingTo(expected);

Expected values are written by hand, worked out by a person: "2.50". That is the whole point: the test is an independent second opinion.

  1. Testing concurrent code

Module 8 left behind ConcurrentCatalog, ExecutorService, CompletableFuture and concurrent collections. Testing them has a fundamental difficulty:

A concurrency test that passes does not prove the code is correct. It only proves that the failure did not show up that time.

Even so, there are useful techniques:

@Test
void theConcurrentCatalogHandlesAHundredSimultaneousThreads() throws Exception {

    ConcurrentCatalog catalogue = new ConcurrentCatalog();
    int threads = 100, operations = 1000;

    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {   // 10-06
        CountDownLatch ready = new CountDownLatch(threads);
        CountDownLatch start = new CountDownLatch(1);

        for (int i = 0; i < threads; i++) {
            executor.submit(() -> {
                ready.countDown();
                start.await();                     // they all begin AT ONCE
                for (int j = 0; j < operations; j++) catalogue.incrementQueries();
                return null;
            });
        }
        ready.await(5, TimeUnit.SECONDS);
        start.countDown();                         // simultaneous starting gun
    }   // closing the executor waits for them to finish

    assertThat(catalogue.getQueries()).isEqualTo((long) threads * operations);
}

The trick with the two CountDownLatch objects —one to wait until everybody is ready, another to release them all at once— maximises the probability that the race condition shows up. It is what you did in 08-05.

Recommendations:

  1. Prefer designs with no shared state. Concurrent code that shares no state needs no concurrency tests.
  2. Test the logic separately, without concurrency, and add a stress test as a complement.
  3. Tag them as slow (@Tag("slow")) and keep them out of the fast cycle.
  4. Specialised tools (jcstress) exist for serious concurrency verification; they are outside this course's scope.

  1. Running the tests with Maven

Commands you will use daily (11-05 explains them in depth):

mvn test                                # compiles and runs the unit tests
mvn test -Dtest=FineCalculatorTest      # only one class
mvn test -Dtest=FineCalculatorTest#the* # only methods starting with "the"
mvn test -Dgroups="fast"                # only the tagged ones
mvn verify                              # unit + integration (failsafe)
mvn package -DskipTests                 # packages WITHOUT running tests

A warning about that last one: -DskipTests is useful for packaging quickly on your own machine. Never in continuous integration. If a project needs to skip tests in order to build, there is a bigger problem.

When something fails, the output is:

[ERROR] Failures:
[ERROR]   FineCalculatorTest.theFineIsCappedAtTheMaximum:47
           expected: 20.00 but was: 100.00
[INFO] Tests run: 24, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE

With the full report in target/surefire-reports/. And the important part: the build fails. That is what stops broken code from reaching production.

  1. Coverage, mentioned

Code coverage measures what percentage of your code the tests execute. In Java it is measured with JaCoCo.

Two warnings, and they are serious:

  1. Coverage is not quality. An executed line is not a tested line. This code has 100 % coverage and checks nothing:
@Test
void coverage100() {
    calculator.calculate(loan);   // it runs... and nothing is checked
}
  1. A high coverage target produces bad tests. If the team must reach 90 %, tests for getters and setters will appear that push the number up without contributing anything.

What is useful: looking at what is not covered. A business method with 0 % coverage is valuable information.

It is developed in 12-05, with JaCoCo's configuration and an honest reading of it.

  1. @SpringBootTest and @DataJpaTest, introduced

So far everything has been unit tests without Spring, which is how it should be: fast and numerous. But you also have to verify that the pieces fit together.

@SpringBootTest starts the complete context:

@SpringBootTest
class BiblioTechApplicationTests {

    @Autowired
    private LoanManager manager;

    @Test
    void theContextLoadsCorrectly() {
        assertThat(manager).isNotNull();
    }
}

That test, apparently trivial, has real value: it detects a missing bean, a circular dependency or a mandatory property left undefined. It is the test that stops you from finding out at deployment time that the application does not start.

@DataJpaTest starts only the persistence layer, with an embedded database and a transaction that is rolled back automatically:

@DataJpaTest
class LoanRepositoryTest {

    @Autowired private LoanRepository repository;
    @Autowired private TestEntityManager em;

    @Test
    void findsTheOverdueLoansThatAreStillOut() {
        Material book = em.persist(new Book("978-0000000001", "Effective Java", 3, "J. Bloch"));
        Employee marta = em.persist(new Employee("[email protected]", "Marta Ruiz"));
        em.persist(new Loan(book, marta,
                LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 10)));
        em.flush();

        List<Loan> overdue = repository.overdue(LocalDate.of(2026, 3, 20));

        assertThat(overdue).hasSize(1)
                .first().extracting(l -> l.getMaterial().getTitle())
                .isEqualTo("Effective Java");
    }
}

Every test runs inside a transaction that is rolled back at the end, so they do not contaminate each other.

Annotation What it starts Speed
(none) Nothing. Plain Java 1-10 ms
@DataJpaTest JPA, repositories, embedded DB ~1 s
@WebMvcTest Only the web layer (12-04) ~1 s
@SpringBootTest The whole context 2-10 s

The rule: use the smallest annotation that does the job. Every @SpringBootTest test that could have been a unit test is time you pay on every build, forever.

They are developed in 11-06 (with Mockito) and in 12-05.

  1. BiblioTech's test suite

The complete result. Structure:

src/test/java/com/nexussoftware/bibliotech/
├── domain/
│   ├── LoanTest.java                  <- due-date and return rules
│   ├── MaterialTest.java              <- available copies
│   └── IsbnTest.java                  <- validation
├── service/
│   ├── FineCalculatorTest.java        <- fines, with a fixed Clock
│   └── LoanManagerTest.java           <- loan limit
├── persistence/
│   └── AtomicWriteTest.java           <- @TempDir
└── BiblioTechApplicationTests.java    <- @SpringBootTest: context loading

The complete FineCalculatorTest:

package com.nexussoftware.bibliotech.service;

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

import java.math.BigDecimal;
import java.time.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

@DisplayName("BiblioTech fine calculation")
class FineCalculatorTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
    private static final LocalDate TODAY = LocalDate.of(2026, 3, 20);
    private static final Clock FIXED_CLOCK =
            Clock.fixed(TODAY.atStartOfDay(MADRID).toInstant(), MADRID);

    private FineCalculator calculator;
    private Employee marta;
    private Material book;

    @BeforeEach
    void setUp() {
        var properties = new BiblioTechProperties(
                "BiblioTech - Nexus Software",
                new BiblioTechProperties.Loan(15, 3),
                new BiblioTechProperties.Fine(new BigDecimal("0.50"), new BigDecimal("20.00")),
                new BiblioTechProperties.Reservation(3));

        calculator = new FineCalculator(properties, FIXED_CLOCK);
        marta = new Employee("[email protected]", "Marta Ruiz");
        book = new Book("978-0000000001", "Effective Java", 3, "J. Bloch");
    }

    @Nested
    @DisplayName("when the loan is still within term")
    class WithinTerm {

        @Test
        @DisplayName("there is no fine if it falls due within a week")
        void noFineIfDueInAWeek() {
            assertThat(calculator.calculate(loanDueOn(TODAY.plusDays(7))))
                    .isEqualByComparingTo("0.00");
        }

        @Test
        @DisplayName("there is no fine on the due date itself")
        void noFineOnTheDueDate() {
            assertThat(calculator.calculate(loanDueOn(TODAY)))
                    .isEqualByComparingTo("0.00");
        }
    }

    @Nested
    @DisplayName("when the loan is overdue")
    class Overdue {

        @ParameterizedTest(name = "{0} days late -> €{1}")
        @CsvSource({
                "  1,  0.50", "  2,  1.00", "  5,  2.50", " 10,  5.00",
                " 39, 19.50", " 40, 20.00", " 41, 20.00", "200, 20.00"
        })
        @DisplayName("the fine is €0.50 per day up to the €20 maximum")
        void fineByDaysLate(int days, BigDecimal expected) {
            assertThat(calculator.calculate(loanDueOn(TODAY.minusDays(days))))
                    .isEqualByComparingTo(expected);
        }

        @Test
        @DisplayName("the fine never exceeds the configured maximum")
        void neverExceedsTheMaximum() {
            assertThat(calculator.calculate(loanDueOn(TODAY.minusYears(2))))
                    .isEqualByComparingTo("20.00");
        }
    }

    @Nested
    @DisplayName("when the loan has already been returned")
    class Returned {

        @Test
        @DisplayName("there is no fine even if it was returned late")
        void noFineAfterTheReturn() {
            Loan loan = loanDueOn(TODAY.minusDays(30));
            loan.returnItem(TODAY.minusDays(10));

            assertThat(calculator.calculate(loan)).isEqualByComparingTo("0.00");
        }
    }

    // --- scenario factories ---
    private Loan loanDueOn(LocalDate dueDate) {
        return new Loan(book, marta, dueDate.minusDays(15), dueDate);
    }
}

Running it:

mvn test
[INFO] Running com.nexussoftware.bibliotech.service.FineCalculatorTest
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.089 s
[INFO] Running com.nexussoftware.bibliotech.domain.LoanTest
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.021 s
...
[INFO] Tests run: 41, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
[INFO] Total time: 6.412 s

Forty-one tests in under a second of effective execution. From now on, every refactoring of BiblioTech has a safety net underneath it.

  1. Common Mistakes and Tips

Mistake: writing tests that never fail. Break the code on purpose and check that the test goes red. If it does not, it tests nothing.

Mistake: testing the implementation instead of the behaviour. If the test breaks when you refactor without changing the result, it is badly written.

Mistake: Thread.sleep in tests. Slow and intermittent. CountDownLatch, Future.get(timeout) or Awaitility.

Mistake: order dependence. If the tests fail when run in a different order, there is shared state. @BeforeEach, not mutable static fields.

Mistake: LocalDate.now() in production code. It makes the test non-deterministic. An injected Clock.

Mistake: assertEquals with BigDecimal. new BigDecimal("2.50").equals(new BigDecimal("2.5")) is false. Use AssertJ's isEqualByComparingTo or compareTo.

Mistake: swapping the order in assertEquals. The expected value goes first; the other way round the messages mislead.

Mistake: try/catch instead of assertThrows. If the exception is not thrown, the test passes anyway.

Mistake: @Disabled with no reason. And a test disabled for more than a month must be deleted: it gives a false sense of coverage.

Mistake: @SpringBootTest for everything. It multiplies the suite's time by a thousand while contributing nothing when the class can be built with new.

Mistake: chasing a coverage percentage. It generates getter tests that verify nothing.

Tip: use AssertJ. You already have it, and its error messages save real time.

Tip: long, descriptive names. theFineIsCappedAtTheConfiguredMaximum is better than testFine. Nobody calls these methods: their only purpose is to communicate.

Tip: one test, one behaviour. If the name needs an "and", they are two tests.

Tip: parameterised tests for edge cases. The cost of adding cases 39, 40 and 41 is one line, and that is exactly where the bugs live.

Tip: write the failing test first. Even if you do not practise strict TDD, starting from a red test guarantees that the test tests something.

Tip: when a bug appears, write the test that reproduces it first. Fix it afterwards. That way that particular bug never comes back.

Tip: if something is hard to test, the problem is in the design. Do not fight the test: fix the design.

  1. Exercises

Exercise 1: testing Loan's rules

This is BiblioTech's Loan entity after 11-03:

@Entity
public class Loan {

    @Id @GeneratedValue private Long id;
    @ManyToOne(fetch = FetchType.LAZY) private Material material;
    @ManyToOne(fetch = FetchType.LAZY) private Employee employee;
    private LocalDate loanDate;
    private LocalDate dueDate;
    private LocalDate returnDate;
    @Enumerated(EnumType.STRING) private LoanStatus status;

    public Loan(Material material, Employee employee, LocalDate loanDate, int days) {
        this.material = material;
        this.employee = employee;
        this.loanDate = loanDate;
        this.dueDate = loanDate.plusDays(days);
        this.status = LoanStatus.ACTIVE;
    }

    public boolean isOverdue(LocalDate today) {
        return returnDate == null && today.isAfter(dueDate);
    }

    public void returnItem(LocalDate date) {
        if (returnDate != null) {
            throw new LoanAlreadyReturnedException(id);
        }
        if (date.isBefore(loanDate)) {
            throw new InvalidDateException("Cannot return before lending");
        }
        this.returnDate = date;
        this.status = LoanStatus.RETURNED;
    }

    public Optional<LocalDate> getReturnDate() {
        return Optional.ofNullable(returnDate);
    }
}

Write a complete test class covering:

  1. That a new loan has the correct data (use assertAll).
  2. isOverdue with at least five cases, including the edges (the due date itself and the following day), by means of a parameterised test.
  3. That returning works and changes the status.
  4. That returning twice throws LoanAlreadyReturnedException.
  5. That returning before lending throws InvalidDateException.
  6. That a returned loan is never overdue, even if it was returned late.
  7. Organise it with @Nested and @DisplayName.

Exercise 2: making the untestable testable

This BiblioTech class cannot be tested. Identify every problem, rewrite it and write three tests that were previously impossible.

public class DueDateNoticeService {

    public int sendTodaysNotices() {
        int sent = 0;
        LocalDate today = LocalDate.now();

        LoanRepository repository = new JpaLoanRepository();
        List<Loan> loans = repository.findAll();

        for (Loan l : loans) {
            long days = ChronoUnit.DAYS.between(today, l.getDueDate());
            if (days == Integer.parseInt(BusinessRules.get("notice.days.ahead", "2"))) {
                EmailClient email = new EmailClient("smtp.nexussoftware.com", 587);
                email.send(l.getEmployee().getEmail(),
                           "Your loan of " + l.getMaterial().getTitle() + " is due soon");
                System.out.println("Notice sent to " + l.getEmployee().getEmail());
                sent++;
            }
        }
        return sent;
    }
}

You are asked for:

  1. A list of every obstacle to testing it.
  2. A rewritten, testable version, without changing the behaviour.
  3. Three tests: a notice is sent when exactly the configured number of days remain; no notice is sent when more or fewer remain; the notices sent are counted correctly.

(Note: here you will use in-memory fake implementations of the interfaces. Mockito mocks are 11-06.)

Exercise 3: finding the bug with a test

A colleague at Nexus Software has implemented loan renewals. Marta Ruiz reports that "sometimes renewing does not add any days". The code:

public class RenewalManager {

    private static final int MAX_RENEWALS = 2;
    private final Clock clock;

    public RenewalManager(Clock clock) { this.clock = clock; }

    public LocalDate renew(Loan loan, int days) {
        if (loan.getRenewals() >= MAX_RENEWALS) {
            throw new RenewalLimitExceededException(loan.getId());
        }
        if (loan.getReturnDate().isPresent()) {
            throw new LoanAlreadyReturnedException(loan.getId());
        }

        LocalDate today = LocalDate.now(clock);
        LocalDate newDueDate = loan.getDueDate().plusDays(days);

        if (newDueDate.isBefore(today)) {
            newDueDate = today;
        }

        loan.updateDueDate(newDueDate);
        loan.incrementRenewals();
        return newDueDate;
    }
}

You are asked for:

  1. Write tests covering the normal case, the renewal limit and the already-returned loan.
  2. Find the bug by writing the test that reveals it. Hint: think about a loan that has been overdue for a long time.
  3. Explain the incorrect behaviour from the business point of view.
  4. Fix the code and prove that the test passes.

Solutions

Solution 1

package com.nexussoftware.bibliotech.domain;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertAll;

import java.time.LocalDate;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

@DisplayName("Loan")
class LoanTest {

    private static final LocalDate LOAN_DATE = LocalDate.of(2026, 3, 1);
    private static final int DAYS = 15;
    private static final LocalDate DUE_DATE = LocalDate.of(2026, 3, 16);

    private Material book;
    private Employee marta;
    private Loan loan;

    @BeforeEach
    void setUp() {
        book = new Book("978-0000000001", "Effective Java", 3, "J. Bloch");
        marta = new Employee("[email protected]", "Marta Ruiz");
        loan = new Loan(book, marta, LOAN_DATE, DAYS);
    }

    @Nested
    @DisplayName("newly created")
    class NewlyCreated {

        @Test
        @DisplayName("(1) has all the correct data")
        void correctData() {
            assertAll("loan data",
                    () -> assertThat(loan.getMaterial()).isEqualTo(book),
                    () -> assertThat(loan.getEmployee()).isEqualTo(marta),
                    () -> assertThat(loan.getLoanDate()).isEqualTo(LOAN_DATE),
                    () -> assertThat(loan.getDueDate()).isEqualTo(DUE_DATE),
                    () -> assertThat(loan.getStatus()).isEqualTo(LoanStatus.ACTIVE),
                    () -> assertThat(loan.getReturnDate()).isEmpty());
        }
    }

    @Nested
    @DisplayName("overdue check")
    class DueDate {

        // (2) five cases, including the edges
        @ParameterizedTest(name = "on {0} -> overdue = {1}")
        @CsvSource({
                "2026-03-01, false",   // the day of the loan
                "2026-03-15, false",   // the eve of the due date
                "2026-03-16, false",   // EDGE: the day itself is NOT overdue
                "2026-03-17, true",    // EDGE: the following day IS
                "2026-04-30, true",    // much later
                "2027-01-01, true"
        })
        void isOverdueDependingOnTheDate(LocalDate today, boolean expected) {
            assertThat(loan.isOverdue(today)).isEqualTo(expected);
        }
    }

    @Nested
    @DisplayName("on return")
    class Returning {

        @Test
        @DisplayName("(3) records the date and changes the status")
        void correctReturn() {
            LocalDate returnDate = LocalDate.of(2026, 3, 10);

            loan.returnItem(returnDate);

            assertAll(
                    () -> assertThat(loan.getReturnDate()).contains(returnDate),
                    () -> assertThat(loan.getStatus()).isEqualTo(LoanStatus.RETURNED));
        }

        @Test
        @DisplayName("(4) returning twice throws LoanAlreadyReturnedException")
        void cannotReturnTwice() {
            loan.returnItem(LocalDate.of(2026, 3, 10));

            assertThatThrownBy(() -> loan.returnItem(LocalDate.of(2026, 3, 11)))
                    .isInstanceOf(LoanAlreadyReturnedException.class);
        }

        @Test
        @DisplayName("(5) returning before lending throws InvalidDateException")
        void cannotReturnBeforeLending() {
            assertThatThrownBy(() -> loan.returnItem(LOAN_DATE.minusDays(1)))
                    .isInstanceOf(InvalidDateException.class)
                    .hasMessageContaining("before lending");
        }

        @Test
        @DisplayName("(6) a returned loan is never overdue, even if it was returned late")
        void returnedIsNeverOverdue() {
            loan.returnItem(LocalDate.of(2026, 4, 30));   // 45 days late

            assertThat(loan.isOverdue(LocalDate.of(2027, 1, 1))).isFalse();
        }
    }
}

Notes on the decisions:

  • The edge cases in point 2 are the ones that matter: the 16th (it falls due, but it is not overdue) and the 17th (it is). That is where the > versus >= bug lives, and a parameterised test covers each of them in one line.
  • Point 6 tests a real business rule, not a line of code: a closed loan cannot be overdue, even if its return date is later than the due date.
  • AssertJ's assertThat(optional).contains(x) is cleaner than isPresent() followed by get().
  • The @BeforeEach leaves the scenario in a known state; every test starts from scratch.

Solution 2

1. Obstacles to testing the original version.

Line Problem Consequence
LocalDate.now() The system clock The test gives different results depending on the day
new JpaLoanRepository() Instantiates a real dependency Needs a database
BusinessRules.get(...) Static and global Cannot be configured in the test
Integer.parseInt inside the loop Repeated conversion Minor, but it is repeated logic
new EmailClient(...) inside the loop Creates an SMTP client per notice It would send real emails; also inefficient
System.out.println An effect that cannot be checked Impossible to verify
No email abstraction Coupled to SMTP Nowhere to put a double

2. Testable version.

package com.nexussoftware.bibliotech.service;

import java.time.Clock;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.slf4j.*;
import org.springframework.stereotype.Service;

@Service
public class DueDateNoticeService {

    private static final Logger log = LoggerFactory.getLogger(DueDateNoticeService.class);

    private final LoanRepository repository;           // interface
    private final NoticeService notices;               // interface
    private final BiblioTechProperties properties;     // injected configuration
    private final Clock clock;                         // controllable time

    public DueDateNoticeService(LoanRepository repository,
                                NoticeService notices,
                                BiblioTechProperties properties,
                                Clock clock) {
        this.repository = repository;
        this.notices = notices;
        this.properties = properties;
        this.clock = clock;
    }

    public int sendTodaysNotices() {
        LocalDate today = LocalDate.now(clock);
        int daysAhead = properties.notice().daysAhead();

        List<Loan> toNotify = repository.findActive().stream()
                .filter(l -> ChronoUnit.DAYS.between(today, l.getDueDate()) == daysAhead)
                .toList();

        toNotify.forEach(l -> {
            notices.notify(l.getEmployee(),
                    "Your loan of " + l.getMaterial().getTitle() + " is due soon");
            log.info("Notice sent to {}", l.getEmployee().getEmail());
        });

        return toNotify.size();
    }
}

The changes and why:

Before Now Why
LocalDate.now() LocalDate.now(clock) Deterministic
new JpaLoanRepository() An injected interface Replaceable by an in-memory fake
BusinessRules.get(...) properties.notice().daysAhead() Configurable and validated
new EmailClient(...) in the loop An injected NoticeService Replaceable, and a single instance
System.out.println A logger (11-07) Configurable per environment
findAll() findActive() Less data: the filtering happens in the query

3. The three tests.

package com.nexussoftware.bibliotech.service;

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

import java.time.*;
import java.util.*;
import org.junit.jupiter.api.*;

@DisplayName("Due-date notices")
class DueDateNoticeServiceTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
    private static final LocalDate TODAY = LocalDate.of(2026, 3, 20);
    private static final Clock CLOCK = Clock.fixed(TODAY.atStartOfDay(MADRID).toInstant(), MADRID);

    // FAKE test double: a real implementation, but in memory
    static class InMemoryLoanRepository implements LoanRepository {
        private final List<Loan> loans = new ArrayList<>();
        void add(Loan l) { loans.add(l); }
        @Override public List<Loan> findActive() { return List.copyOf(loans); }
    }

    // Home-made SPY double: it records what it is asked to do
    static class RecordingNotices implements NoticeService {
        final List<String> recipients = new ArrayList<>();
        final List<String> messages = new ArrayList<>();
        @Override public void notify(Employee recipient, String message) {
            recipients.add(recipient.getEmail());
            messages.add(message);
        }
    }

    private InMemoryLoanRepository repository;
    private RecordingNotices notices;
    private DueDateNoticeService service;

    @BeforeEach
    void setUp() {
        repository = new InMemoryLoanRepository();
        notices = new RecordingNotices();

        var properties = propertiesWithDaysAhead(2);   // notify 2 days in advance
        service = new DueDateNoticeService(repository, notices, properties, CLOCK);
    }

    @Test
    @DisplayName("notifies when exactly the configured number of days remain")
    void notifiesWithTheConfiguredLeadTime() {
        repository.add(loanDueOn(TODAY.plusDays(2), "[email protected]"));

        int sent = service.sendTodaysNotices();

        assertThat(sent).isEqualTo(1);
        assertThat(notices.recipients).containsExactly("[email protected]");
        assertThat(notices.messages).first().asString().contains("Effective Java");
    }

    @Test
    @DisplayName("does not notify if more or fewer days remain than configured")
    void doesNotNotifyOutsideTheWindow() {
        repository.add(loanDueOn(TODAY.plusDays(1), "[email protected]"));   // 1 day
        repository.add(loanDueOn(TODAY.plusDays(3), "[email protected]"));   // 3 days
        repository.add(loanDueOn(TODAY,             "[email protected]"));   // today
        repository.add(loanDueOn(TODAY.minusDays(5),"[email protected]"));   // overdue

        int sent = service.sendTodaysNotices();

        assertThat(sent).isZero();
        assertThat(notices.recipients).isEmpty();
    }

    @Test
    @DisplayName("counts the notices sent correctly")
    void countsTheNotices() {
        repository.add(loanDueOn(TODAY.plusDays(2), "[email protected]"));
        repository.add(loanDueOn(TODAY.plusDays(2), "[email protected]"));
        repository.add(loanDueOn(TODAY.plusDays(2), "[email protected]"));
        repository.add(loanDueOn(TODAY.plusDays(9), "[email protected]"));

        assertThat(service.sendTodaysNotices()).isEqualTo(3);
        assertThat(notices.recipients).containsExactlyInAnyOrder(
                "[email protected]",
                "[email protected]",
                "[email protected]");
    }

    private Loan loanDueOn(LocalDate dueDate, String email) {
        return new Loan(
                new Book("978-0000000001", "Effective Java", 3, "J. Bloch"),
                new Employee(email, "Test employee"),
                dueDate.minusDays(15), dueDate);
    }
}

Look at the second test: it verifies the four edge cases around the notice window (1 day, 3 days, today, overdue). That == daysAhead is exactly where a misplaced >= would notify everybody every day, and this test would catch it.

And look at the two home-made doubles: a fake (an in-memory repository with real behaviour) and a spy (it records what it receives). In 11-06 you will see their precise names and how Mockito generates them for you.

Solution 3

1 and 2. The tests, including the one that reveals the bug.

package com.nexussoftware.bibliotech.service;

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

import java.time.*;
import org.junit.jupiter.api.*;

@DisplayName("Loan renewal")
class RenewalManagerTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
    private static final LocalDate TODAY = LocalDate.of(2026, 3, 20);
    private static final Clock CLOCK = Clock.fixed(TODAY.atStartOfDay(MADRID).toInstant(), MADRID);

    private RenewalManager manager;

    @BeforeEach
    void setUp() { manager = new RenewalManager(CLOCK); }

    @Test
    @DisplayName("renewing adds the days to the due date")
    void renewingAddsDays() {
        Loan loan = loanDueOn(TODAY.plusDays(3));                // due on the 23rd

        LocalDate newDueDate = manager.renew(loan, 15);

        assertThat(newDueDate).isEqualTo(LocalDate.of(2026, 4, 7));   // 23 + 15
        assertThat(loan.getRenewals()).isEqualTo(1);
    }

    @Test
    @DisplayName("a loan cannot be renewed more than twice")
    void renewalLimit() {
        Loan loan = loanDueOn(TODAY.plusDays(3));
        manager.renew(loan, 15);
        manager.renew(loan, 15);

        assertThatThrownBy(() -> manager.renew(loan, 15))
                .isInstanceOf(RenewalLimitExceededException.class);
    }

    @Test
    @DisplayName("a loan that has already been returned cannot be renewed")
    void aReturnedLoanIsNotRenewed() {
        Loan loan = loanDueOn(TODAY.plusDays(3));
        loan.returnItem(TODAY.minusDays(1));

        assertThatThrownBy(() -> manager.renew(loan, 15))
                .isInstanceOf(LoanAlreadyReturnedException.class);
    }

    // ===== THE TEST THAT REVEALS THE BUG =====
    @Test
    @DisplayName("renewing a long-overdue loan must add the days from TODAY, not from the past")
    void renewingALongOverdueLoan() {
        // Marta had a loan that fell due 60 days ago (on 19 January)
        Loan loan = loanDueOn(TODAY.minusDays(60));

        LocalDate newDueDate = manager.renew(loan, 15);

        // Expected by the business: today (20 March) + 15 days = 4 April
        assertThat(newDueDate).isEqualTo(LocalDate.of(2026, 4, 4));
    }
}

That last test fails:

Expected: 2026-04-04
Actual:   2026-03-20

3. The incorrect behaviour.

Tracing the code with a loan that fell due on 19 January 2026 and a 15-day renewal:

LocalDate today = LocalDate.of(2026, 3, 20);
LocalDate newDueDate = LocalDate.of(2026, 1, 19).plusDays(15);   // = 2026-02-03

if (newDueDate.isBefore(today)) {  // 3 February is before 20 March -> true
    newDueDate = today;            // it stays at TODAY!
}

The loan is renewed with today as its due date: Marta clicks "Renew 15 days" and her loan falls due that same afternoon. And worse: she has spent one of her two renewals in exchange for nothing. The next day it is overdue again, with a fine, and she has only one renewal left.

That is the "sometimes renewing does not add any days" from the report. It is "sometimes" because it only happens when the accumulated delay exceeds the renewal days —exactly the case of whoever needs to renew the most—.

The root of the error is that the safety clause if (newDueDate.isBefore(today)) had a good intention —stopping a renewal from leaving the due date in the past— but chose the wrong correction: instead of recalculating from today, it clamped to today.

4. The fix.

public LocalDate renew(Loan loan, int days) {
    if (loan.getRenewals() >= MAX_RENEWALS) {
        throw new RenewalLimitExceededException(loan.getId());
    }
    if (loan.getReturnDate().isPresent()) {
        throw new LoanAlreadyReturnedException(loan.getId());
    }

    LocalDate today = LocalDate.now(clock);

    // A renewal always counts from the LATER of today and the current due
    // date. That guarantees that `days` full days of usable loan time are ALWAYS
    // added, whether the loan is within term or overdue.
    LocalDate base = loan.getDueDate().isAfter(today)
            ? loan.getDueDate()
            : today;

    LocalDate newDueDate = base.plusDays(days);

    loan.updateDueDate(newDueDate);
    loan.incrementRenewals();
    return newDueDate;
}

Checking it against the four tests:

Scenario Current due date Base Result
Within term (due on the 23rd) 2026-03-23 2026-03-23 2026-04-07 ✔
Due today 2026-03-20 2026-03-20 2026-04-04 ✔
Overdue by 60 days 2026-01-19 2026-03-20 (today) 2026-04-04 ✔

All four tests pass, and the one that revealed the bug stays in the suite forever: if somebody reintroduces the old logic during a refactoring, it will go red instantly.

That is the professional practice behind the tip in section 32: when a bug appears, first the test that reproduces it, then the fix. That way that particular bug never comes back.

Conclusion

BiblioTech finally has a safety net.

You understand the cost of having no tests, which you had been paying for eleven lessons without knowing it: fear of change, regressions discovered in production, slow debugging, repeated manual checking and —the one that connects with 11-01's Log4Shell— the inability to update a dependency quickly when a vulnerability appears. And you know what you get in return: refactoring without fear, executable documentation that cannot go stale because if it lies it goes red, and better design, because tests are a detector of structural problems.

You know the test pyramid and why its shape matters: many unit tests that take milliseconds, some integration tests, very few end-to-end ones. An inverted suite takes twenty minutes and nobody runs it.

You have mastered JUnit 5: its Platform, Jupiter and Vintage architecture; the dependencies spring-boot-starter-test brings; the Arrange-Act-Assert anatomy with its rules —one action per test, no logic, one behaviour per test—; long, descriptive names with @DisplayName; JUnit's assertions, with assertThrows returning the exception so it can be inspected and assertAll reporting every failure at once; the life cycle with @BeforeEach preferred over @BeforeAll; the new instance per test that guarantees independence; @Disabled always with a reason, and the conditional annotations; @Nested to organise by scenario with its own setup; @Tag for selective execution; and @TempDir, which finally made it possible to test module 7's file code —including the property that motivated writing AtomicWrite: that a failure halfway through must not corrupt the existing file—.

And you have mastered parameterised tests, which turned twelve fine-calculation cases —with the three edges that really matter, 39, 40 and 41 around the maximum— into six lines. With @CsvSource, @EnumSource, @MethodSource for complex objects and the canonical pattern of @NullAndEmptySource plus @ValueSource for testing input validation.

You know why AssertJ is recommended: not for aesthetics, but because hasSize(3) tells you what was there when it fails, because isEqualByComparingTo solves the real problem of new BigDecimal("2.50").equals(new BigDecimal("2.5")) being false, and because its chaining over collections and Optional expresses checks that with JUnit would be loops.

And above all you understand what makes a design testable, which is the part that changes how you write production code: constructor injection, interfaces at the boundaries, pure functions separated from effects, state that is received rather than looked up, and small methods. With the counterpoint: you recognise the code that cannot be tested —static that reads global state, new inside the method, LocalDate.now(), hidden effects, private methods with complex logic— and you know that when something is hard to test, the problem is in the design, not in the test.

And the injectable Clock from 10-05 has finally been cashed in. With Clock.fixed, "today" is always 20 March 2026, on any machine and on any day of the year; you can travel in time within a single test and verify three different dates in five milliseconds. With the general lesson that goes far beyond the clock: everything you do not control —time, randomness, network, files, environment— must come in through an injected dependency.

You know the properties of a good suite —fast, independent, repeatable, self-validating, free of logic, order-independent— and its antipatterns: brittle tests that test implementation, tests that test the framework, Thread.sleep, flaky tests that are worse than having no tests, and copying the implementation's formula into the expected value.

You know how to run them with Maven, how to read the report and —the decisive part— that the build fails when a test breaks. You know about coverage and its honest reading, deferred to 12-05. And you have been introduced to @SpringBootTest and @DataJpaTest, with the golden rule: use the smallest annotation that does the job.


Forty-one tests in under a second. And now, this lesson's uncomfortable question.

What exactly is mvn test?

You have typed it five times. You have seen that it compiles, that it runs the tests, that it writes reports into target/surefire-reports/ and that it fails the build if something goes red. You have put <scope>test</scope> on a dependency and taken it on trust that this keeps AssertJ and JUnit out of the production jar. You have assumed that src/test/java is where tests go and that the project knows how to find them. You have seen maven-surefire-plugin appear in a pom.xml without knowing what a plugin is. And in 11-01 there was talk of mvn dependency:tree and of groupId:artifactId:version coordinates as if you already knew what they were.

None of that has been explained. And yet it has spent three lessons holding up everything you have done: the Spring dependencies, the Hibernate ones, H2's, JUnit's, the executable jar, spring-boot:run.

There is something more serious. Up to module 10, BiblioTech was compiled with javac by hand and a hand-written classpath. With forty jars in the dependency tree, that is no longer tedious: it is simply impossible. And one of Log4Shell's lessons (11-01) was that the ability to respond to a vulnerability depends on being able to change a version with one line and verify with one command that nothing broke. You have just acquired the second half of that sentence. The first half is missing.

The next lesson explains the tool that has been underneath all along. You will see what problem a build tool solves compared with module 1's javac; the convention over configuration that explains why src/test/java works with no configuration at all; BiblioTech's complete, commented POM; dependencies with their scopes —including that test you have already used—, their transitive dependencies and the rule that resolves version conflicts; the life cycle with its phases, and why mvn test compiles before testing without being asked; the plugins, among them the surefire that runs your tests and the failsafe that will run the integration ones; profiles, multi-module projects, the Maven Wrapper and an honest comparison with Gradle.

And by the end, BiblioTech will be a complete, reproducible Maven project, with all its dependencies declared, runnable with a single command on any machine in the world that has Java 17.

After that we will go back to testing — because the tests you wrote today could only cover what has no real collaborators, and that needs solving.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved