Old-School Backend / 2026 ・ No.14
Old-School Backend
Part 14: Tests — so the machine remembers what we fixed
Published
Over 13 parts we have fixed dozens of things in the claims application, and each fix was checked by requests the author sent by hand. If someone (or the author) quietly undoes one next month, would we notice?
This time we start by running the test that was there from the beginning. Then we write tests without Spring, tests of just the counter, tests on a real PostgreSQL and tests that press buttons at the same moment, and finally undo ten fixes on purpose to see whether the tests notice. We also use coverage and PIT, a tool that tests the tests.
There was one test
AraiWe've had tests all along. mvn test has always been green.
FuruiEver looked inside?
The 15-part route map (you are here: part 14)
# The test that came with the start.spring.io skeleton (all of it)
@SpringBootTest
class ExpensesApplicationTests {
@Test
void contextLoads() {
}
}
# ① mvn test somewhere that can't reach the practice DB
Tests run: 1, Failures: 0, Errors: 1 (5.502 s)
Caused by: java.net.UnknownHostException: db.internal
# ② Somewhere that can reach it
Tests run: 1, Failures: 0, Errors: 0 (7.805 s) BUILD SUCCESS
# ③ Again, after undoing three fixes from parts 10 and 11
Tests run: 1, Failures: 0, Errors: 0 (5.187 s) BUILD SUCCESS
and the jar was built expenses-0.0.1-SNAPSHOT.jar 34,810,862 B
The skeleton we made with start.spring.io in part 1 came with exactly one test. It is empty and checks only that the application starts. Starting needs the practice database, so anywhere the database can't be reached, it fails (①).
In ③ we put back three fixes as they were before: part 10's “guard the status with where”, and part 11's “only claims you may see” and “no approving your own”. The result is an application where you can read other people's claims and approve your own. The test stayed green, and the jar was built. Green also means “nothing was checked”. That is where this part begins.
- No Spring — the 30-case transition table, file headers, claim rules (measured: 0.5 s)
- Counter only — a stand-in clerk, looking only at the shape of replies (measured)
- A real database — PostgreSQL 16 in Docker; races, rights, files (measured)
- Checking the answers — ten fixes undone, coverage, PIT (measured)
The tools are what the Spring Boot skeleton brings — JUnit Jupiter 6.0.3, AssertJ, Mockito — plus Spring Security's test support and Testcontainers 2.0.5. The test names are in Japanese (JUnit shows Japanese method names as they are); the text gives their meaning in brackets.
The smallest tests
First, tests that start neither Spring nor the database. We copied part 10's transition table (6 states × 5 actions) as it is. With @ParameterizedTest and @CsvSource, each row of the table becomes one test.
/** Part 10's transition table copied as is: 6 states × 5 actions = 30 cases */
class ActionTest {
@ParameterizedTest(name = "{0} で {1} → {2}")
@CsvSource({
// from, action, to (- = not allowed)
"DRAFT, SUBMIT, SUBMITTED", "DRAFT, APPROVE, -", "DRAFT, RETURN, -", …
"SUBMITTED, SUBMIT, -", "SUBMITTED, APPROVE, APPROVED", "SUBMITTED, RETURN, RETURNED", …
…(6 行 × 5 列 = 30 通り)
})
void 状態遷移表のとおり(ClaimStatus from, Action action, String to) {
if (to.equals("-")) {
assertThat(action.allowedFrom(from)).as("%s should not allow %s", from, action).isFalse();
} else {
assertThat(action.allowedFrom(from)).as("%s should allow %s", from, action).isTrue();
assertThat(action.to()).isEqualTo(ClaimStatus.valueOf(to));
}
}
}
The same way, we wrote part 12's file header bytes (7 cases) and part 6's strict claim rules (13 cases).
# Three tests that don't start Spring (transition table, file header bytes, claim annotations) $ bash mvnt.sh test -Dtest='ActionTest,FileTypeTest,StrictExpenseRequestTest' Tests run: 42 0.198 s ActionTest Tests run: 7 0.355 s FileTypeTest Tests run: 13 0.933 s StrictExpenseRequestTest Tests run: 62, Failures: 0, Errors: 0 BUILD SUCCESS # The first run had one failure. Wrong was not the app but the author's assumption [10 桁] expected: "amount" but was: "amount,reasonGivenForLargeAmount"
62 tests in about 1.5 seconds. One failed on the first run, but what was wrong was the author, not the application. I expected a 10-digit amount to be refused only by @Digits, but a billion yen also trips “over 50,000 yen needs a reason”. Writing a test also tests how you remember the specification. With a reason added, it passed.
A specification decided as a table becomes a test as a table. One row, one test, so a failure names its row.
Starting only the counter
Next, the counter (the controller). @WebMvcTest starts only the web side: the counter and the handler that turns exceptions into replies (part 7's ApiErrors). The clerk (the service) is replaced by a stand-in with @MockitoBean, which answers from a script: “this claim is submitted”. We also load part 11's gatekeeper settings, so requests pass the same gates as in production.
/** Start only part 10's counter; the clerk (Service) is a stand-in that answers from a script */
@WebMvcTest(V10Controller.class)
@Import({ApiErrors.class, SecurityConfig.class})
class V10ControllerTest {
@Autowired MockMvcTester mvc;
@MockitoBean ClaimFlowService flow;
@MockitoBean NaiveFlowService naive;
@Test
void できない操作は409で今の状態とボタンを返す() {
given(flow.act(eq("EXP-2026-9001"), eq(Action.PAY), any()))
.willThrow(new IllegalTransitionException("EXP-2026-9001", ClaimStatus.SUBMITTED, Action.PAY));
MvcTestResult r = mvc.post().uri("/api/v10/claims/EXP-2026-9001/pay").header("X-User", "keiri").exchange();
assertThat(r).hasStatus(409);
assertThat(r).bodyJson().extractingPath("$.status").isEqualTo("submitted");
assertThat(r).bodyJson().extractingPath("$.actions").asArray().containsExactly("approve", "return", "reject");
}
@Test
void 思わぬ失敗は500で問い合わせ番号は頼みごとの番号() { // part 13
given(flow.find("EXP-2026-9001")).willThrow(new IllegalStateException("DB が止まった"));
MvcTestResult r = mvc.get().uri("/api/v10/claims/EXP-2026-9001").exchange();
assertThat(r).hasStatus(500);
String reqId = r.getResponse().getHeader("X-Request-Id");
assertThat(r).bodyJson().extractingPath("$.errorId").isEqualTo("ERR-" + reqId);
assertThat(r).bodyText().doesNotContain("DB が止まった");
}
}
# The counter-only test (7 cases), run three times ✓ 承認できたら200と次のボタン(approved → 200 and the next buttons) ✓ できない操作は409で今の状態とボタンを返す(not allowed → 409 with current status and buttons) ✓ 知らない操作名は400(unknown action name → 400) ✓ ない申請は404(unknown claim → 404) ✓ ないURLは500ではなく404(unknown URL → 404, not 500) ✓ 中身の変更で金額がマイナスなら400と項目(negative amount on edit → 400 and the field) ✓ 思わぬ失敗は500で問い合わせ番号は頼みごとの番号(unexpected failure → 500, enquiry no. = request no.) Spring start-up 0.82–0.85 s all 7 1.22–1.34 s
The request is not real HTTP; MockMvc hands it straight into Spring. MockMvcTester lets us check the reply in AssertJ style. Part 13's “unknown URL is 404” and “a 500's enquiry number is the request number” are checked here too. With no database, Spring starts in about 0.8 seconds.
But with a stand-in clerk, we learn nothing about whether the SQL is right. That's the next chapter.
On a real database
The first stumble in writing database tests was “how do we build a test database?”. The shape of the database wasn't written down anywhere in the source.
# The database shape in the app's source (src/main/resources/schema.sql) 1 table (expense) and 1 sequence, as of part 1 # What the practice DB really has 7 tables app_user budget expense expense_history expense_line receipt receipt_blob added by setup4.sql setup9.sql setup10.sql setup11.sql setup12.sql (run by hand in psql) # Take the current shape out for the tests $ pg_dump -s -O -x expenses > dump.sql # 315 lines → minus practice-only tables (clock, batch_test) and psql-only lines src/test/resources/schema-test.sql # 125 lines, 26 statements
Since part 4, tables were added by running each part's SQL by hand. The application's source holds only part 1's single table. On a new PC, the counters after part 4 would fail for want of tables and columns. For now we took the shape out of the practice database with pg_dump -s (structure only) as the test DDL. (The proper way is a tool like Flyway that keeps each change, in order, in the source.)
For the database, Testcontainers starts one PostgreSQL 16 in Docker. With @ServiceConnection, Spring Boot picks up where to connect by itself.
/** Start one PostgreSQL 16 in Docker for the tests; Spring picks up where to connect */
@TestConfiguration(proxyBeanMethods = false)
public class TestDb {
@Bean
@ServiceConnection
PostgreSQLContainer postgres() {
return new PostgreSQLContainer("postgres:16");
}
}
/** Marks a test that starts the whole app on a real PostgreSQL */
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestDb.class, Claims.class})
@ActiveProfiles("test")
public @interface DbTest {
}
# extra settings for tests only (Testcontainers fills in the DB address)
spring.sql.init.schema-locations=classpath:schema-test.sql
# Tests of the database's own rules (4 cases), run three times ✓ 金額0円はDBが断る(0 yen → the DB refuses) ✓ 打ち間違いの状態はDBが断る(misspelt status → the DB refuses) ✓ 一円未満はDBが黙って丸める(below 1 yen → the DB silently rounds) ✓ 同じ日同じ品目同じ金額は1件だけ(same day, item, amount → only once) # Time taken (Docker images already pulled) clean-up container (ryuk) 0.64–0.80 s PostgreSQL 16 container 1.67–1.81 s whole app start (incl. above) 9.3–10.1 s these 4 tests (incl. above) 11.5–12.2 s
PostgreSQL starts in under 2 seconds, but with the whole application it is about 10: the heaviest kind of test. Still, the database's CHECKs (parts 6 and 10) and the way it silently rounds fractions of a yen (part 6) can only be checked on the real database.
H2, which runs inside Java, is a popular test database. We fed it the same DDL.
# ① The same DDL (26 statements of schema-test.sql) on H2 2.4 in PostgreSQL mode
accepted 8 rejected 18
CREATE TABLE public.expense (… CHECK (status = ANY ((ARRAY['draft', …])::text[])) …)
Syntax error in SQL statement "… [*]])))"
→ no expense table, so its index and foreign keys fail too
# ② With a hand-written H2 table, the statements tried agree
H2 2.4.240 PostgreSQL 16.15
amount = 1280.125 1280.13 1280.13
status = 'aproved' CHECK refuses CHECK refuses
amount = 1280.124 1280.12 1280.12
where item = '交通費 '0 0
select 7 / 2 3 3
18 of the 26 PostgreSQL statements failed on H2. Rewrite the tables for H2 and the statements we tried give the same answers. But that means keeping a second, hand-made database shape that isn't production's. In this part we use the same PostgreSQL 16 as production.
Pressing at the same moment
The fixes in parts 9 and 10 were all about “what if they arrive together”. A test that sends one request after another doesn't fail even with the where removed (chapter 8 checks this for real). So we made a helper: prepare n threads, wait until all are ready, then start them on a signal.
/** Start n jobs at once on a signal; return how many succeeded */
public static int run(int n, Job job) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(n);
CountDownLatch go = new CountDownLatch(1);
List<Future<Boolean>> results = new ArrayList<>();
for (int i = 0; i < n; i++) {
int k = i;
results.add(pool.submit(() -> {
go.await(); // wait until everyone is ready
try {
job.run(k);
return true;
} catch (RuntimeException e) {
return false; // a failure like a 409
}
}));
}
go.countDown(); // ready, go
…
}
/** Part 10: approve and return pressed together, only one gets through */
@Test
void 承認50件と差戻し50件を同時に押しても通るのは1件() throws Exception {
String id = claims.create("新井", "submitted");
int ok = Race.run(100, i -> flow.act(id, i % 2 == 0 ? Action.APPROVE : Action.RETURN, "furui"));
assertThat(ok).isEqualTo(1);
assertThat(claims.historyRows(id)).isEqualTo(1);
}
How often does this test catch a broken version? Against part 10's naive version without the where guard, we ran it round after round and counted.
# Approve and return sent together to part 10's naive version (no where guard); two or more successes = caught jobs at once with signal no signal guarded version 2 145 / 150 134 / 150 0 / 60 10 144 / 150 136 / 150 0 / 60 100 150 / 150 105 / 150 0 / 60 (3 runs × 50 rounds; guarded 3 × 20. Numbers = rounds with two or more successes)
How often the broken version was caught (measured)
Lined up on a signal, 100 at once caught it in all 150 rounds. Without the signal, starting each thread as soon as it is made, even 100 at once missed it 30% of the time. With 2 at once, even with the signal, 5 of 150 rounds missed it. A race test can pass on broken code. Don't trust one green run: line threads up and use plenty. The guarded version let exactly one through every round.
Who may do what
Part 11's “3 people × 5 actions” table became a test as it is. Logging in uses Spring Security's test helper user(…), “pretend this person is logged in”, and csrf() adds the token for writes. The gatekeeper (SecurityConfig), the clerk and the database are all real.
/** Part 11: who may do what to which claim */
@ParameterizedTest(name = "{0} が {1} → {2}")
@CsvSource({
"arai, submit, 200", "arai, approve, 403", "arai, return, 403", "arai, reject, 403", "arai, pay, 403",
"furui, submit, 404", "furui, approve, 200", "furui, return, 200", "furui, reject, 200", "furui, pay, 403",
"keiri, submit, 404", "keiri, approve, 403", "keiri, return, 403", "keiri, reject, 403", "keiri, pay, 200",
})
void 役割ごとにできる操作(String who, String action, int expected) {
String id = claims.create("新井", READY.get(action)); // make an Arai claim in a state where the action is allowed, then press
assertThat(mvc.post().uri("/api/v11/claims/{id}/{a}", id, action).with(user(USERS.get(who))).with(csrf()))
.hasStatus(expected);
}
@Test
void 他人の申請は存在しないのと同じ404() { // IDOR
String furuis = claims.create("古井", "submitted");
assertThat(mvc.get().uri("/api/v11/claims/{id}", furuis).with(user(USERS.get("arai")))).hasStatus(404);
}
# Tests of part 11's gatekeeper and clerk (21 cases) ✓ 役割ごとにできる操作(what each role may do) × 15 ✓ 他人の申請は存在しないのと同じ404(someone else's claim = not found, 404) ✓ 自分の申請は承認できない(no approving your own claim) ✓ ヘッダーで名乗っても無視される(a name in a header is ignored) ✓ ログインしていなければ401(not logged in → 401) ✓ 合言葉がなければ403(no CSRF token → 403) ✓ パスワードちがいといない利用者は同じ401(wrong password and unknown user: same 401) all 21 0.66–0.75 s (app and DB already started by an earlier test)
The app and database were already started by an earlier test, so 21 tests take under a second. Spring's test support reuses a start-up with the same settings. There are 10 test classes, but Spring started only twice (with the database, and counter only).
For the password tests there is a single user stored with {noop} (a marker for “plain text”). We don't use real bcrypt hashes in tests (as measured in part 11, each one costs about 80 ms).
Clocks and leftovers
Part 6's “today” experiment only worked between midnight and 9 a.m. in Tokyo, because on a UTC JVM Tokyo's today is still “the future”. In a test, we stop the clock. Hibernate Validator has a hook (ClockProvider) for the clock its rules use.
static Validator validator(Clock clock) {
return Validation.byProvider(HibernateValidator.class).configure()
.clockProvider(() -> clock).buildValidatorFactory().getValidator();
}
// Part 6's “today”: 00:30 on 31 Oct in Tokyo is still 15:30 on 30 Oct in UTC
static final Instant TOKYO_0030 = Instant.parse("2026-10-30T15:30:00Z");
@Test
void 東京の今日の申請は東京の時計なら通る() {
Validator v = validator(Clock.fixed(TOKYO_0030, ZoneId.of("Asia/Tokyo")));
assertThat(broken(v, new StrictExpenseRequest(LocalDate.of(2026, 10, 31), "交通費", BigDecimal.ONE, null))).isEmpty();
}
@Test
void UTCの時計だと東京の今日が未来になる() {
Validator v = validator(Clock.fixed(TOKYO_0030, ZoneId.of("UTC")));
assertThat(broken(v, new StrictExpenseRequest(LocalDate.of(2026, 10, 31), "交通費", BigDecimal.ONE, null))).isEqualTo("date");
}
With the clock stopped at 00:30 on 31 October in Tokyo, the same claim passes on a Tokyo clock and is refused as a future date on a UTC clock. These two tests give the same results day or night, in any country. A test that depends on the current time will fail one day.
Something else lives outside the tests: part 12's files.
# Part 12's tests (6 cases) ✓ 置くときの名前は自分で付ける(we name the stored file ourselves) ✓ 中身が画像でなければ415(not an image inside → 415) ✓ 同じ領収書は2度目が409(same receipt twice → 409) ✓ 他人の申請には付けられない(can't attach to someone else's claim) ✓ 他人の領収書は返さない(won't return someone else's receipt) ✓ DBに書けなかったらファイルも残さない(no DB row, no file left behind) all 6 0.68–0.82 s # After the chapter 8 run with a fix undone (R5), the same test failed five runs in a row ReceiptRulesTest.置くときの名前は自分で付けるR6 R7 R8 R9 R10 target/test-store/written-outside.jpg ← still there the broken version had written a file outside the store, and it was still there
When chapter 8's experiment undid a part 12 fix, the broken version did as the name said and wrote a file outside the store. After the fix was restored the file was still there, and the next five runs failed with a correct application. A test must not depend on what an earlier run left behind. The test now deletes it before starting. The database is the same: each test here creates its own new claim numbered TST-… and never touches another test's claims.
Undoing the fixes
AraiAll 107 green!
FuruiSo was chapter 1's single test. Let's break things and see.
We picked ten fixes from parts 6 to 13, put each back the way it was before the fix, one at a time, and ran all 107 tests (a script does the undoing and the restoring).
| The fix | What we put back | Tests that failed (count), and the message |
|---|---|---|
| Part 6: strip surrounding spaces | item = item.strip() → (—) | StrictExpenseRequestTest (1) expected: "交通費" but was: " 交通費 " |
| Part 9: don't spend what isn't there | and remaining >= #{amount} → (—) | BudgetRaceTest (1) expected: 100 but was: 150 |
| Part 10: guard the status with where | where id = #{id} and status = #{from} → where id = #{id} | ClaimRaceTest (1) expected: 1 but was: 3 |
| Part 10: resubmit after a return | SUBMIT(SUBMITTED, DRAFT, RETURNED) → SUBMIT(SUBMITTED, DRAFT) | ActionTest (2) expected: "submit" but was: "" |
| Part 11: only claims you may see | <if test="!seeAll">and applicant = #{name}</if> → (—) | AccessRulesTest, ReceiptRulesTest (3) [HTTP status code] expected: 404 but was: 200 |
| Part 11: no approving your own | if (… APPROVE && applicant == me) throw SelfApproval… → (—) | AccessRulesTest (1) [HTTP status code] expected: 403 but was: 200 |
| Part 11: only approvers approve | .hasRole("APPROVER") → .authenticated() | AccessRulesTest (5) [HTTP status code] expected: 403 but was: 200 |
| Part 12: we name stored files | dir.resolve(id) → dir.resolve(file.getOriginalFilename()) | ReceiptRulesTest (2) [HTTP status code] expected: 200 but was: 500 |
| Part 12: judge by the content | if (type == null) throw … 415 → type = file.getContentType() | ReceiptRulesTest (1) [HTTP status code] expected: 415 but was: 200 |
| Part 13: unknown URL is 404 | @ExceptionHandler(NoResourceFoundException.class) → (—) | V10ControllerTest (1) [HTTP status code] expected: 404 but was: 500 |
For all ten, at least one test failed, and the messages say plainly what happened, such as “expected 404 but was 200”. Removing part 10's where was caught only by the race test (five tries, five catches). In one-at-a-time tests the transition-table check refuses first, so the missing where does no harm there.
The interesting one was removing part 11's “only approvers approve”. Of the six tests that should have failed, the one where Arai approves didn't: the test claim belongs to Arai, so “no approving your own” returned 403 instead. With two guards, removing one can leave the tests green. Each guard needs its own row that checks it alone.
Once a test is written, break the code on purpose once and watch it fail. A test that never fails protects nothing.
Coverage, and testing the tests
JaCoCo measures how far the tests reach: while they run, it marks every line and branch that was executed.
# JaCoCo 0.8.14 (lines and branches run by the 107 tests) package lines branches parts 1–8 (many naive demo counters) 71/172 41.3% 10/30 33.3% part 9 (budget, versions) 26/89 29.2% 4/32 12.5% part 10 (state transitions) 51/82 62.2% 5/16 31.2% part 11 (authn, authz) 48/72 66.7% 16/22 72.7% part 12 (files) 59/71 83.1% 28/38 73.7% part 13 (request numbers) 19/19 100.0% 5/8 62.5% total 274/505 54.3% 68/146 46.6% # Some classes Action 13/13 SecureClaimService 12/13 ReceiptService 31/33 FileType 4/4 NaiveFlowService 3/15 SeedUsers 0/17
54% overall. That looks low, but the naive versions of parts 1–9 (broken on purpose) and the experiment counters are not things the tests should protect. Of the classes we do want to protect, Action, SecureClaimService and ReceiptService are above 90%. Coverage is a map of what hasn't been checked, not a score.
Running a line, though, is not checking it. PIT changes the code in small ways on purpose (>= to >, true to false and so on), runs the tests after each change, and sees whether they fail. It is chapter 8 done dozens of times by a machine.
# PIT 1.20.4: small deliberate changes to the four classes the Spring-free tests cover mutants 42 killed 38 (90%) survived 3 no coverage 1 SURVIVED FileType.detect line 9 h.length >= 3 → h.length > 3 SURVIVED FileType.detect line 11 h.length >= 5 → h.length > 5 SURVIVED StrictExpenseRequest line 27 amount.compareTo(50000) <= 0 → < 0 # Three boundary rows added "JPEG の印ちょうど3バイト| ffd8ff| image/jpeg" "%PDF- ちょうど5バイト| 255044462d | application/pdf" "ちょうど 5 万円で理由なし| 出張 | 50000 | '' | ''" mutants 42 killed 41 (98%) survived 0 no coverage 1
A surviving mutant (measured)
In the four classes the Spring-free tests cover, 3 of 42 mutants went unnoticed by every test. All three sit on edges: a JPEG of exactly 3 bytes, a PDF of exactly 5, exactly 50,000 yen. FileType had 100% line coverage in JaCoCo, yet its edges had never been tried. With three edge rows added, none survived (the one left is a small method no test calls). A PIT run took about 15 seconds; including the tests that start Spring would take many times longer, so tables and calculations are a good place to start.
Speed, and when to run what
# 107 tests, three runs: counts and times by kind (from Maven's output) kind tests time no Spring 65 0.48–0.54 s counter only (@WebMvcTest) 7 1.22–1.34 s whole app + real DB 35 14.0–14.8 s (start-up 9.3–10.1 s) all of Maven (incl. compiling) 107 20.6–23.6 s Spring contexts started 2 (10 test classes; ones with the same setup share a context)
Three kinds of test
The 65 Spring-free tests take 0.5 seconds together; the 35 database tests take 14 (10 of them start-up). Many fast tests, slow ones where it matters. Run the Spring-free tests on every save, and everything before a push or in CI (the automatic check that runs whenever code enters the shared repository), and the waiting stops mattering. If Docker is available in CI, the same mvn test runs the real-PostgreSQL tests there too.
| To try | How |
|---|---|
| Run every test | ./mvnw test |
| Run one class | ./mvnw test -Dtest=ActionTest |
| Coverage (target/site/jacoco/index.html) | jacoco-maven-plugin with prepare-agent and report |
| PIT (narrow the classes and tests) | ./mvnw org.pitest:pitest-maven:mutationCoverage -DtargetClasses=… -DtargetTests=… |
| Real-database tests | start Docker first (Testcontainers starts PostgreSQL) |
Pick the fix in your own application that scares you most, write one test for it, then undo the fix and watch the test fail. That is this whole part.
§ — On one page, and next time
- The skeleton's test only checks that the app starts; it stayed green with three fixes undone.
- A specification decided as a table becomes a test as a table. Without Spring, 65 tests take 0.5 s.
- Counter-only tests check the shape of replies; real-database tests check SQL, CHECKs and rounding. Keep the database's shape in the source.
- Race tests: line threads up on a signal and use plenty. Even then they can miss.
- Stop the clock, and clear leftovers before starting.
- Break the code on purpose and watch the test fail; all ten undone fixes were caught. Coverage is a map; PIT finds the missing edges.
AraiNow it's safe whoever edits it next month.
FuruiWherever these tests run, yes. Next time is the last part: running this claims application somewhere other than your own PC. Docker, nginx, how many users it can take, and virtual threads.
References
- Spring Boot — Testing Spring Boot Applications
- Spring Boot — Testcontainers
- Spring Framework — MockMvc AssertJ Integration(MockMvcTester)
- Spring Security — Testing with CSRF Protection
- JUnit User Guide — Parameterized Classes and Tests
- Testcontainers for Java — Postgres Module
- PIT — Maven Quick Start
- JaCoCo — Java Code Coverage Library
The recordings were made on 31 October 2026 on a cloud Linux machine (Ubuntu 24.04, kernel 6.18) with Spring Boot 4.1.2 (Spring Framework 7.0.10, Spring Security 7.1.2, MyBatis 3.5.19), JUnit Jupiter 6.0.3, Testcontainers 2.0.5 (Docker 29.9.2, postgres:16), JaCoCo 0.8.14, PIT 1.20.4, H2 2.4.240, OpenJDK 25.0.4 and PostgreSQL 16.15. Times are read from Maven's output and given as the range over three runs of all 107 tests (Docker images already pulled). The round counts of the race tests were measured at the time as well. In the undo experiment, a script changed the code, ran the tests and restored it. Every number and output in the text is a real value read from the commands' output at the time. The characters are fictional.
Old-School Backend, part 14 — Tests / 2026.10