Old-School Backend / 2026 ・ No.14

Old-School Backend

Part 14: Tests — so the machine remembers what we fixed

Published

A terminal. bash mvnt.sh test: DbRulesTest 4 tests 12.21 s, BudgetRaceTest 2, FileTypeTest 9, ReceiptRulesTest 6, AccessRulesTest 21, ExpensesApplicationTests 1, StrictExpenseRequestTest 14, ActionTest 42, ClaimRaceTest 1, V10ControllerTest 7. In total Tests run: 107, Failures: 0, Errors: 0, BUILD SUCCESS, Total time 21.321 s
Running every test: 107 tests in 10 classes, about 21 seconds. The slowest part is starting the database at the beginning (Captured by the author on a cloud Linux machine on 31 October 2026. The bar across the top of the window was added for the illustration.)

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.

1

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)

1 Big picture 2 Java basics 3 Raw HTTP 4 Raw JDBC 5 Spring Boot 6 Validation 7 Errors 8 DB access 9 Transactions 10 Workflow 11 Auth 12 Files 13 Logging 14 Testing 15 Running it You are here Basics By hand Front door Data Rules & safety Operations
# 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.

2

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.

POINT

A specification decided as a table becomes a test as a table. One row, one test, so a failure names its row.

3

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.

4

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.

5

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)

How often the broken version was caught (measured, 150 rounds each) 0% 50% 100% 97% 89% 0% 2 at once 96% 91% 0% 10 at once 100% 70% 0% 100 at once with start signal no signal guarded version A race test can pass on broken code: line threads up, and use many The guarded version let exactly one through in all 3 × 20 rounds

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.

6

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).

7

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.

8

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).

A terminal. python3 revert14.py R2 R3. Undoing R2 (part 11: only claims you may see) makes 3 of 107 tests fail, each with expected: 404 but was: 200. Undoing R3 (no approving your own) makes AccessRulesTest.自分の申請は承認できない fail with expected: 403 but was: 200
Undoing part 11's fixes one at a time and running every test. When others' claims become visible, three tests fail at once; when you can approve your own, one does (Captured by the author on a cloud Linux machine on 31 October 2026. The bar across the top of the window was added for the illustration.)
The fixWhat we put backTests that failed (count), and the message
Part 6: strip surrounding spacesitem = item.strip() → (—)StrictExpenseRequestTest (1)
expected: "交通費" but was: " 交通費 "
Part 9: don't spend what isn't thereand remaining >= #{amount} → (—)BudgetRaceTest (1)
expected: 100 but was: 150
Part 10: guard the status with wherewhere id = #{id} and status = #{from} → where id = #{id}ClaimRaceTest (1)
expected: 1 but was: 3
Part 10: resubmit after a returnSUBMIT(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 ownif (… 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 filesdir.resolve(id) → dir.resolve(file.getOriginalFilename())ReceiptRulesTest (2)
[HTTP status code] expected: 200 but was: 500
Part 12: judge by the contentif (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.

POINT

Once a test is written, break the code on purpose once and watch it fail. A test that never fails protects nothing.

9

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.

A PIT report. Lines 9 and 11 of FileType.java are red. Of the 9 mutants on line 9, number 7, changed conditional boundary, SURVIVED; of the 8 on line 11, number 3, changed conditional boundary, SURVIVED. All others were KILLED
The PIT report before the boundary rows were added. Where >= could become > without any test failing, the line stays red (Captured by the author on a cloud Linux machine on 31 October 2026. The bar across the top of the window was added for the illustration.)
# 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)

A surviving mutant (FileType, line 9) h.length >= 3 && FF D8 FF … h.length > 3 && FF D8 FF … original code code as PIT changed it JPEG, 10 bytes image/jpeg image/jpeg same → missed ffd8 (2 bytes) null null same → missed ffd8ff (3 bytes) image/jpeg null differs → caught Until the 3-byte row was added, no test checked this edge at all Running a line is not checking it. Put one case on each edge JaCoCo had FileType at 4/4 lines (100%)

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.

10

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

Three kinds of test (what runs for real) gate, counter clerk storekeeper PostgreSQL no Spring not started call a class 65 tests 0.48–0.54 s copied tables edges, rules counter only real stand-in not started not started 7 tests 1.22–1.34 s start 0.82–0.85 s status codes reply shape whole app + DB real real real real 35 tests 14.0–14.8 s start 9.3–10.1 s SQL, races, rights files Further right is more real but slower: many fast tests, few slow ones Measured over three runs of all 107 tests (images already pulled)

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 tryHow
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 testsstart 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

  1. The skeleton's test only checks that the app starts; it stayed green with three fixes undone.
  2. A specification decided as a table becomes a test as a table. Without Spring, 65 tests take 0.5 s.
  3. Counter-only tests check the shape of replies; real-database tests check SQL, CHECKs and rounding. Keep the database's shape in the source.
  4. Race tests: line threads up on a signal and use plenty. Even then they can miss.
  5. Stop the clock, and clear leftovers before starting.
  6. 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

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

← Back to all posts