BiblioTech has an architecture, patterns, a professional CLI and a complete REST API. And one unanswered question: does it actually work?

There are tests — the forty-one from module 11, plus the ones the previous lesson added — but that is not a quality strategy. Nobody knows what percentage of the code is exercised. Nobody has checked whether those tests verify anything or simply run lines without asserting a thing. The repository tests run against H2, which is not the production database and which lies about details that matter. Nobody has run a static analyser. And there is no continuous integration: if Diego Alonso breaks the fine calculation on a Friday, nobody finds out until Marta Ruiz complains on Tuesday.

This lesson turns "I have tests" into "I have a quality strategy": knowing what is tested at each level and how long it should take, testing against the real database, measuring coverage and — most importantly — interpreting it honestly, judging the quality of your own assertions with mutation testing, running static analysis tools, refactoring with a safety net, writing code driven by tests, reviewing other people's work, and automating the lot so that the machine says "no" before a user does.

A warning up front: none of this is free. Every tool adds build time and maintenance work. This lesson always includes the cost, not just the benefit, because a quality strategy the team abandons after three weeks is worse than having none at all.

Contents

  1. What "quality" means in a software project
  2. BiblioTech's testing strategy
  3. The test pyramid and why it inverts itself
  4. Target times per level
  5. Testcontainers: why H2 lies
  6. Real PostgreSQL from the test
  7. Separating unit and integration tests in Maven
  8. Coverage with JaCoCo
  9. The honest interpretation of coverage
  10. Mutation testing with PIT
  11. Static analysis: SpotBugs, PMD, Checkstyle, SonarQube
  12. Automatic formatting with Spotless
  13. Complexity, technical debt and code smells
  14. Safe refactoring
  15. TDD: the red-green-refactor cycle
  16. TDD step by step: surcharge for damaged material
  17. When TDD pays off and when it does not
  18. Code review
  19. Continuous integration with GitHub Actions
  20. What NOT to test
  21. Flaky tests as debt
  22. Performance and load
  23. Common Mistakes and Tips
  24. Exercises
  25. Conclusion

  1. What "quality" means in a software project

There are two kinds of quality, and confusing them explains half the arguments on this subject:

External quality Internal quality
Who perceives it The user Whoever maintains the code
What it is That it works, is fast, does not lose data That it is easy to understand and change
How it is measured Production defects, response time Complexity, coupling, time to make a change
If neglected Users complain today The project slows down six months from now

External quality defends itself: users protest. Internal quality has nobody to speak for it, and that is why it degrades. Its symptom is always the same and it is measurable: the time it takes to add a feature grows over time.

Tests are the only tool that serves both: they verify behaviour (external) and they let you change the code without fear (internal). Everything else in this lesson — coverage, mutation, static analysis, reviews — exists to answer one question: can I trust these tests?

  1. BiblioTech's testing strategy

A strategy is not "write tests". It is deciding what is tested at each level so that you neither test the same thing three times nor leave gaps.

Level What is tested Tools Starts up How many
Domain Pure business rules: fines, statuses, invariants JUnit 5, AssertJ Nothing Many (~60%)
Application Use cases: orchestration, error paths JUnit 5, Mockito Nothing Quite a few (~20%)
Repositories Queries, mapping, relations, transactions @DataJpaTest, Testcontainers Database Few (~10%)
Web Routes, validation, status codes, JSON @WebMvcTest, MockMvc Spring MVC Few (~8%)
End to end Complete user flows @SpringBootTest, Testcontainers Everything Very few (~2%)

Applied to one concrete feature, "lend a material", the split looks like this:

What is tested At which level Why there
A loan cannot be returned twice Domain It is an invariant of Loan; it needs nothing else
The fine is €0.50/day capped at €20.00 Domain Pure calculation with Clock.fixed
An employee cannot hold 4 active loans Application It needs the repository (mocked)
If the notifier fails, the loan is still created Application Error path with Mockito
findOverdueBefore returns the right thing Repository It is JPQL: it has to run against a real database
POST /api/loans returns 201 with Location Web It is an HTTP contract
An invalid ISBN returns 400 with the detail Web It is input validation
Lending and returning works end to end E2E Real integration of every piece

The rule that avoids duplication: every check is made at the lowest possible level. Verifying the fine calculation in a @SpringBootTest costs five seconds and proves exactly what a five-millisecond domain test proves.

  1. The test pyramid and why it inverts itself

flowchart TD
    E["E2E — few, slow, brittle<br/>~2%: 15 s each"]
    W["Web and integration<br/>~18%: 1-3 s each"]
    U["Unit — many, fast, stable<br/>~80%: 5 ms each"]

    E --- W
    W --- U

    style U fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style E fill:#ffebee,stroke:#c62828

The right shape is a pyramid: a wide base of fast tests, a narrow tip of slow ones. The shape that appears on its own if nobody watches is the opposite, the ice cream cone:

Reason it inverts How it sounds in the team
An E2E test feels more "real" "If the E2E passes, everything works"
Writing one requires no design work "I just start everything up and that's it"
Testing a class in isolation requires it to be isolatable "The thing is, this class needs half of Spring"
Nobody measures the suite's runtime Until it takes 25 minutes

And the consequences are concrete and all bad: the suite takes so long that nobody runs it before pushing code; when something fails, the diagnosis is "something in the loan flow" instead of "the fine is calculated wrong on day 31"; tests fail intermittently because of timeouts and end up being ignored; and the cost of maintaining them exceeds the value they provide, at which point somebody proposes deleting them.

The root cause is almost never laziness: if testing a class on its own is hard, the problem is that class's design, not the test. A class with seven dependencies and static state cannot be tested in isolation. The solution is not to write an E2E test: it is to fix the class (12-01 and 12-02).

  1. Target times per level

The times are not a whim; they determine whether the suite gets run or ignored.

Level Per test Whole suite When it runs
Domain < 10 ms < 5 s On every save, from the IDE
Application < 50 ms < 15 s Before every commit
Repository < 500 ms < 60 s Before every push
Web < 200 ms < 30 s Before every push
E2E < 20 s < 5 min In CI
Total in CI < 10 min On every Pull Request

The ten-minute limit in CI is not arbitrary: above that threshold people stop waiting for the result, switch task and lose the context. And the fifteen-second limit locally matters even more: if the fast suite takes longer, it stops being run.

Measuring the time matters as much as measuring the result:

# The 10 slowest tests in the project
./mvnw test -Dsurefire.reportFormat=plain
grep -h "Time elapsed" target/surefire-reports/*.txt | sort -t: -k2 -rn | head -10

And a test that stops the suite from degrading, which turns out to be surprisingly effective:

@Test
void theDomainSuiteIsFast() {
    long start = System.nanoTime();
    // run the domain test set…
    long ms = (System.nanoTime() - start) / 1_000_000;
    assertThat(ms)
        .as("The domain tests must stay fast")
        .isLessThan(5_000);
}

  1. Testcontainers: why H2 lies

BiblioTech has used in-memory H2 for repository tests since module 11. It is fast and convenient. And it produces false positives and false negatives, because H2 is not PostgreSQL.

The concrete cases where it lies:

Difference H2 PostgreSQL Consequence
JSON types No real jsonb jsonb with operators and indexes Queries that work in H2 and not in production
Native functions Absent to_tsvector, similarity, generate_series Full-text search cannot be tested
Sequences Its own behaviour Specific SERIAL/IDENTITY semantics Identifier collisions only in production
Text ordering Binary According to the system collation "Álvarez" comes before or after "Alvarez" depending on the engine
Locking Simplified FOR UPDATE, real isolation levels Deadlocks do not show up in the tests
Case sensitivity Depends on configuration Sensitive by default Queries that fail only in production
Constraints Less strict Strict Integrity violations that only surface for real
Time zones Simplified Real timestamptz Date errors at the daylight-saving switch

The most painful case, and a very real one: a JPQL query with a function that Hibernate translates differently depending on the dialect. It passes green with H2 and blows up in production with an SQL syntax error. The cost of that lesson is paid at 3 in the morning.

Testcontainers solves this: it starts a Docker container with the real database from the test itself, and destroys it when it finishes.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-testcontainers</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>postgresql</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>

  1. Real PostgreSQL from the test

Spring Boot 3.1 introduced @ServiceConnection, which removes the most tedious part: configuring the URL, the username and the password from the container.

/**
 * Base class for the integration tests.
 *
 * The container is static: it is created ONCE for the whole run
 * and shared by every class that inherits from it. Without static,
 * one PostgreSQL would start per test class (30 s each).
 */
@Testcontainers
public abstract class PostgresTestBase {

    @Container
    @ServiceConnection            // Spring Boot 3.1+: configures the DataSource on its own
    static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>("postgres:16-alpine")
                    .withDatabaseName("bibliotech_test")
                    .withUsername("test")
                    .withPassword("test")
                    .withReuse(true);      // reuses the container between local runs
}

Before @ServiceConnection you had to write this, and it is still seen in many projects:

@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
    registry.add("spring.datasource.username", POSTGRES::getUsername);
    registry.add("spring.datasource.password", POSTGRES::getPassword);
}

A repository test with a real database:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)   // do not swap in H2!
@Tag("integration")
class LoanRepositoryIT extends PostgresTestBase {

    @Autowired LoanRepository repository;
    @Autowired TestEntityManager em;

    @Test
    void findsTheOverdueLoansOrderedByAge() {
        Employee marta = em.persist(anEmployee("Marta Ruiz"));
        Material java = em.persist(aBook("978-0000000001", "Effective Java"));

        em.persist(aLoan(java, marta).dueOn(LocalDate.of(2026, 3, 1)));    // overdue
        em.persist(aLoan(java, marta).dueOn(LocalDate.of(2026, 3, 10)));   // overdue
        em.persist(aLoan(java, marta).dueOn(LocalDate.of(2026, 9, 1)));    // current
        em.flush();

        List<Loan> overdue = repository.overdueBefore(LocalDate.of(2026, 8, 5));

        assertThat(overdue)
                .hasSize(2)
                .extracting(Loan::getDueDate)
                .containsExactly(LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 10));
    }

    @Test
    void theSearchIgnoresCaseAndAccentsJustLikeInProduction() {
        em.persist(aBook("978-0000000003", "Refactoring"));
        em.flush();

        // This uses PostgreSQL's unaccent(): in H2 it would be IMPOSSIBLE to test
        assertThat(repository.findByTitle("refactoring")).hasSize(1);
        assertThat(repository.findByTitle("REFACTORING")).hasSize(1);
    }

    @Test
    void detectsTheOptimisticLockingConflict() {
        Loan loan = em.persistFlushFind(aLoan());
        em.detach(loan);

        // Simulate a concurrent modification by another transaction
        em.getEntityManager()
          .createNativeQuery("update loans set version = version + 1 where id = :id")
          .setParameter("id", loan.getId())
          .executeUpdate();

        loan.renew(7);

        assertThatThrownBy(() -> { repository.save(loan); em.flush(); })
                .isInstanceOf(OptimisticLockingFailureException.class);
    }
}

The cost, unvarnished:

Aspect H2 Testcontainers
Startup ~200 ms 3-15 s for the first container
Per test ~10 ms ~50 ms (shared container)
Requires Docker No Yes, in CI too
Fidelity to production Low Total
Suite of 30 repository tests ~5 s ~25 s

Five times slower, and worth it, for one concrete reason: repository tests are few (10% of the suite) and they are exactly the ones that benefit most from fidelity. The domain tests, which are 60%, still start nothing and still take milliseconds.

Two tricks that reduce the real cost:

# ~/.testcontainers.properties — reuse containers between local runs
testcontainers.reuse.enable=true

And the single-container pattern, already applied above with the static in the base class: without it, every test class starts its own PostgreSQL.

  1. Separating unit and integration tests in Maven

With tests running at two speeds, you need to be able to run only the fast ones. Maven has had the mechanism since 11-05: surefire for unit tests, failsafe for integration tests.

Naming convention:

Suffix Plugin Phase Example
*Test.java surefire test FineCalculatorTest
*IT.java failsafe verify LoanRepositoryIT
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <configuration>
        <excludedGroups>integration</excludedGroups>   <!-- in case some Test carries the @Tag -->
        <includes>
          <include>**/*Test.java</include>
        </includes>
      </configuration>
    </plugin>

    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-failsafe-plugin</artifactId>
      <configuration>
        <includes>
          <include>**/*IT.java</include>
        </includes>
      </configuration>
      <executions>
        <execution>
          <goals>
            <goal>integration-test</goal>
            <goal>verify</goal>            <!-- verify is what FAILS the build -->
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>
./mvnw test                    # unit tests only: ~20 s
./mvnw verify                  # unit + integration: ~3 min
./mvnw verify -DskipITs        # skip the integration ones
./mvnw test -Dgroups=fast       # only the tagged ones

On top of that, JUnit 5's @Tag allows cross-cutting slices:

@Tag("integration")
@Tag("slow")
class BulkImportIT extends PostgresTestBase { … }

One detail about parallelism, which is the cheapest way to claw back time:

# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=1.0

With the obligatory warning: parallelism exposes tests that share state. If they start failing randomly once you turn it on, do not turn parallelism off; fix the tests, because that shared state is a real problem.

  1. Coverage with JaCoCo

Coverage measures what percentage of the code runs during the tests. JaCoCo is the standard tool in Java.

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.12</version>
  <executions>
    <!-- 1. Instrument before the unit tests -->
    <execution>
      <id>prepare-agent</id>
      <goals><goal>prepare-agent</goal></goals>
    </execution>

    <!-- 2. Generate the report after the tests -->
    <execution>
      <id>report</id>
      <phase>verify</phase>
      <goals><goal>report</goal></goals>
    </execution>

    <!-- 3. Check thresholds: if they are not met, the build FAILS -->
    <execution>
      <id>coverage-threshold</id>
      <phase>verify</phase>
      <goals><goal>check</goal></goals>
      <configuration>
        <rules>
          <rule>
            <element>BUNDLE</element>
            <limits>
              <limit>
                <counter>INSTRUCTION</counter>
                <value>COVEREDRATIO</value>
                <minimum>0.75</minimum>
              </limit>
              <limit>
                <counter>BRANCH</counter>          <!-- the metric that really matters -->
                <value>COVEREDRATIO</value>
                <minimum>0.70</minimum>
              </limit>
            </limits>
          </rule>
          <!-- The domain is pure logic: more is demanded of it -->
          <rule>
            <element>PACKAGE</element>
            <includes><include>com.nexussoftware.bibliotech.domain.*</include></includes>
            <limits>
              <limit>
                <counter>BRANCH</counter>
                <value>COVEREDRATIO</value>
                <minimum>0.90</minimum>
              </limit>
            </limits>
          </rule>
        </rules>
      </configuration>
    </execution>
  </executions>

  <configuration>
    <excludes>
      <!-- Exclude whatever has no logic to test -->
      <exclude>**/dto/**</exclude>
      <exclude>**/*Application.class</exclude>
      <exclude>**/config/**</exclude>
      <exclude>**/generated/**</exclude>
    </excludes>
  </configuration>
</plugin>
./mvnw verify
# The browsable report, with the code coloured line by line:
open target/site/jacoco/index.html

Line coverage versus branch coverage, which is the distinction that separates a useful metric from a misleading one:

public Money calculateFine(Loan loan, LocalDate today) {
    long days = ChronoUnit.DAYS.between(loan.getDueDate(), today);
    if (days <= 0) {
        return Money.ZERO;
    }
    Money fine = loan.finePerDay().times(days);
    return fine.isGreaterThan(MAX_FINE) ? MAX_FINE : fine;
}

With a single test:

@Test
void calculatesTheFineForTenDays() {
    assertThat(calculator.calculateFine(loanOverdueBy(10), TODAY))
            .isEqualTo(Money.euros("5.00"));
}
Metric Result What is missing
Lines 80% (4 of 5) The return Money.ZERO
Branches 50% (2 of 4) days <= 0, and the cap at the maximum

Line coverage says 80% and that sounds fine. Branch coverage says 50% and it tells the truth: half the decision paths have never been tested, including the €20 cap, which is an explicit business rule.

Always measure branches. It is harder to raise and far more informative.

  1. The honest interpretation of coverage

This is where most teams fool themselves, so it is worth being blunt:

High coverage does NOT guarantee quality. Low coverage DOES signal risk.

It is a one-way implication, and this test proves it:

@Test
void calculatesTheFine() {
    // Runs the WHOLE method: 100% line coverage
    calculator.calculateFine(loanOverdueBy(10), TODAY);
    // And CHECKS NOTHING.
}

JaCoCo will report 100% coverage of that method. If tomorrow somebody changes 0.50 to 50.00, the test still passes green. Coverage measures execution, not verification.

Real cases of coverage that lies:

Pattern Coverage Real value
Test with no assertions 100% Zero
assertThat(result).isNotNull() 100% Almost zero
Test that only covers the happy path 60% branches Half: the errors are not tested
Test with the same logic as the code 100% Negative: it replicates the bug

And the other way round, low coverage always means something:

Coverage of a package Interpretation
0% in domain.loans Alarm: the business logic is not tested
30% in a service The error paths are probably not tested
95% in dto Irrelevant: there is no logic; exclude it from the calculation

How to use coverage without fooling yourself:

  1. As a gap detector, not as a target. Open the report and look for important logic in red. That is a to-do list.
  2. With per-package thresholds, not global ones. Demanding 90% in the domain and 60% in infrastructure makes sense; demanding 80% overall rewards testing getters.
  3. Watching the trend, not the absolute value. Dropping from 78% to 71% in a PR is a signal; being at 78% instead of 80% is not.
  4. Never as an individual target. The day somebody measures a developer's performance by coverage, you will have thousands of tests with no assertions. Goodhart's law in its purest form: when a measure becomes a target, it ceases to be a good measure.

And to find out whether your tests verify anything, there is a specific tool.

  1. Mutation testing with PIT

Coverage measures whether the code runs. Mutation testing measures whether your assertions detect changes.

How it works: the tool introduces small modifications into your code (the mutants) and runs the tests. If one fails, the mutant has been killed (good). If they all pass, the mutant survives: your tests would not detect that change (bad).

Mutation Example
Conditional boundary <<=
Negate conditional ==!=
Arithmetic operator +-
Return value return xreturn null
Remove void call The line is deleted
Increments ++--
<plugin>
  <groupId>org.pitest</groupId>
  <artifactId>pitest-maven</artifactId>
  <version>1.16.1</version>
  <dependencies>
    <dependency>
      <groupId>org.pitest</groupId>
      <artifactId>pitest-junit5-plugin</artifactId>
      <version>1.2.1</version>
    </dependency>
  </dependencies>
  <configuration>
    <targetClasses>
      <param>com.nexussoftware.bibliotech.domain.*</param>   <!-- where the logic lives -->
    </targetClasses>
    <targetTests>
      <param>com.nexussoftware.bibliotech.domain.*Test</param>
    </targetTests>
    <mutationThreshold>70</mutationThreshold>
    <timestampedReports>false</timestampedReports>
  </configuration>
</plugin>
./mvnw org.pitest:pitest-maven:mutationCoverage
open target/pit-reports/index.html

The surviving mutant in the fine calculation. This is the real code:

public Money calculateFine(Loan loan, LocalDate today) {
    long days = ChronoUnit.DAYS.between(loan.getDueDate(), today);
    if (days <= 0) {                                    // ← the critical point
        return Money.ZERO;
    }
    Money fine = loan.finePerDay().times(days);
    return fine.isGreaterThan(MAX_FINE) ? MAX_FINE : fine;
}

And these were the tests in place:

@Test void noDelayMeansNoFine()        { assertThat(fineFor(-3)).isEqualTo(Money.ZERO); }
@Test void tenDaysMeansFiveEuros()     { assertThat(fineFor(10)).isEqualTo(Money.euros("5.00")); }
@Test void itNeverExceedsTheMaximum()  { assertThat(fineFor(100)).isEqualTo(Money.euros("20.00")); }

Branch coverage: 100%. PIT report:

FineCalculator.java
  L.4   changed conditional boundary → SURVIVED    (days <= 0  →  days < 0)
  L.8   changed conditional boundary → KILLED
  L.7   Replaced long multiplication with division → KILLED

The surviving mutant changes days <= 0 into days < 0. The difference lies exactly on day 0: the day the loan falls due. With the original code, returning the item that same day produces no fine. With the mutant, days == 0 enters the calculation and produces… 0 days × €0.50 = €0.00. In this particular case the result matches by chance, but the boundary is not tested, and it only takes somebody changing the formula tomorrow to (days + 1) * rate for the due date itself to start being charged without any test noticing.

The missing test, and the one an experienced reviewer would ask for:

@ParameterizedTest
@CsvSource({
    "-1, 0.00",     // one day before it falls due
    " 0, 0.00",     // THE DUE DATE ITSELF: the boundary
    " 1, 0.50",     // one day late
    "39, 19.50",    // just under the maximum
    "40, 20.00",    // exactly the maximum
    "41, 20.00"     // above it: the cap applies
})
void calculatesTheFineAtTheBoundaries(int daysLate, String expectedFine) {
    assertThat(fineFor(daysLate)).isEqualTo(Money.euros(expectedFine));
}

With it, PIT kills the mutant. And notice what has happened: coverage was 100% before and is still 100% after. Coverage could not see this problem; mutation could.

The cost, which is real: PIT is slow (it runs the suite once per mutant) and it produces false positives (equivalent mutants, which do not change behaviour and are impossible to kill). So:

  • Apply it only to the domain, which is where the logic that matters lives.
  • Run it weekly or on the main branch, not on every PR.
  • A threshold of 70-80% in the domain is demanding and attainable. 100% is not a reasonable goal.

  1. Static analysis: SpotBugs, PMD, Checkstyle, SonarQube

Static analysis examines the code without running it. Each tool looks for different things and they complement one another:

Tool What it looks for Example finding False positives
SpotBugs Likely bugs (analyses bytecode) Possible NullPointerException, comparing String with ==, unclosed resource Few
PMD Bad practices and complexity 200-line method, complexity 25, unused variable, empty catch Medium
Checkstyle Style and conventions Names, import order, missing Javadoc Many if badly configured
SonarQube All of the above + security + duplication + history SQL injection, secrets, technical debt in hours Medium
ArchUnit Architecture rules (12-01) "The domain imports Spring" None
Error Prone Bugs at compile time Comparison of incompatible types Very few

SpotBugs delivers the most value per line of configuration, because it finds real defects:

<plugin>
  <groupId>com.github.spotbugs</groupId>
  <artifactId>spotbugs-maven-plugin</artifactId>
  <version>4.8.6.4</version>
  <configuration>
    <effort>Max</effort>
    <threshold>Medium</threshold>
    <failOnError>true</failOnError>
    <excludeFilterFile>config/spotbugs-exclusions.xml</excludeFilterFile>
    <plugins>
      <plugin>
        <groupId>com.h3xstream.findsecbugs</groupId>     <!-- security analysis -->
        <artifactId>findsecbugs-plugin</artifactId>
        <version>1.13.0</version>
      </plugin>
    </plugins>
  </configuration>
  <executions>
    <execution><phase>verify</phase><goals><goal>check</goal></goals></execution>
  </executions>
</plugin>

Typical findings in a project like BiblioTech:

// SpotBugs: DM_DEFAULT_ENCODING — depends on the platform encoding
Files.readString(path);                          // BAD
Files.readString(path, StandardCharsets.UTF_8);  // GOOD

// SpotBugs: ES_COMPARING_STRINGS_WITH_EQ
if (status == "ACTIVE")           // BAD: compares references
if ("ACTIVE".equals(status))      // GOOD

// SpotBugs: EI_EXPOSE_REP — the internal representation is exposed
public List<Loan> getLoans() { return loans; }             // BAD: mutable
public List<Loan> getLoans() { return List.copyOf(loans); } // GOOD

// FindSecBugs: SQL_INJECTION_JPA
em.createQuery("select m from Material m where m.title like '%" + text + "%'");     // BAD
em.createQuery("select m from Material m where m.title like :t").setParameter("t", …); // GOOD

SonarQube / SonarCloud adds what the others do not: history and the concept of new code.

./mvnw verify sonar:sonar \
  -Dsonar.projectKey=nexussoftware_bibliotech \
  -Dsonar.host.url=https://sonarcloud.io \
  -Dsonar.token=$SONAR_TOKEN

Its most useful idea is called Clean as You Code: it does not demand that you fix the historical debt (impossible and demoralising), but that new code meets the standard. Typical thresholds:

Metric on new code Threshold
Coverage ≥ 80%
Duplication ≤ 3%
Vulnerabilities 0
High-severity bugs 0
Blocker code smells 0

And one piece of adoption advice that is worth more than the configuration: do not switch on every rule of every tool on day one. Three thousand warnings will appear, the team will ignore them wholesale and you will have lost the tool. Start with SpotBugs at high severity and ArchUnit; add the rest gradually.

  1. Automatic formatting with Spotless

Already configured in 12-01. Here only the part that concerns CI:

./mvnw spotless:apply     # formats (locally, or as a pre-commit hook)
./mvnw spotless:check     # fails if it is not formatted (in CI)

The real benefit is not aesthetic: it removes all the formatting noise from code reviews, leaving room to talk about what matters. And it makes diffs show behaviour changes, not re-indentations.

  1. Complexity, technical debt and code smells

Cyclomatic complexity is the number of independent paths through a method: 1 + the number of decisions (if, case, &&, ||, catch, loops).

Complexity Assessment Action
1-5 Simple None
6-10 Moderate Acceptable
11-20 Complex Refactor when you touch it
21+ Very complex Refactor now

Its most direct practical use: cyclomatic complexity is the minimum number of tests needed to cover every branch. A method with complexity 15 needs 15 tests. If it does not have them, there are untested paths.

Technical debt. Ward Cunningham's metaphor: taking a shortcut today is taking out a loan; the interest is the extra time every future change will cost.

Type Example Acceptable?
Deliberate and prudent "We ship without a cache; we add it if needed" Yes, if documented
Deliberate and reckless "There's no time for tests" No
Inadvertent and prudent "Now we know how it should have been done" Unavoidable
Inadvertent and reckless "What's a layer?" Cured by training

Prudent debt gets written down. In BiblioTech:

// DEBT: the search walks the whole catalogue in memory because there are ~3,000 materials.
// Beyond 50,000 we will have to move to PostgreSQL full-text search.
// Consciously decided on 2026-08-05 — see docs/adr/0007-in-memory-search.md

The most frequent code smells and their remedies:

Smell Symptom Remedy
Long method More than 30 lines Extract method
Large class More than 300 lines, more than 10 dependencies Extract class (12-02)
Long parameter list More than 4 Parameter object, Builder
Feature envy A method uses more data from another class than from its own Move the method
Primitive obsession String isbn instead of Isbn Value object
Repeated switch statements The same switch in five places Polymorphism
Duplicated code Copy and paste Extract method or class
Explanatory comments "Here we calculate the fine taking into account…" Extract a well-named method

  1. Safe refactoring

Refactoring means changing the internal structure without changing the observable behaviour. The condition is not negotiable: without tests it is not refactoring, it is rewriting and hoping.

The cycle:

flowchart LR
    A["Tests green"] --> B["One small change"]
    B --> C["Run the tests"]
    C -->|"green"| D["Commit"]
    C -->|"red"| E["Undo"]
    D --> B
    E --> A

Refactoring 1: extract method.

// BEFORE: 40 lines, three responsibilities tangled together
public ImportResult importFrom(Path file) {
    List<String> lines = Files.readAllLines(file, UTF_8);
    List<Material> materials = new ArrayList<>();
    List<String> errors = new ArrayList<>();

    for (int i = 1; i < lines.size(); i++) {
        String[] fields = lines.get(i).split(";");
        if (fields.length < 4) { errors.add("Line " + i + ": not enough fields"); continue; }
        if (!fields[0].matches("97[89]-\\d{10}")) { errors.add("Line " + i + ": invalid ISBN"); continue; }
        // … 20 more lines of conversion and validation
    }
    // … saving and summary
}
// AFTER: the main method tells the story; the details live one level down
public ImportResult importFrom(Path file) throws IOException {
    List<CsvLine> lines = readLines(file);
    ParseResult parsed = parse(lines);
    List<Material> saved = save(parsed.valid());
    return new ImportResult(saved.size(), parsed.errors());
}

private ParseResult parse(List<CsvLine> lines) {
    List<Material> valid = new ArrayList<>();
    List<ImportError> errors = new ArrayList<>();
    for (CsvLine line : lines) {
        parseLine(line).ifPresentOrElse(valid::add, () -> errors.add(errorFrom(line)));
    }
    return new ParseResult(valid, errors);
}

Refactoring 2: extract class.

// BEFORE: Loan mixes its identity with the fine calculation
public class Loan {
    public Money calculateFine(LocalDate today) {
        long days = ChronoUnit.DAYS.between(dueDate, today);
        if (days <= 0) return Money.ZERO;
        Money base = material.finePerDay().times(days);
        if (employee.tenureInMonths() < 6) base = base.multiplyBy(0.5);
        if (employee.isManagement()) return Money.ZERO;
        return base.isGreaterThan(MAX_FINE) ? MAX_FINE : base;
    }
}
// AFTER: the fine policy is a concept of its own, with tests of its own
public class FinePolicy {
    private final List<RateRule> rules;         // Strategy (12-02)

    public Money calculate(Loan loan, LocalDate today) { … }
}

public class Loan {
    public Money accruedFine(LocalDate today, FinePolicy policy) {
        return policy.calculate(this, today);
    }
}

Refactoring 3: replace conditional with polymorphism (picks up 03-06).

// BEFORE
public int loanDays(Material m) {
    switch (m.getType()) {
        case BOOK: return 15;
        case MAGAZINE: return 7;
        case DVD: return 3;
        default: throw new IllegalStateException();
    }
}
// AFTER: each type answers for itself
public abstract class Material {
    public abstract int defaultLoanDays();
}

And the safe procedure for doing it, which is what stops you breaking things:

  1. Add the abstract method and its implementations, without deleting the switch.
  2. Make the switch delegate: return m.defaultLoanDays();
  3. Run the tests. Green.
  4. Replace the calls to the old method with direct calls.
  5. Run the tests. Green.
  6. Delete the old method.
  7. Run the tests. Commit.

Seven steps, seven chances to catch a mistake. Doing it in one go gives you zero.

  1. TDD: the red-green-refactor cycle

Test-Driven Development reverses the usual order: the test first, the code afterwards.

flowchart LR
    R["🔴 RED<br/>Write a failing test"]
    V["🟢 GREEN<br/>The minimum code to pass it"]
    F["🔵 REFACTOR<br/>Improve without breaking anything"]
    R --> V --> F --> R

Robert C. Martin's three rules:

  1. Do not write production code except to make a failing test pass.
  2. Do not write more of a test than is needed to fail (not compiling counts as failing).
  3. Do not write more production code than is needed to pass the test.

And what is usually misunderstood: TDD is not a testing technique, it is a design technique. The tests are a side effect. What it does is force you to use your own API before implementing it, and that produces more usable designs with fewer dependencies, because a class that is hard to test is painful to write under TDD and you feel it at the start, not at the end.

  1. TDD step by step: surcharge for damaged material

A new requirement from Nexus Software: if a material is returned damaged, a surcharge is applied on top of the late fine.

  • Minor damage: 20% of the material's value.
  • Severe damage: 60%.
  • Unrecoverable: 100% of the value, and the material is withdrawn from the catalogue.
  • The surcharge is added to the late fine.
  • The total charge (fine + surcharge) cannot exceed the material's value.

Let us go step by step, without skipping any.

Step 1 — Red. The simplest possible test:

class DamageSurchargeTest {

    @Test
    void anUndamagedMaterialHasNoSurcharge() {
        var calculator = new SurchargeCalculator();
        var material = aBook("978-0000000001").withValue(Money.euros("45.00"));

        Money surcharge = calculator.calculate(material, ReturnCondition.UNDAMAGED);

        assertThat(surcharge).isEqualTo(Money.ZERO);
    }
}

It does not compile. SurchargeCalculator and ReturnCondition do not exist. That is red.

Step 2 — Green. The minimum code. Literally the minimum:

public enum ReturnCondition { UNDAMAGED }

public class SurchargeCalculator {
    public Money calculate(Material material, ReturnCondition condition) {
        return Money.ZERO;      // yes, this is cheating. And it is correct in TDD.
    }
}

Green. Always returning zero looks absurd, but it is exactly what TDD asks for: without a test demanding something else, there is no justification for writing more.

Step 3 — Red. Now we force the next case:

@Test
void minorDamageMeansTwentyPercentOfTheValue() {
    var material = aBook("978-0000000001").withValue(Money.euros("45.00"));

    Money surcharge = calculator.calculate(material, ReturnCondition.MINOR);

    assertThat(surcharge).isEqualTo(Money.euros("9.00"));     // 45 × 0.20
}

Step 4 — Green:

public enum ReturnCondition { UNDAMAGED, MINOR }

public class SurchargeCalculator {
    public Money calculate(Material material, ReturnCondition condition) {
        if (condition == ReturnCondition.UNDAMAGED) return Money.ZERO;
        return material.getValue().multiplyBy(new BigDecimal("0.20"));
    }
}

Step 5 — Red, green, and the duplication appears. We add severe and unrecoverable:

@ParameterizedTest
@CsvSource({
    "UNDAMAGED,      0.00",
    "MINOR,          9.00",
    "SEVERE,        27.00",
    "UNRECOVERABLE, 45.00"
})
void theSurchargeDependsOnTheReturnCondition(ReturnCondition condition, String expected) {
    var material = aBook("978-0000000001").withValue(Money.euros("45.00"));
    assertThat(calculator.calculate(material, condition)).isEqualTo(Money.euros(expected));
}

An implementation that passes it:

public Money calculate(Material material, ReturnCondition condition) {
    BigDecimal percentage = switch (condition) {
        case UNDAMAGED -> BigDecimal.ZERO;
        case MINOR -> new BigDecimal("0.20");
        case SEVERE -> new BigDecimal("0.60");
        case UNRECOVERABLE -> BigDecimal.ONE;
    };
    return material.getValue().multiplyBy(percentage);
}

Step 6 — Refactor. Tests green: time to improve the design. That switch is exactly what 12-02 taught you to replace, and an enum with state is the idiomatic form:

public enum ReturnCondition {
    UNDAMAGED(BigDecimal.ZERO),
    MINOR(new BigDecimal("0.20")),
    SEVERE(new BigDecimal("0.60")),
    UNRECOVERABLE(BigDecimal.ONE);

    private final BigDecimal surchargePercentage;

    ReturnCondition(BigDecimal surchargePercentage) {
        this.surchargePercentage = surchargePercentage;
    }

    public BigDecimal surchargePercentage() { return surchargePercentage; }
    public boolean requiresWithdrawal() { return this == UNRECOVERABLE; }
}

public class SurchargeCalculator {
    public Money calculate(Material material, ReturnCondition condition) {
        return material.getValue().multiplyBy(condition.surchargePercentage());
    }
}

Tests run: still green. That is the point of TDD: the refactor is not frightening because there is a net underneath.

Step 7 — Red. The cap at the total value:

@Test
void theTotalOfFineAndSurchargeDoesNotExceedTheMaterialValue() {
    var material = aBook("978-0000000001").withValue(Money.euros("45.00"));
    var loan = aLoanOf(material).overdueBy(200);              // enormous fine

    Money total = calculator.totalToCharge(loan, ReturnCondition.SEVERE, TODAY);

    // fine (capped at €20) + surcharge (€27) = €47, but the material is worth €45
    assertThat(total).isEqualTo(Money.euros("45.00"));
}

Step 8 — Green:

public Money totalToCharge(Loan loan, ReturnCondition condition, LocalDate today) {
    Money fine = finePolicy.calculate(loan, today);
    Money surcharge = calculate(loan.getMaterial(), condition);
    Money total = fine.plus(surcharge);
    Money materialValue = loan.getMaterial().getValue();
    return total.isGreaterThan(materialValue) ? materialValue : total;
}

Step 9 — Red, the side effect. The withdrawal from the catalogue is missing:

@Test
void anUnrecoverableMaterialIsWithdrawnFromTheCatalogue() {
    var material = aBook("978-0000000001").withValue(Money.euros("45.00"));
    var loan = aLoanOf(material);

    service.registerReturn(loan.getId(), ReturnCondition.UNRECOVERABLE, TODAY);

    assertThat(material.isWithdrawn()).isTrue();
    verify(catalog).withdraw(material.getIsbn(), WithdrawalReason.DAMAGED);
}

@Test
void aSeverelyDamagedMaterialStaysInTheCatalogue() {
    var material = aBook("978-0000000001");
    service.registerReturn(aLoanOf(material).getId(), ReturnCondition.SEVERE, TODAY);

    assertThat(material.isWithdrawn()).isFalse();
    verifyNoInteractions(catalog);
}

Step 10 — Green:

@Transactional
public ReturnResult registerReturn(Long loanId, ReturnCondition condition, LocalDate date) {
    Loan loan = repository.findById(loanId)
            .orElseThrow(() -> new LoanNotFoundException(loanId));

    loan.registerReturn(date);
    Money total = calculator.totalToCharge(loan, condition, date);

    if (condition.requiresWithdrawal()) {
        catalog.withdraw(loan.getIsbn(), WithdrawalReason.DAMAGED);
    }
    events.publish(new MaterialReturned(loan.getId(), condition, total));       // Observer
    return new ReturnResult(loan.getId(), date, total, condition);
}

Step 11 — Refactor and verification with PIT:

./mvnw org.pitest:pitest-maven:mutationCoverage \
    -DtargetClasses=com.nexussoftware.bibliotech.domain.loans.*
SurchargeCalculator    : 100% mutation (8/8 mutants killed)
ReturnCondition        : 100% mutation (4/4)

What this process has produced, and it is worth pointing out:

  1. Zero untested code. Every line exists because a test demanded it.
  2. A better design. The enum with state did not come out of the first implementation: it came out of the refactor step, which TDD makes safe.
  3. The boundaries covered from the start. The material-value cap was thought through while writing the test, not on receiving a bug report.
  4. Executable documentation. The test names are the specification of the requirement.

  1. When TDD pays off and when it does not

An honest assessment, without dogma:

TDD pays off a lot TDD adds little or gets in the way
Business logic with rules and edge cases Exploratory code: you do not yet know what you want
Fixing a bug (first the test that reproduces it) User interfaces, visual layout
Algorithms with clear inputs and outputs Integrations with poorly documented external APIs
APIs that others are going to use Configuration and wiring
Refactoring legacy code (characterise it first) Prototypes that will be thrown away
When the design is unclear and you want it to emerge When the design is obvious and trivial

Two observations that are usually missing from TDD arguments:

  • It is not all or nothing. You can use TDD for the domain and write the tests afterwards for the controllers. That is what most teams who really use it do.
  • What matters is that the tests exist and verify. A team that writes exhaustive tests after the code is infinitely better off than one that claims to do TDD and does not.

And there is one case where TDD is simply the best option available: fixing a bug. The sequence is always the same and it always works:

  1. Write a test that reproduces the bug. It must fail.
  2. Fix the code. The test passes.
  3. That test stays forever, and the bug cannot come back without somebody noticing.

  1. Code review

Code review is the cheapest quality control there is, and the one most often done badly.

What to look at, in order of importance:

Priority What Example question
1 Correctness Does it do what it says? Edge cases? Nulls?
2 Security Is the input validated? Sensitive data in the log?
3 Tests Are there any? Do they really verify or only execute?
4 Design Is it in the right layer? Does it couple what it should not?
5 Readability Is it understandable without explanation? Do the names tell the truth?
6 Consistency Does it follow the project's patterns?
7 Style (Should be automated with Spotless)

How to give useful feedback. The difference between a comment that improves the code and one that creates resistance:

Instead of Write
"This is wrong" "If material is null here, wouldn't line 42 throw an NPE?"
"Use a stream" "A stream().filter().toList() would make this more direct, what do you think?"
"I don't understand any of this" "Could you explain what flag2 represents? Maybe a more descriptive name would help"
"The test is missing" "Would a test for the case where the employee already has 3 loans be worth it?"

Three conventions that work:

  • Mark the severity. [blocking], [suggestion], [nit] (minor detail), [question]. Without that, the author does not know what must change and what is optional.
  • Praise the good. "Nice call extracting this into FinePolicy" costs five seconds and changes the tone of the review.
  • Review early and in small chunks. A 1,000-line PR gets "LGTM"; a 200-line one gets useful comments. Review quality falls off a cliff as size grows.

BiblioTech's checklist:

## Code review — BiblioTech

### Correctness
- [ ] Does it do what the PR description says?
- [ ] Are the edge cases handled (empty, null, zero, negative, maximum)?
- [ ] Are exceptions caught at the right level (06-07)?
- [ ] Are there race conditions if this runs in parallel?

### Architecture
- [ ] Is it in the right module (domain / application / infrastructure)?
- [ ] Does the domain still avoid importing Spring or JPA?
- [ ] Are JPA entities exposed in the API? (they should not be)
- [ ] Is the transaction in the use case, not in the controller?

### Tests
- [ ] Are there tests for the happy path AND for the errors?
- [ ] Do the tests have meaningful assertions?
- [ ] Are they at the lowest possible level?
- [ ] Is an injectable `Clock` used instead of `LocalDate.now()`?

### Security (12-07)
- [ ] Is every external input validated?
- [ ] Are there secrets, tokens or personal data in the code or the log?
- [ ] Do the queries use parameters, never concatenation?

### Readability
- [ ] Do the names describe the intent?
- [ ] Does any method exceed 30 lines or complexity 10?
- [ ] Do the comments explain the "why", not the "what"?

  1. Continuous integration with GitHub Actions

Continuous integration automatically runs the build and the tests on every change. Its value is not technical but social: it takes away the responsibility of remembering.

flowchart LR
    P["Push / PR"] --> C["Compile"]
    C --> F["Formatting<br/>Spotless"]
    F --> U["Unit<br/>tests"]
    U --> I["Integration<br/>tests"]
    I --> CO["Coverage<br/>JaCoCo"]
    CO --> A["Analysis<br/>SpotBugs"]
    A --> R["Result<br/>on the PR"]

    style R fill:#e8f5e9,stroke:#2e7d32
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Cancels earlier runs of the same PR: testing already-obsolete code makes no sense
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  JAVA_VERSION: '21'

jobs:

  # ---------------------------------------------------------------
  # Job 1: fast. Gives an answer in under 3 minutes.
  # ---------------------------------------------------------------
  fast-checks:
    name: Build, formatting and unit tests
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK ${{ env.JAVA_VERSION }}
        uses: actions/setup-java@v4
        with:
          java-version: ${{ env.JAVA_VERSION }}
          distribution: temurin
          cache: maven              # caches ~/.m2: saves 1-2 minutes per run

      - name: Check formatting
        run: ./mvnw -B spotless:check

      - name: Compile
        run: ./mvnw -B clean compile

      - name: Unit tests
        run: ./mvnw -B test

      - name: Publish test results
        uses: mikepenz/action-junit-report@v4
        if: always()                # also when they fail: that is when it is needed most
        with:
          report_paths: '**/target/surefire-reports/TEST-*.xml'
          check_name: 'Unit tests'

  # ---------------------------------------------------------------
  # Job 2: slow. Testcontainers, coverage and static analysis.
  # ---------------------------------------------------------------
  full-checks:
    name: Integration, coverage and analysis
    runs-on: ubuntu-latest
    needs: fast-checks              # do not spend 10 minutes if it does not compile
    timeout-minutes: 25

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0            # Sonar needs the history for "new code"

      - uses: actions/setup-java@v4
        with:
          java-version: ${{ env.JAVA_VERSION }}
          distribution: temurin
          cache: maven

      # Docker is already available on ubuntu-latest: Testcontainers just works

      - name: Integration tests and coverage
        run: ./mvnw -B verify
        env:
          TESTCONTAINERS_RYUK_DISABLED: 'false'

      - name: Check coverage thresholds
        run: ./mvnw -B jacoco:check

      - name: Publish coverage on the PR
        uses: madrapps/[email protected]
        if: github.event_name == 'pull_request'
        with:
          paths: '**/target/site/jacoco/jacoco.xml'
          token: ${{ secrets.GITHUB_TOKEN }}
          min-coverage-overall: 75
          min-coverage-changed-files: 80    # NEW code, held to a higher bar
          title: 'Coverage report'

      - name: Static analysis
        run: ./mvnw -B spotbugs:check

      - name: Store reports
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: reports
          path: |
            **/target/site/jacoco/
            **/target/spotbugsXml.xml
          retention-days: 7

  # ---------------------------------------------------------------
  # Job 3: main only. Mutation testing, which is slow.
  # ---------------------------------------------------------------
  mutation:
    name: Mutation testing
    runs-on: ubuntu-latest
    needs: full-checks
    if: github.ref == 'refs/heads/main'
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: ${{ env.JAVA_VERSION }}
          distribution: temurin
          cache: maven

      - name: PIT over the domain
        run: ./mvnw -B -pl bibliotech-domain org.pitest:pitest-maven:mutationCoverage

      - uses: actions/upload-artifact@v4
        with:
          name: mutation-report
          path: '**/target/pit-reports/'

And the part that makes all of this worth anything: protecting the main branch in the repository settings.

Rule Effect
Require a PR before merging Nobody pushes straight to main
Require CI to be green A PR with red tests cannot be merged
Require 1 approval Everything is reviewed by somebody else
Dismiss approvals when new changes arrive One thing is approved and another merged
Require the branch to be up to date It is tested against the current main

Without branch protection, CI is a traffic light nobody is obliged to look at.

  1. What NOT to test

Writing useless tests costs time, slows the suite down and gives a false sense of safety.

Do not test Why
Trivial getters and setters There is no logic. If they break, a thousand tests fail anyway
The framework Spring, Hibernate and Jackson already have their own tests
The standard library ArrayList.add works
Generated code (Lombok, MapStruct) The generator is already tested
Simple configuration Whether @Value injects is not your responsibility
Private implementation details Test the public behaviour; private things change
Constants assertThat(MAX_FINE).isEqualTo(20) only duplicates the code

The implementation-details case deserves an example, because it is the most expensive mistake:

// BAD: it tests HOW it is done. Refactoring breaks it even if the behaviour does not change.
@Test
void usesTheRepositoryToSearch() {
    service.findByIsbn(isbn);
    verify(repository).findByIsbn(isbn);       // what if tomorrow it uses a cache?
}

// GOOD: it tests WHAT it does. It survives any internal refactor.
@Test
void returnsTheMaterialWhenItExists() {
    when(repository.findByIsbn(isbn)).thenReturn(Optional.of(effectiveJava));

    Optional<Material> result = service.findByIsbn(isbn);

    assertThat(result).contains(effectiveJava);
}

  1. Flaky tests as debt

A flaky test fails for reasons that are not a real defect. And its cost is worse than it looks: it trains the team to ignore red.

Kind of flakiness Cause Solution
Time-dependent LocalDate.now() in the code Injectable Clock (10-05)
Order-dependent State shared between tests Isolate; @DirtiesContext as a last resort
Network-dependent Calls a real API WireMock, or a double
Machine-dependent Absolute paths, time zone @TempDir, fixed zone
Randomness-dependent Math.random(), UUID Fixed seed, injected generator
With fixed waits Thread.sleep(500) Awaitility with a condition
Over-specified verify on every call Verify only what is relevant

The two examples that show up most in practice:

// FLAKY: fails on 1 January, or if the test runs at midnight
@Test
void theLoanFallsDueInFifteenDays() {
    Loan loan = manager.lend(isbn, 1L, 15);
    assertThat(loan.getDueDate()).isEqualTo(LocalDate.now().plusDays(15));
}

// ROBUST: time is a dependency like any other
@Test
void theLoanFallsDueInFifteenDays() {
    var clock = Clock.fixed(Instant.parse("2026-08-05T10:00:00Z"), ZoneId.of("Europe/Madrid"));
    var manager = new LoanManager(repository, notices, clock);

    Loan loan = manager.lend(isbn, 1L, 15);

    assertThat(loan.getDueDate()).isEqualTo(LocalDate.of(2026, 8, 20));
}
// FLAKY: 500 ms may not be enough on a loaded machine, and is wasted on a fast one
@Test
void theAsyncImportFinishes() throws Exception {
    service.importAsync(file);
    Thread.sleep(500);
    assertThat(repository.count()).isEqualTo(100);
}

// ROBUST: wait for the CONDITION, not for a duration
@Test
void theAsyncImportFinishes() {
    service.importAsync(file);

    await().atMost(Duration.ofSeconds(5))
           .pollInterval(Duration.ofMillis(50))
           .untilAsserted(() -> assertThat(repository.count()).isEqualTo(100));
}

The rule for an intermittent test: fix it or delete it. Do not mark it @Disabled "temporarily", because that temporary lasts years and meanwhile it protects nothing.

  1. Performance and load

Note: performance testing. Picking up 10-07, JMH (Java Microbenchmark Harness) is the only reliable way to measure Java code, because it handles JIT warm-up, stops the compiler from eliminating code with no effects, and computes the variance. A System.nanoTime() around a loop measures, above all, the state of the JIT at that instant.

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
public class SearchBenchmark {

    private List<Material> catalog;

    @Setup public void setUp() { catalog = generateCatalog(50_000); }

    @Benchmark
    public List<Material> linearSearch() {
        return catalog.stream()
                .filter(m -> m.getTitle().toLowerCase().contains("java"))
                .toList();
    }

    @Benchmark
    public List<Material> indexedSearch() {
        return index.search("java");
    }
}

And load tests (k6, Gatling, JMeter) measure something quite different: how the whole system behaves with N concurrent users. What matters is the 95th and 99th latency percentiles, not the mean — the mean hides exactly the cases that annoy users — and the point at which errors start appearing. They run against a production-like environment, never in the CI of every PR.

// k6: 100 users for 5 minutes
export const options = {
  stages: [ { duration: '1m', target: 100 }, { duration: '3m', target: 100 },
            { duration: '1m', target: 0 } ],
  thresholds: { http_req_duration: ['p(95)<300'], http_req_failed: ['rate<0.01'] },
};
export default function () { http.get('http://localhost:8080/api/materials?page=0&size=20'); }

Note: contract testing. When two services integrate, contract tests (Pact, Spring Cloud Contract) verify that consumer and provider agree on the format without having to start them together. The consumer declares what it expects, the provider verifies that it complies. BiblioTech does not need it yet, but as soon as the mobile app consumes the API or BiblioTech depends on the human resources service, it becomes the cheapest way to stop a change breaking a third party without anybody noticing until production.

Common Mistakes and Tips

1. Chasing 100% coverage. The cost of going from 80% to 100% is enormous and the value minimal: the last 20% is usually handling of impossible errors and generated code. Use coverage to find gaps, not as a target.

2. Tests with no assertions. They cover, they do not verify. If a test would pass just the same with the code broken, it is not a test.

3. Turning coverage into an individual target. Goodhart's law: you will get thousands of tests that run code without checking anything.

4. Trusting H2 to test queries. H2 is not PostgreSQL. Queries are tested against the real database with Testcontainers, and only those.

5. Turning everything into @SpringBootTest. It is the easiest and the worst: the suite goes to twenty minutes, the diagnostics become vague and nobody runs it.

6. Ignoring intermittent tests. "Just run it again, it fails sometimes" is the beginning of the end. They get fixed or deleted.

7. Testing the implementation instead of the behaviour. verify(repository).findByIsbn(...) breaks with any legitimate refactor. Test results.

8. Switching on every static analysis rule on day one. Three thousand warnings nobody will look at. Start with the serious ones and grow.

9. Thousand-line Pull Requests. They get "LGTM" in two minutes. Slice the work up: 200-400 lines is the point where a review is useful.

10. CI that does not block. If the team can merge with the tests red, the tests cease to exist. Protect the branch.

11. Refactoring without tests. That is not refactoring. If there are no tests, first write characterisation tests that pin down the current behaviour (even if it is wrong), and only then change it.

12. Believing TDD is about testing. It is about design. If a class is hard to test, TDD tells you before you write it, not after.

A final tip: the best quality metric is not in any tool. It is the answer to this question: does the team deploy on a Friday afternoon without fear? If the answer is yes, the strategy works. If it is no, something needs fixing, and no coverage figure makes up for it.

Exercises

Exercise 1: improve some tests that lie

These tests exist in BiblioTech and have 100% line coverage over ReservationProcessor. Identify all of their problems and rewrite them.

class ReservationProcessorTest {

    ReservationProcessor processor = new ReservationProcessor(
            new InMemoryReservationRepository(), new FakeNotices());

    @Test
    void testReserve() {
        processor.reserve("978-0000000001", 1L);
    }

    @Test
    void testCancel() throws Exception {
        Reservation r = processor.reserve("978-0000000001", 1L);
        processor.cancel(r.getId());
        assertNotNull(r);
    }

    @Test
    void testExpiry() throws Exception {
        Reservation r = processor.reserve("978-0000000001", 1L);
        Thread.sleep(1000);
        processor.expireOverdue();
        assertTrue(true);
    }

    @Test
    void testDeadline() {
        Reservation r = processor.reserve("978-0000000001", 1L);
        assertEquals(LocalDate.now().plusDays(2), r.getDeadline());
    }
}

Exercise 2: TDD for a new rule

Nexus Software introduces priority loans: an employee with a project flagged as critical can jump the reservation queue for a material.

Rules:

  • Only if the employee has an active critical project.
  • At most one simultaneous priority loan per employee.
  • The material must be on loan (if it is free, it is a normal loan).
  • When the priority is used, the loan in progress is flagged for urgent return within 48 h.
  • The employee holding the material gets a notice.
  • The priority cannot be used if the material is already flagged as urgent.

Develop the feature with TDD, showing each red-green-refactor cycle. When you are done, run PIT mentally over your implementation and identify which mutants could survive.

Exercise 3: a complete CI pipeline

Write the GitHub Actions workflow for BiblioTech that:

  • Runs on PRs and on pushes to main.
  • Has a fast job (under 3 minutes) and a full one.
  • Runs the integration tests with Testcontainers.
  • Fails the PR if coverage of new code drops below 80%.
  • Posts a comment on the PR with the coverage summary and the failed tests.
  • Runs mutation testing only on Mondays.
  • Caches the Maven dependencies.
  • Runs the test matrix on Java 21 and Java 23 (to catch problems with the next LTS).

Solutions

Solution 1

Problems found (eleven):

# Test Problem Severity
1 testReserve No assertion at all: it only executes Critical
2 testCancel assertNotNull(r) does not check the cancellation Critical
3 testExpiry assertTrue(true) is a fake assertion Critical
4 testExpiry Thread.sleep(1000) is flaky and slow High
5 testDeadline LocalDate.now() in the test: fails at midnight High
6 All Names that do not describe the expected behaviour Medium
7 All State is shared between tests (stateful instance field) High
8 All No error case is tested High
9 testCancel Unnecessary throws Exception, hides what can fail Low
10 All No @DisplayName and no structure Low
11 All JUnit's assertNotNull/assertEquals instead of AssertJ Low

Rewrite:

@DisplayName("Reservation processor")
class ReservationProcessorTest {

    // FIXED clock: removes any dependency on the moment of execution
    private static final Instant NOW = Instant.parse("2026-08-05T10:00:00Z");
    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");

    private InMemoryReservationRepository repository;
    private RecordingNotices notices;
    private MutableClock clock;                  // advanceable clock, no Thread.sleep
    private ReservationProcessor processor;

    @BeforeEach
    void setUp() {
        // FRESH state for every test: no contamination between them
        repository = new InMemoryReservationRepository();
        notices = new RecordingNotices();
        clock = MutableClock.at(NOW, MADRID);
        processor = new ReservationProcessor(repository, notices, clock);
    }

    @Nested
    @DisplayName("When creating a reservation")
    class WhenReserving {

        @Test
        @DisplayName("it is left pending and in the material's queue")
        void isPendingAndInTheQueue() {
            Reservation reservation = processor.reserve(ISBN_JAVA, MARTA);

            assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.PENDING);
            assertThat(reservation.getMaterialIsbn()).isEqualTo(ISBN_JAVA);
            assertThat(reservation.getEmployeeId()).isEqualTo(MARTA);
            assertThat(reservation.getRequestDate()).isEqualTo(NOW);
            assertThat(repository.pendingFor(ISBN_JAVA)).containsExactly(reservation);
        }

        @Test
        @DisplayName("it respects the arrival order in the queue")
        void respectsTheArrivalOrder() {
            Reservation first = processor.reserve(ISBN_JAVA, MARTA);
            clock.advance(Duration.ofMinutes(5));
            Reservation second = processor.reserve(ISBN_JAVA, DIEGO);

            assertThat(repository.pendingFor(ISBN_JAVA))
                    .containsExactly(first, second);         // the order matters
        }

        @Test
        @DisplayName("it rejects a second reservation for the same employee and material")
        void rejectsDuplicateReservation() {
            processor.reserve(ISBN_JAVA, MARTA);

            assertThatThrownBy(() -> processor.reserve(ISBN_JAVA, MARTA))
                    .isInstanceOf(DuplicateReservationException.class)
                    .hasMessageContaining(ISBN_JAVA.value());

            assertThat(repository.pendingFor(ISBN_JAVA)).hasSize(1);
        }

        @Test
        @DisplayName("it rejects reserving a material that does not exist")
        void rejectsUnknownMaterial() {
            assertThatThrownBy(() -> processor.reserve(ISBN_UNKNOWN, MARTA))
                    .isInstanceOf(MaterialNotFoundException.class);
        }
    }

    @Nested
    @DisplayName("When cancelling")
    class WhenCancelling {

        @Test
        @DisplayName("the reservation moves to CANCELLED and leaves the queue")
        void movesToCancelledAndLeavesTheQueue() {
            Reservation reservation = processor.reserve(ISBN_JAVA, MARTA);

            processor.cancel(reservation.getId());

            assertThat(repository.byId(reservation.getId()))
                    .get()
                    .extracting(Reservation::getStatus)
                    .isEqualTo(ReservationStatus.CANCELLED);
            assertThat(repository.pendingFor(ISBN_JAVA)).isEmpty();
        }

        @Test
        @DisplayName("cancelling twice throws InvalidTransitionException")
        void cancellingTwiceFails() {
            Reservation reservation = processor.reserve(ISBN_JAVA, MARTA);
            processor.cancel(reservation.getId());

            assertThatThrownBy(() -> processor.cancel(reservation.getId()))
                    .isInstanceOf(InvalidTransitionException.class);
        }

        @Test
        @DisplayName("cancelling a reservation that does not exist throws ReservationNotFoundException")
        void unknownReservation() {
            assertThatThrownBy(() -> processor.cancel(9999L))
                    .isInstanceOf(ReservationNotFoundException.class);
        }
    }

    @Nested
    @DisplayName("Expiry")
    class Expiry {

        @Test
        @DisplayName("an available reservation expires after exactly 48 hours")
        void expiresAfter48Hours() {
            Reservation reservation = processor.reserve(ISBN_JAVA, MARTA);
            processor.assignCopy(reservation.getId());

            clock.advance(Duration.ofHours(48));         // no Thread.sleep!
            int expired = processor.expireOverdue();

            assertThat(expired).isEqualTo(1);
            assertThat(repository.byId(reservation.getId()))
                    .get().extracting(Reservation::getStatus)
                    .isEqualTo(ReservationStatus.EXPIRED);
        }

        @Test
        @DisplayName("at 47 hours and 59 minutes it has NOT expired yet")
        void doesNotExpireEarly() {
            Reservation reservation = processor.reserve(ISBN_JAVA, MARTA);
            processor.assignCopy(reservation.getId());

            clock.advance(Duration.ofHours(47).plusMinutes(59));
            int expired = processor.expireOverdue();

            assertThat(expired).isZero();                // THE BOUNDARY
            assertThat(repository.byId(reservation.getId()))
                    .get().extracting(Reservation::getStatus)
                    .isEqualTo(ReservationStatus.AVAILABLE);
        }

        @Test
        @DisplayName("pending reservations do not expire: they have no deadline")
        void pendingOnesDoNotExpire() {
            processor.reserve(ISBN_JAVA, MARTA);         // PENDING, no copy assigned

            clock.advance(Duration.ofDays(30));

            assertThat(processor.expireOverdue()).isZero();
        }

        @Test
        @DisplayName("on expiry the employee is notified and the next in the queue is activated")
        void notifiesAndActivatesTheNextOne() {
            Reservation martasOne = processor.reserve(ISBN_JAVA, MARTA);
            Reservation diegosOne = processor.reserve(ISBN_JAVA, DIEGO);
            processor.assignCopy(martasOne.getId());

            clock.advance(Duration.ofHours(48));
            processor.expireOverdue();

            assertThat(notices.sent())
                    .extracting(Notice::type)
                    .containsExactly(NoticeType.RESERVATION_EXPIRED, NoticeType.RESERVATION_AVAILABLE);
            assertThat(repository.byId(diegosOne.getId()))
                    .get().extracting(Reservation::getStatus)
                    .isEqualTo(ReservationStatus.AVAILABLE);
        }
    }

    @Nested
    @DisplayName("Collection deadline")
    class CollectionDeadline {

        @Test
        @DisplayName("it is set 48 hours from the copy assignment")
        void at48HoursFromTheAssignment() {
            Reservation reservation = processor.reserve(ISBN_JAVA, MARTA);
            clock.advance(Duration.ofDays(3));           // the assignment arrives days later
            processor.assignCopy(reservation.getId());

            // ABSOLUTE date computed from the fixed clock: never depends on "today"
            assertThat(repository.byId(reservation.getId()))
                    .get().extracting(Reservation::getCollectionDeadline)
                    .isEqualTo(NOW.plus(Duration.ofDays(3)).plus(Duration.ofHours(48)));
        }

        @Test
        @DisplayName("a pending reservation has no deadline")
        void pendingOnesHaveNoDeadline() {
            Reservation reservation = processor.reserve(ISBN_JAVA, MARTA);
            assertThat(reservation.getCollectionDeadline()).isNull();
        }
    }
}

The advanceable clock, which replaces every Thread.sleep:

/** Mutable Clock for tests: lets you move time forward without waiting. */
public class MutableClock extends Clock {

    private Instant current;
    private final ZoneId zone;

    private MutableClock(Instant current, ZoneId zone) {
        this.current = current;
        this.zone = zone;
    }

    public static MutableClock at(Instant current, ZoneId zone) {
        return new MutableClock(current, zone);
    }

    public void advance(Duration duration) { this.current = current.plus(duration); }

    @Override public Instant instant() { return current; }
    @Override public ZoneId getZone() { return zone; }
    @Override public Clock withZone(ZoneId z) { return new MutableClock(current, z); }
}

Result: from 4 tests that verified nothing to 14 that cover the happy path, errors, boundaries and side effects. Without a single Thread.sleep, with no dependency on the real date, with clean state in each one and with names that document the requirement. The suite went from taking over a second to taking milliseconds.

Solution 2

Cycle 1 — Red:

@Test
void anEmployeeWithoutACriticalProjectCannotUsePriority() {
    Employee diego = anEmployee("Diego Alonso").withoutCriticalProjects();

    assertThatThrownBy(() -> service.lendWithPriority(ISBN_JAVA, diego.getId()))
            .isInstanceOf(NoPriorityAvailableException.class)
            .hasMessageContaining("has no active critical project");
}

Cycle 1 — Green:

public Loan lendWithPriority(Isbn isbn, Long employeeId) {
    Employee employee = employees.findById(employeeId).orElseThrow(…);
    if (!employee.hasActiveCriticalProject()) {
        throw new NoPriorityAvailableException(
                "Employee %s has no active critical project."
                        .formatted(employee.getName()));
    }
    return null;   // the minimum for the test to pass
}

Cycle 2 — Red:

@Test
void ifTheMaterialIsFreeItIsANormalLoanAndNoPriorityIsSpent() {
    Employee marta = anEmployee("Marta Ruiz").withCriticalProject();
    materialAvailable(ISBN_JAVA);

    Loan loan = service.lendWithPriority(ISBN_JAVA, marta.getId());

    assertThat(loan.isPriority()).isFalse();
    assertThat(marta.prioritiesInUse()).isZero();        // the priority was NOT spent
}

Cycle 2 — Green:

public Loan lendWithPriority(Isbn isbn, Long employeeId) {
    Employee employee = …;
    checkHasCriticalProject(employee);

    Material material = materials.findByIsbn(isbn).orElseThrow(…);
    if (material.hasFreeCopies()) {
        return loanManager.lend(isbn, employeeId, null);          // normal loan
    }
    return null;
}

Cycle 3 — Red: the central case.

@Test
void flagsTheLoanInProgressForUrgentReturnWithinFortyEightHours() {
    Employee marta = anEmployee("Marta Ruiz").withCriticalProject();
    Loan inProgress = activeLoanOf(ISBN_JAVA, DIEGO);
    materialWithNoFreeCopies(ISBN_JAVA);

    service.lendWithPriority(ISBN_JAVA, marta.getId());

    assertThat(inProgress.isUrgent()).isTrue();
    assertThat(inProgress.getDueDate())
            .isEqualTo(LocalDate.now(clock).plusDays(2));
}

@Test
void notifiesTheEmployeeHoldingTheMaterial() {
    Employee marta = anEmployee("Marta Ruiz").withCriticalProject();
    activeLoanOf(ISBN_JAVA, DIEGO);
    materialWithNoFreeCopies(ISBN_JAVA);

    service.lendWithPriority(ISBN_JAVA, marta.getId());

    assertThat(notices.sent())
            .singleElement()
            .satisfies(n -> {
                assertThat(n.type()).isEqualTo(NoticeType.URGENT_RETURN);
                assertThat(n.recipient()).isEqualTo(DIEGO.getEmail());
            });
}

Cycle 3 — Green:

public Loan lendWithPriority(Isbn isbn, Long employeeId) {
    Employee employee = …;
    checkHasCriticalProject(employee);

    Material material = …;
    if (material.hasFreeCopies()) {
        return loanManager.lend(isbn, employeeId, null);
    }

    Loan inProgress = loans.activeFor(isbn).orElseThrow(…);
    inProgress.markUrgent(LocalDate.now(clock).plusDays(2));
    notices.send(Notice.urgentReturn(inProgress));

    return Loan.priorityWaiting(material, employee, LocalDate.now(clock));
}

Cycle 4 — Red: the two remaining constraints.

@Test
void anEmployeeCannotHoldTwoSimultaneousPriorities() {
    Employee marta = anEmployee("Marta Ruiz").withCriticalProject().withPriorityInUse();
    materialWithNoFreeCopies(ISBN_JAVA);

    assertThatThrownBy(() -> service.lendWithPriority(ISBN_JAVA, marta.getId()))
            .isInstanceOf(PriorityAlreadyInUseException.class);
}

@Test
void priorityCannotBeUsedOnAMaterialAlreadyFlaggedAsUrgent() {
    Employee marta = anEmployee("Marta Ruiz").withCriticalProject();
    activeLoanOf(ISBN_JAVA, DIEGO).alreadyMarkedUrgent();
    materialWithNoFreeCopies(ISBN_JAVA);

    assertThatThrownBy(() -> service.lendWithPriority(ISBN_JAVA, marta.getId()))
            .isInstanceOf(MaterialAlreadyUrgentException.class)
            .hasMessageContaining("already has an urgent return in progress");
}

Cycle 4 — Green and refactor. The method has grown; time to extract and apply the Strategy pattern with a chain of checks (12-02):

@Service
public class PriorityLoanService {

    private final List<PriorityCheck> checks;                   // Chain of Responsibility
    private final Clock clock;

    @Transactional
    public Loan lendWithPriority(Isbn isbn, Long employeeId) {
        PriorityContext ctx = buildContext(isbn, employeeId);

        // If the material is free, there is nothing to check: normal loan
        if (ctx.material().hasFreeCopies()) {
            return loanManager.lend(isbn, employeeId, null);
        }

        // Each check throws its own specific exception
        checks.forEach(c -> c.check(ctx));

        return activatePriority(ctx);
    }

    private Loan activatePriority(PriorityContext ctx) {
        LocalDate deadline = LocalDate.now(clock).plusDays(2);
        ctx.loanInProgress().markUrgent(deadline);
        ctx.employee().consumePriority();
        notices.send(Notice.urgentReturn(ctx.loanInProgress(), deadline));
        events.publish(new PriorityActivated(ctx.isbn(), ctx.employee().getId(), deadline));
        return Loan.priorityWaiting(ctx.material(), ctx.employee(), LocalDate.now(clock));
    }
}

@Component @Order(10)
class CheckCriticalProject implements PriorityCheck {
    public void check(PriorityContext ctx) {
        if (!ctx.employee().hasActiveCriticalProject()) {
            throw new NoPriorityAvailableException(ctx.employee().getName());
        }
    }
}

@Component @Order(20)
class CheckPriorityAvailable implements PriorityCheck {
    public void check(PriorityContext ctx) {
        if (ctx.employee().prioritiesInUse() >= 1) {
            throw new PriorityAlreadyInUseException(ctx.employee().getName());
        }
    }
}

@Component @Order(30)
class CheckMaterialNotUrgent implements PriorityCheck {
    public void check(PriorityContext ctx) {
        if (ctx.loanInProgress().isUrgent()) {
            throw new MaterialAlreadyUrgentException(ctx.isbn());
        }
    }
}

Mutants that could survive — the analysis the exercise asks for:

Mutation Does it survive? Missing test
prioritiesInUse() >= 1> 1 Yes An employee with exactly 1 priority must be rejected (already there)
plusDays(2)plusDays(3) Yes, if only isUrgent() is checked Assert the exact date, not only the flag
Remove consumePriority() Yes Verify that after using it, the second one fails
Remove events.publish(...) Yes Verify that the event is published
hasFreeCopies() → negated No Already covered by cycle 2

The tests that close those gaps:

@Test
void afterUsingThePriorityTheEmployeeCannotUseAnother() {
    Employee marta = anEmployee("Marta Ruiz").withCriticalProject();
    materialWithNoFreeCopies(ISBN_JAVA);
    materialWithNoFreeCopies(ISBN_PATTERNS);

    service.lendWithPriority(ISBN_JAVA, marta.getId());

    assertThatThrownBy(() -> service.lendWithPriority(ISBN_PATTERNS, marta.getId()))
            .isInstanceOf(PriorityAlreadyInUseException.class);
}

@Test
void publishesThePriorityActivatedEvent() {
    Employee marta = anEmployee("Marta Ruiz").withCriticalProject();
    activeLoanOf(ISBN_JAVA, DIEGO);
    materialWithNoFreeCopies(ISBN_JAVA);

    service.lendWithPriority(ISBN_JAVA, marta.getId());

    assertThat(events.published())
            .singleElement(as(InstanceOfAssertFactories.type(PriorityActivated.class)))
            .satisfies(e -> {
                assertThat(e.isbn()).isEqualTo(ISBN_JAVA);
                assertThat(e.deadline()).isEqualTo(LocalDate.of(2026, 8, 7));      // EXACT date
            });
}

Solution 3

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 4 * * 1'        # Mondays at 04:00 UTC: mutation testing

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read
  pull-requests: write         # needed to comment on the PR
  checks: write

jobs:

  # =====================================================================
  # 1. FAST — an answer in under 3 minutes
  # =====================================================================
  fast:
    name: Formatting and unit tests
    runs-on: ubuntu-latest
    timeout-minutes: 8

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: temurin
          cache: maven

      - name: Check formatting
        run: ./mvnw -B --no-transfer-progress spotless:check

      - name: Compile and unit tests
        run: ./mvnw -B --no-transfer-progress test

      - name: Publish results
        uses: mikepenz/action-junit-report@v4
        if: always()
        with:
          report_paths: '**/target/surefire-reports/TEST-*.xml'
          check_name: 'Unit tests'
          detailed_summary: true

  # =====================================================================
  # 2. MATRIX — Java 21 (production) and Java 23 (early warning)
  # =====================================================================
  compatibility:
    name: Java ${{ matrix.java }}
    runs-on: ubuntu-latest
    needs: fast
    timeout-minutes: 15

    strategy:
      fail-fast: false            # a failure on 23 must not cancel the 21 run
      matrix:
        java: ['21', '23']

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java }}
          distribution: temurin
          cache: maven

      - name: Unit tests
        run: ./mvnw -B --no-transfer-progress test
        # Java 23 is informational: it must not block the merge
        continue-on-error: ${{ matrix.java == '23' }}

  # =====================================================================
  # 3. FULL — integration with Testcontainers, coverage and analysis
  # =====================================================================
  full:
    name: Integration, coverage and analysis
    runs-on: ubuntu-latest
    needs: fast
    timeout-minutes: 25

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: temurin
          cache: maven

      # Pre-pull the image: stops the first test carrying the 30 s download
      - name: Pre-pull the PostgreSQL image
        run: docker pull postgres:16-alpine

      - name: Integration tests and coverage
        run: ./mvnw -B --no-transfer-progress verify
        env:
          TESTCONTAINERS_REUSE_ENABLE: 'false'      # in CI, clean containers

      - name: Comment the coverage on the PR
        uses: madrapps/[email protected]
        if: github.event_name == 'pull_request'
        with:
          paths: '**/target/site/jacoco/jacoco.xml'
          token: ${{ secrets.GITHUB_TOKEN }}
          min-coverage-overall: 75
          min-coverage-changed-files: 80
          title: '📊 Coverage'
          update-comment: true                      # updates instead of piling up comments
          pass-emoji: '✅'
          fail-emoji: '❌'

      - name: Check coverage thresholds
        run: ./mvnw -B jacoco:check

      - name: Static analysis (SpotBugs + FindSecBugs)
        run: ./mvnw -B spotbugs:check

      - name: Architecture rules (ArchUnit)
        run: ./mvnw -B test -Dtest='ArchitectureRulesTest'

      - name: Store reports
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: quality-reports
          path: |
            **/target/site/jacoco/
            **/target/spotbugsXml.xml
            **/target/failsafe-reports/
          retention-days: 14

      - name: Summary on the run tab
        if: always()
        run: |
          echo "## Quality summary" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "| Check | Result |" >> $GITHUB_STEP_SUMMARY
          echo "|---|---|" >> $GITHUB_STEP_SUMMARY
          echo "| Unit tests | ${{ needs.fast.result }} |" >> $GITHUB_STEP_SUMMARY
          echo "| Integration | ${{ job.status }} |" >> $GITHUB_STEP_SUMMARY

  # =====================================================================
  # 4. MUTATION — Mondays and main only. It is slow.
  # =====================================================================
  mutation:
    name: Mutation testing (PIT)
    runs-on: ubuntu-latest
    if: github.event_name == 'schedule' || github.ref == 'refs/heads/main'
    timeout-minutes: 40

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: temurin
          cache: maven

      - name: PIT over the domain
        run: |
          ./mvnw -B -pl bibliotech-domain \
            org.pitest:pitest-maven:mutationCoverage \
            -DmutationThreshold=70

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: mutation-report
          path: '**/target/pit-reports/'
          retention-days: 30

      - name: Open an issue if it drops below the threshold
        if: failure()
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: '⚠️ Domain mutation coverage dropped below 70%',
              body: 'Check the PIT report in the artifacts of run ' +
                    context.runId + '. There are surviving mutants: the assertions ' +
                    'in some test do not detect changes in the code.',
              labels: ['quality', 'testing']
            })

Pipeline design decisions, which is what the exercise assesses:

Decision Reason
Two jobs, fast and full 90% of failures are caught in 3 minutes
needs: fast on the full job Do not spend 25 minutes if the formatting is wrong
concurrency with cancel-in-progress A new push cancels the previous one: saves minutes and money
cache: maven Saves 1-2 minutes per run
fail-fast: false in the matrix A failure on Java 23 must not hide the result on 21
continue-on-error on Java 23 Informational: catches future problems without blocking today
if: always() on the publishing steps Reports matter above all when something fails
min-coverage-changed-files: 80 Clean as You Code: demand it of new code, not of the historical debt
Mutation on schedule It is slow; on every PR it would be unbearable
Automatic issue when mutation drops Nobody looks at reports; an issue does get seen
timeout-minutes everywhere A hung job must not eat the quota indefinitely
Explicit permissions Least privilege (12-07) in CI too

Conclusion

BiblioTech no longer just works: you can demonstrate that it works.

You have a real testing strategy, not a pile of tests: what gets verified at each level — domain, application, repository, web, end to end — with the rule that avoids duplication (every check at the lowest possible level) and with target times that determine whether the suite gets run or ignored: fifteen seconds locally, ten minutes in CI. And you understand why the pyramid inverts itself if nobody watches it, and that the root cause is almost never laziness but design: if testing a class in isolation is hard, the problem is the class.

You swapped H2 for Testcontainers with real PostgreSQL, knowing exactly where H2 lies — types, native functions, sequences, ordering, locking, constraints, time zones — and accepting the cost with your eyes open: five times slower on the 10% of the suite that benefits most from fidelity. With Spring Boot 3.1's @ServiceConnection, the shared container in a base class and the surefire/failsafe split that lets you run only the fast tests when that is what you need.

You measure coverage with JaCoCo, distinguishing lines from branches — and knowing that only the second tells the truth — with per-package thresholds, stricter in the domain. And above all you have the honest interpretation, which is what separates someone who uses the metric from someone who is fooled by it: high coverage does not guarantee quality, low coverage does signal risk; a test with no assertions gives 100% and is worth zero; and the day coverage becomes an individual target, it will stop measuring anything.

To find out whether your assertions are any use, you have mutation testing with PIT, with the concrete example of the surviving mutant in the fine calculation: days <= 0 turned into days < 0, the boundary of the due date itself, invisible to 100% coverage. With the cost accepted: domain only, weekly, a 70-80% threshold.

You know static analysis and what each tool finds — SpotBugs the real bugs, PMD the complexity, Checkstyle the style, SonarQube the history and the security, ArchUnit the architecture — with the adoption advice that stops the team ignoring it wholesale: start with the serious stuff and grow. And you can read cyclomatic complexity for what it is — the minimum number of tests needed — classify technical debt as prudent or reckless, and identify code smells with their remedies.

You refactor with a net: extract method, extract class and replace conditional with polymorphism, with the step-by-step procedure that gives seven chances to catch a mistake where doing it in one go gives zero. And you know that without tests it is not refactoring, it is rewriting and hoping.

You developed a complete feature with TDD — the surcharge for damaged material — step by step, including the steps that look absurd (always returning zero) and that are exactly what the discipline asks for. With the result in plain sight: zero untested code, boundaries covered from the start, an enum with state that came out of the refactor step and not the first implementation, and executable documentation. Plus the honest assessment of when it pays off and when it gets in the way, and the case where it is simply the best option available: fixing a bug.

You know how to review code — what to look at and in what order, how to give feedback that improves the code instead of creating resistance, and why a thousand-line PR gets "LGTM" while a two-hundred-line one gets useful comments — and you have BiblioTech's checklist. And you have continuous integration with GitHub Actions: a fast job and a full job, coverage commented on the PR, static analysis, scheduled mutation testing, a Java version matrix and the branch protection without which all of the above is a traffic light nobody is obliged to look at.

And you know what not to test — getters, the framework, the standard library, private implementation details — and how to recognise flaky tests as the debt they are, with their seven causes and their solutions: an injectable Clock instead of LocalDate.now(), Awaitility instead of Thread.sleep, isolation instead of shared state.

BiblioTech is tested, measured, analysed and verified on every change. And it still exists for nobody: it runs on Diego Alonso's laptop and on the GitHub Actions runner. Neither Marta Ruiz nor Nuria Vidal can open a browser and use it, because there is no server anywhere running it.

The next lesson puts it into production: layered jar packaging, containers with a multi-stage Dockerfile commented line by line, cgroup-aware JVM options, versioned database migrations with Flyway — because ddl-auto is no good in production — where to deploy and on what criteria, health probes and graceful shutdown, deployment strategies with rollback, and the complete continuous delivery pipeline that builds the image, publishes it and deploys it.

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