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
- What "quality" means in a software project
- BiblioTech's testing strategy
- The test pyramid and why it inverts itself
- Target times per level
- Testcontainers: why H2 lies
- Real PostgreSQL from the test
- Separating unit and integration tests in Maven
- Coverage with JaCoCo
- The honest interpretation of coverage
- Mutation testing with PIT
- Static analysis: SpotBugs, PMD, Checkstyle, SonarQube
- Automatic formatting with Spotless
- Complexity, technical debt and code smells
- Safe refactoring
- TDD: the red-green-refactor cycle
- TDD step by step: surcharge for damaged material
- When TDD pays off and when it does not
- Code review
- Continuous integration with GitHub Actions
- What NOT to test
- Flaky tests as debt
- Performance and load
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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?
- 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.
- 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).
- 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 -10And 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);
}
- 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>
- 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=trueAnd the single-container pattern, already applied above with the static in the base class: without it, every test class starts its own PostgreSQL.
- 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 onesOn top of that, JUnit 5's @Tag allows cross-cutting slices:
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.0With 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.
- 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.htmlLine 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.
- 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:
- As a gap detector, not as a target. Open the report and look for important logic in red. That is a to-do list.
- With per-package thresholds, not global ones. Demanding 90% in the domain and 60% in infrastructure makes sense; demanding 80% overall rewards testing getters.
- 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.
- 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.
- 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 x → return 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>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.
- 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", …); // GOODSonarQube / 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_TOKENIts 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.
- 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.
- 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.mdThe 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 |
- 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:
- Add the abstract method and its implementations, without deleting the
switch. - Make the
switchdelegate:return m.defaultLoanDays(); - Run the tests. Green.
- Replace the calls to the old method with direct calls.
- Run the tests. Green.
- Delete the old method.
- Run the tests. Commit.
Seven steps, seven chances to catch a mistake. Doing it in one go gives you zero.
- 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:
- Do not write production code except to make a failing test pass.
- Do not write more of a test than is needed to fail (not compiling counts as failing).
- 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.
- 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.*What this process has produced, and it is worth pointing out:
- Zero untested code. Every line exists because a test demanded it.
- A better design. The
enumwith state did not come out of the first implementation: it came out of the refactor step, which TDD makes safe. - The boundaries covered from the start. The material-value cap was thought through while writing the test, not on receiving a bug report.
- Executable documentation. The test names are the specification of the requirement.
- 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:
- Write a test that reproduces the bug. It must fail.
- Fix the code. The test passes.
- That test stays forever, and the bug cannot come back without somebody noticing.
- 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"?
- 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.
- 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);
}
- 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.
- 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
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
