Old-School Backend / 2026 ・ No.14

いにしえのバックエンド入門

第14回 テスト ── 直したことを、機械に覚えておいてもらう

公開

端末の画面。bash mvnt.sh test の結果。DbRulesTest 4 件 12.21 s、BudgetRaceTest 2 件、FileTypeTest 9 件、ReceiptRulesTest 6 件、AccessRulesTest 21 件、ExpensesApplicationTests 1 件、StrictExpenseRequestTest 14 件、ActionTest 42 件、ClaimRaceTest 1 件、V10ControllerTest 7 件。合計 Tests run: 107, Failures: 0, Errors: 0、BUILD SUCCESS、Total time 21.321 s
テストを全部動かしました。10 のクラスで 107 件、約 21 秒。いちばん時間がかかるのは、最初に DB を立てるところです(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)

この連載では、13 回かけて申請のアプリを何十か所も直してきました。確かめたのは、そのたびに筆者が手で送ったリクエストです。来月だれかが(あるいは筆者が)うっかり元に戻したら、気づけるでしょうか。
今回は、最初から入っていたテストを動かすところから始めます。Spring を起動しないテスト、窓口だけのテスト、本物の PostgreSQL を使うテスト、同時に押すテストを書き、最後に直したところを10か所わざと元に戻して、テストが気づくかを確かめます。網羅率と、PIT というテストを試す道具も使います。

一

テストは1件だけあった

新井テストなら、最初からありますよ。mvn test は、ずっと緑でした。

古井中身を見たことは?

全15回の路線図(いまここ:第14回)

1 全体図 2 Java の準備 3 素の HTTP 4 素の JDBC 5 Spring Boot 6 入力チェック 7 例外と返事 8 DB アクセス 9 トランザクション 10 状態遷移 11 認証・認可 12 ファイル 13 ログと監視 14 テスト 15 動かす いまここ 基礎 素手で 受け口 データ 業務と守り 運用
# start.spring.io の雛形に、最初から入っていたテスト(全部)
@SpringBootTest
class ExpensesApplicationTests {
    @Test
    void contextLoads() {
    }
}
# ① 練習用 DB に届かない場所で mvn test
  Tests run: 1, Failures: 0, Errors: 1   (5.502 s)
  Caused by: java.net.UnknownHostException: db.internal
# ② 練習用 DB に届く場所で
  Tests run: 1, Failures: 0, Errors: 0   (7.805 s)   BUILD SUCCESS
# ③ 第10回・第11回の直しを3つ元に戻してから、もう一度
  Tests run: 1, Failures: 0, Errors: 0   (5.187 s)   BUILD SUCCESS
  jar もできたexpenses-0.0.1-SNAPSHOT.jar  34,810,862 B

第1回で start.spring.io から作った雛形には、テストが1件だけ入っていました。中身は空で、確かめているのは「アプリが起動するか」だけです。しかも起動には練習用 DB が要るので、DB に届かない場所では落ちます(①)。

③では、第10回の「状態は where で守る」、第11回の「見てよい申請だけ」「自分の申請は承認しない」の3つを、直す前の書き方に戻しました。他人の申請が見え、自分で承認できるアプリです。それでもテストは緑で、jar もできました。緑は「何も確かめていない」ことでも出る。この回は、ここから始めます。

  • Spring なし ── 状態遷移表 30 通り、ファイルの先頭、申請の印(実測 0.5 秒)
  • 窓口だけ ── 担当者は偽物にして、返事の形だけを見る(実測)
  • 本物の DB ── Docker で PostgreSQL 16 を立てる。同時に押す、権限、ファイル(実測)
  • 答え合わせ ── 直しを10か所元に戻す、網羅率、PIT(実測)

道具は、Spring Boot の雛形に入っている JUnit Jupiter 6.0.3、AssertJ、Mockito に、Spring Security のテスト用の部品と Testcontainers 2.0.5 を足しました。テストの名前は日本語にしています(JUnit は、日本語のメソッド名もそのまま結果に出します)。

二

いちばん小さいテスト

最初は、Spring も DB も起動しないテストです。第10回の状態遷移表(6 つの状態 × 5 つの操作)を、そのまま書き写しました。@ParameterizedTest と @CsvSource を使うと、表の1行が1件のテストになります。

/** 第10回の状態遷移表を、6 つの状態 × 5 つの操作 = 30 通り、そのまま書き写す */
class ActionTest {
    @ParameterizedTest(name = "{0} で {1} → {2}")
    @CsvSource({
        // 今の状態,  操作,     行き先(- はできない)
        "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 から %s はできないはず", from, action).isFalse();
        } else {
            assertThat(action.allowedFrom(from)).as("%s から %s はできるはず", from, action).isTrue();
            assertThat(action.to()).isEqualTo(ClaimStatus.valueOf(to));
        }
    }
}

同じやり方で、第12回のファイルの先頭のバイト(7 通り)と、第6回のきびしい印(13 通り)も書きました。

# Spring を起動しない3つのテスト(状態遷移表・ファイルの先頭のバイト・申請の印)
$ 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
# 1回目は1件落ちた。落ちたのはアプリではなく、筆者の思い込み
  [10 桁]
  expected: "amount"
   but was: "amount,reasonGivenForLargeAmount"

62 件で 1.5 秒ほどです。1回目に1件落ちましたが、落ちたのはアプリではなく筆者でした。「10 桁の金額」は印 @Digits だけで断られると思っていたら、10 億円なので「5 万円を超えたら理由が要る」にも引っかかっていました。テストを書くと、自分が仕様をどう覚えているかも試される。理由を書いた申請に直して通しました。

POINT

表で決めた仕様は、表のままテストにする。1行1件なので、落ちたときに「どの行か」がそのまま名前に出る。

三

窓口だけを起動する

次は窓口(Controller)です。@WebMvcTest は、窓口と、例外を返事にする係(第7回の ApiErrors)など、Web のまわりだけを起動します。担当者(Service)は @MockitoBean で偽物にして、「この申請は submitted です」と台本どおりに答えさせます。第11回の門番の設定も読み込み、本番と同じ門を通します。

/** 第10回の窓口だけを起動する。担当者(Service)は台本どおりに答える偽物 */
@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で問い合わせ番号は頼みごとの番号() {  // 第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 が止まった");
    }
}
# 窓口だけのテスト(7 件)。3回動かした
  ✓ 承認できたら200と次のボタン
  ✓ できない操作は409で今の状態とボタンを返す
  ✓ 知らない操作名は400
  ✓ ない申請は404
  ✓ ないURLは500ではなく404
  ✓ 中身の変更で金額がマイナスなら400と項目
  ✓ 思わぬ失敗は500で問い合わせ番号は頼みごとの番号
  Spring の起動  0.82〜0.85 s     7 件ぜんぶで  1.22〜1.34 s

リクエストは本物の HTTP ではなく、MockMvc が Spring の中へ直接渡します。MockMvcTester は、その返事を AssertJ の書き方で確かめられる道具です。第13回の「ない URL は 404」「500 の問い合わせ番号は頼みごとの番号」も、ここで確かめました。DB は起動しないので、Spring の起動は 0.8 秒ほどです。

ただし、担当者が偽物なので、SQL が正しいかは何も分かりません。そこは次の章です。

四

本物の DB で

DB を使うテストを書こうとして、最初につまずいたのは「テスト用の DB をどう作るか」でした。DB の形が、ソースのどこにもまとまっていなかったのです。

# アプリのソースにある DB の形(src/main/resources/schema.sql)
  表 1 つ(expense)と番号 1 つ。第1回のまま
# 練習用 DB に実際にあるもの
  表 7 つapp_user  budget  expense  expense_history  expense_line  receipt  receipt_blob
  足してきた SQLsetup4.sql  setup9.sql  setup10.sql  setup11.sql  setup12.sql  (手で psql に流した)
# テスト用に、いまの形を取り出す
$ pg_dump -s -O -x expenses > dump.sql            # 315 行
  → 練習用だけの表(clock・batch_test)と、psql 向けの行を外して
  src/test/resources/schema-test.sql              # 125 行、26 文

第4回からの表は、回ごとの SQL を手で流して足してきました。アプリのソースにあるのは第1回の表1つだけ。新しい PC で動かすと、第4回より後の窓口は、表や列が足りずに動きません。ひとまず練習用 DB から pg_dump -s(形だけ)で取り出して、テスト用の DDL にしました(本来は、Flyway のような道具で、変更を順番にソースに残します)。

DB は、Testcontainers で Docker の中に PostgreSQL 16 を1つ立てます。@ServiceConnection を付けると、接続先を Spring Boot が自動で受け取ります。

/** テスト用の PostgreSQL 16 を、Docker で1つ立てる。接続先は Spring が自動で受け取る */
@TestConfiguration(proxyBeanMethods = false)
public class TestDb {
    @Bean
    @ServiceConnection
    PostgreSQLContainer postgres() {
        return new PostgreSQLContainer("postgres:16");
    }
}

/** アプリをまるごと起動し、本物の PostgreSQL につなぐテストの印 */
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestDb.class, Claims.class})
@ActiveProfiles("test")
public @interface DbTest {
}

# テストのときだけ足す設定(DB の接続先は Testcontainers が入れる)
spring.sql.init.schema-locations=classpath:schema-test.sql
# DB の決まりのテスト(4 件)。3回動かした
  ✓ 金額0円はDBが断る
  ✓ 打ち間違いの状態はDBが断る
  ✓ 一円未満はDBが黙って丸める
  ✓ 同じ日同じ品目同じ金額は1件だけ
# かかった時間(Docker の像は取得ずみ)
  片づけ係(ryuk)の起動0.64〜0.80 s
  PostgreSQL 16 の起動1.67〜1.81 s
  アプリ全体の起動(↑を含む)9.3〜10.1 s
  この4件ぜんぶで(↑を含む)11.5〜12.2 s

PostgreSQL の起動は 2 秒足らずですが、アプリ全体の起動まで含めると約 10 秒。いちばん重いテストです。それでも、DB の CHECK(第6回・第10回)や、1 円未満を黙って丸めること(第6回)は、本物の DB でしか確かめられません。

テスト用の DB として、Java の中で動く H2 もよく使われます。同じ DDL を流してみました。

# ① 同じ DDL(schema-test.sql の 26 文)を H2 2.4(PostgreSQL モード)に流す
  通った 8通らなかった 18
  CREATE TABLE public.expense (… CHECK (status = ANY ((ARRAY['draft', …])::text[])) …)
    Syntax error in SQL statement "… [*]])))"
  → expense 表が作れないので、そのあとの索引・外部キーも次々に失敗
# ② 表を手で H2 向けに書きなおすと、試した文はどちらも同じ答え
                                      H2 2.4.240          PostgreSQL 16.15
  amount = 1280.125                   1280.13             1280.13
  status = 'aproved'                  CHECK で拒否CHECK で拒否
  amount = 1280.124                   1280.12             1280.12
  where item = '交通費 '0                   0
  select 7 / 2                        3                   3

PostgreSQL の DDL は、H2 では 26 文中 18 文が通りませんでした。H2 向けに表を書きなおせば、ここで試した文は同じ答えになります。でもそれは、本番とは別の DB の形を、もう1つ手で持つということです。この回は、本番と同じ PostgreSQL 16 を使うことにしました。

五

同時に押すテスト

第9回・第10回の直しは、どれも「同時に来たら」の話でした。1件ずつ順に送るテストでは、where を外しても落ちません(第8章で実際に確かめます)。そこで、スレッドを n 本用意し、全員そろってから合図で一斉に始める道具を作りました。

/** n 件の仕事を、合図で同時に始める。うまくいった件数を返す */
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();  // 全員そろうまで待つ
            try {
                job.run(k);
                return true;
            } catch (RuntimeException e) {
                return false;  // 409 にあたる失敗
            }
        }));
    }
    go.countDown();  // よーい、どん
    …
}

/** 第10回:承認と差戻しを同時に押しても、通るのは1件だけ */
@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);
}

このテストは、壊れた版にどのくらい気づけるのでしょう。where で守らない第10回の素朴版を相手に、何周も回して数えました。

# where で守らない素朴版(第10回)に、承認と差戻しを同時に送る。2件以上通ったら「気づいた」
  同時の件数合図あり合図なしwhere で守った版
  2             145 / 150         134 / 150         0 / 60
  10            144 / 150         136 / 150         0 / 60
  100           150 / 150         105 / 150         0 / 60
  (3回 × 50 周。守った版は 3回 × 20 周。数字は「2件以上通った周」の数)

壊れた版に気づいた割合(実測)

壊れた版に気づいた割合(実測、合図あり・なしは各 150 周) 0% 50% 100% 97% 89% 0% 同時 2 件 96% 91% 0% 同時 10 件 100% 70% 0% 同時 100 件 合図あり(そろってから) 合図なし(作ったそばから) where で守った版 同時のテストは、壊れていても通ることがある。合図でそろえ、件数を多めに 守った版は 3回 × 20 周すべてで 1 件だけ通った(2件以上は 0 周)

合図でそろえれば、同時 100 件で 150 周すべて気づきました。合図なしで、作ったそばから走らせると、100 件でも 3 割は見逃します。同時 2 件では、合図があっても 150 周のうち 5 周は見逃しました。同時のテストは、壊れていても通ることがある。1回の緑で安心せず、そろえて、件数を多めにします。where で守った版は、どの周も1件だけが通りました。

六

だれが何をしてよいか

第11回の「3人 × 5操作」の表も、そのままテストにしました。ログインは、Spring Security のテスト用の部品 user(…) で「この人としてログインしたことにする」、書き込みの合言葉は csrf() で付けます。門番(SecurityConfig)も担当者も DB も本物です。

/** 第11回:だれが、どの申請に、何をしてよいか */
@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));  // その操作ができる状態の、新井の申請を作って押す
    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);
}
# 第11回の門番と担当者のテスト(21 件)
  ✓ 役割ごとにできる操作× 15
  ✓ 他人の申請は存在しないのと同じ404
  ✓ 自分の申請は承認できない
  ✓ ヘッダーで名乗っても無視される
  ✓ ログインしていなければ401
  ✓ 合言葉がなければ403
  ✓ パスワードちがいといない利用者は同じ401
  21 件ぜんぶで0.66〜0.75 s(アプリと DB は前のテストで起動ずみ)

アプリと DB は前のテストで起動ずみなので、21 件で 1 秒かかりません。Spring のテストは、同じ設定の起動を使い回すからです。テストのクラスは 10 ありますが、Spring の起動は 2 回(DB つきと窓口だけ)でした。

パスワードのテストには、{noop}(そのままの文字という印)の利用者を1人だけ入れています。本物の bcrypt のハッシュは使いません(第11回で測ったとおり、1回 80 ms ほどかかります)。

七

時計と、残り物

第6回の「今日」の実験は、東京の 0〜9 時にしかできませんでした。UTC の JVM では、東京の今日がまだ「未来」だからです。テストでは、時計を止めます。Hibernate Validator には、印が使う時計を差し替える口(ClockProvider)があります。

static Validator validator(Clock clock) {
    return Validation.byProvider(HibernateValidator.class).configure()
            .clockProvider(() -> clock).buildValidatorFactory().getValidator();
}

// 第6回の「今日」:東京の 10/31 00:30 は、UTC ではまだ 10/30 15:30
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");
}

東京の 10/31 00:30 に時計を止めると、同じ申請が、東京の時計なら通り、UTC の時計なら「未来の日付」で断られました。この2件は、昼でも夜でも、どの国で動かしても同じ結果になります。今の時刻に左右されるテストは、いつか必ず落ちる。

もう1つ、テストの外に残るものがあります。第12回のファイルです。

# 第12回のテスト(6 件)
  ✓ 置くときの名前は自分で付ける
  ✓ 中身が画像でなければ415
  ✓ 同じ領収書は2度目が409
  ✓ 他人の申請には付けられない
  ✓ 他人の領収書は返さない
  ✓ DBに書けなかったらファイルも残さない
  6 件ぜんぶで0.68〜0.82 s
# 第8章の実験で、直しを元に戻して動かした(R5)あと、5回つづけて同じテストが落ちた
  ReceiptRulesTest.置くときの名前は自分で付けるR6 R7 R8 R9 R10
  target/test-store/written-outside.jpg   ← 残っていた
  壊した版が、置き場所の外に書いたファイルが残っていた

第8章の実験で第12回の直しを元に戻したとき、壊れた版は名前どおり、置き場所の外にファイルを書きました。直しを戻したあとも、そのファイルは残っていて、次の5回のテストは、アプリは正しいのに落ちました。テストは、前に動かしたときの残り物に左右されてはいけない。始める前に消すように直しました。DB も同じで、この回のテストは、受付番号が TST- で始まる新しい申請をテストごとに作り、ほかのテストの申請には触りません。

八

直したところを元に戻す

新井107 件、ぜんぶ緑です!

古井第1章の1件も緑だった。壊して確かめよう。

第6回から第13回までの直しを10か所選び、1か所ずつ「直す前の書き方」に戻して、107 件を全部動かしました(戻すのも、元に戻すのも、スクリプトでやります)。

端末の画面。python3 revert14.py R2 R3 の結果。R2(第11回 見てよい申請だけ)を元に戻すと 107 件中 3 件が落ち、どれも expected: 404 but was: 200。R3(自分の申請は承認しない)を元に戻すと AccessRulesTest.自分の申請は承認できない が expected: 403 but was: 200 で落ちた
第11回の直しを1つずつ元に戻して、テストを全部動かしました。他人の申請が見えるようになると3件が、自分で承認できるようになると1件が、すぐに落ちます(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
直したところ元に戻した書き方落ちたテスト(件数)と、落ちたときの言葉
第6回 前後の空白を落とすitem = item.strip() → (—)StrictExpenseRequestTest(1)
expected: "交通費" but was: " 交通費 "
第9回 足りなければ引かないand remaining >= #{amount} → (—)BudgetRaceTest(1)
expected: 100 but was: 150
第10回 状態は where で守るwhere id = #{id} and status = #{from} → where id = #{id}ClaimRaceTest(1)
expected: 1 but was: 3
第10回 差戻しから再申請SUBMIT(SUBMITTED, DRAFT, RETURNED) → SUBMIT(SUBMITTED, DRAFT)ActionTest(2)
expected: "submit" but was: ""
第11回 見てよい申請だけ<if test="!seeAll">and applicant = #{name}</if> → (—)AccessRulesTest, ReceiptRulesTest(3)
[HTTP status code] expected: 404 but was: 200
第11回 自分の申請は承認しないif (… APPROVE && applicant == me) throw SelfApproval… → (—)AccessRulesTest(1)
[HTTP status code] expected: 403 but was: 200
第11回 承認は承認者だけ.hasRole("APPROVER") → .authenticated()AccessRulesTest(5)
[HTTP status code] expected: 403 but was: 200
第12回 置く名前は自分で付けるdir.resolve(id) → dir.resolve(file.getOriginalFilename())ReceiptRulesTest(2)
[HTTP status code] expected: 200 but was: 500
第12回 中身で見分けるif (type == null) throw … 415 → type = file.getContentType()ReceiptRulesTest(1)
[HTTP status code] expected: 415 but was: 200
第13回 ない URL は 404@ExceptionHandler(NoResourceFoundException.class) → (—)V10ControllerTest(1)
[HTTP status code] expected: 404 but was: 500

10か所とも、少なくとも1件のテストが落ちました。落ちたときの言葉は「404 のはずが 200」のように、何が起きたかをそのまま言っています。第10回の where を外したときは、同時のテストだけが気づきました(5回やって5回とも)。順に1件ずつ送るテストでは、遷移表のチェックが先に断るので、where がなくても困らないからです。

おもしろかったのは、第11回の「承認は承認者だけ」を外したときです。落ちるはずの6件のうち、新井が承認する1件は落ちませんでした。テストの申請が新井のものなので、「自分の申請は承認しない」のほうで 403 になったのです。守りが二重だと、片方を外してもテストが通ることがある。それぞれの守りを、別々に確かめる行が要ります。

POINT

テストを書いたら、一度わざと壊して、落ちるのを見る。落ちないテストは、何も守っていない。

九

網羅率と、テストを試す道具

テストがどこまで届いているかは、JaCoCo で数えられます。テストを動かしている間に、通った行と分かれ道に印を付けていきます。

# JaCoCo 0.8.14 の結果(107 件のテストを動かしたとき、通った行と分かれ道)
  パッケージ行分かれ道
  第1〜8回(素朴版の窓口が多い)71/172  41.3%     10/30  33.3%
  第9回(予算・版番号)26/89  29.2%      4/32  12.5%
  第10回(状態遷移)51/82  62.2%      5/16  31.2%
  第11回(認証・認可)48/72  66.7%      16/22  72.7%
  第12回(ファイル)59/71  83.1%      28/38  73.7%
  第13回(番号)19/19  100.0%     5/8  62.5%
  合計274/505  54.3%    68/146  46.6%
# クラスごと(いくつか)
  Action                  13/13
  SecureClaimService      12/13
  ReceiptService          31/33
  FileType                4/4
  NaiveFlowService        3/15
  SeedUsers               0/17

全体では 54%。低く見えますが、第1〜9回の素朴版(わざと壊れた版)や実験用の窓口は、テストで守るものではありません。守りたいクラスのうち Action、SecureClaimService、ReceiptService は 9 割を超えました。網羅率は「どこを確かめていないか」を探す地図で、点数ではありません。

ただし、行を通ったからといって、確かめたとは限りません。PIT は、コードをわざと少しずつ書きかえ(>= を > に、true を false に、など)、そのたびにテストを動かして、落ちるかを見る道具です。第8章を、機械に何十回もやらせるようなものです。

PIT の報告の画面。FileType.java の 9 行目と 11 行目が赤。9 行目の変異 9 個のうち 7 番目 changed conditional boundary が SURVIVED、11 行目の 8 個のうち 3 番目 changed conditional boundary が SURVIVED。ほかは KILLED
PIT の報告(境目の行を足す前)。>= を > に書きかえても、どのテストも落ちなかった場所が赤く残っています(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# PIT 1.20.4:Spring を起動しないテストが見ている4つのクラスを、わざと少しずつ書きかえる
  書きかえ(変異) 42テストが気づいた 38 (90%)気づかなかった 3テストが通らない場所 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
# 境目の3行を足した
  "JPEG の印ちょうど3バイト| ffd8ff| image/jpeg"
  "%PDF- ちょうど5バイト| 255044462d  | application/pdf"
  "ちょうど 5 万円で理由なし| 出張 | 50000 | '' | ''"
  書きかえ 42テストが気づいた 41 (98%)気づかなかった 0テストが通らない場所 1

生き残った書きかえ(実測)

生き残った書きかえ(FileType の 9 行目) h.length >= 3 && FF D8 FF … h.length > 3 && FF D8 FF … 元のコード PIT が書きかえたコード JPEG 10 バイト image/jpeg image/jpeg 同じ → 気づかない ffd8(2 バイト) null null 同じ → 気づかない ffd8ff(3 バイト) image/jpeg null ちがう → 気づく 3 バイトの行を足すまで、この境目はどのテストにも確かめられていなかった 行を通っただけでは「確かめた」ことにならない。境目に1件ずつ置く FileType は JaCoCo の行では 4/4(100%)だった

Spring なしのテストが見ている4つのクラスで、42 の書きかえのうち 3 つを、どのテストも見逃しました。3 つとも境目です。ちょうど 3 バイトの JPEG、ちょうど 5 バイトの PDF、ちょうど 5 万円。FileType は JaCoCo では行 100% だったのに、境目は一度も試されていませんでした。境目の3行を足すと、見逃しは 0 になりました(残る1つは、テストから呼んでいない小さなメソッドです)。PIT は1回 15 秒ほど。Spring を起動するテストまで含めると何倍もかかるので、まずは表や計算のクラスに使うのが手ごろです。

十

速さと、いつ動かすか

# テスト 107 件を3回。種類ごとの件数と時間(Maven の表示から)
  種類件数時間
  Spring なし65      0.48〜0.54 s
  窓口だけ(@WebMvcTest)7       1.22〜1.34 s
  アプリ全体+本物の DB35      14.0〜14.8 s(うち起動 9.3〜10.1 s)
  Maven 全体(コンパイルなども含む)107     20.6〜23.6 s
  Spring を起動した回数2   (テストのクラスは 10。同じ設定のものは使い回された)

テストの3つの種類

テストの3つの種類(何を本物で起動するか) 門番・窓口 担当者 倉庫番 PostgreSQL Spring なし 起動しない クラスを直接呼ぶ 65 件 0.48〜0.54 s 表の書き写し 境目・印 窓口だけ 本物 偽物(台本) 起動しない 起動しない 7 件 1.22〜1.34 s 起動 0.82〜0.85 s 状態コード 返事の形 アプリ全体+DB 本物 本物 本物 本物 35 件 14.0〜14.8 s 起動 9.3〜10.1 s SQL・同時・権限 ファイル 右ほど本物に近いが、遅い。速いテストを多く、遅いテストは要所に 件数と時間は 107 件を3回動かした実測(Docker の像は取得ずみ)

Spring なしの 65 件は合わせて 0.5 秒、DB つきの 35 件は 14 秒(うち 10 秒は起動)。速いテストを多く、遅いテストは要所に。保存のたびに動かすのは Spring なしのテスト、push の前や CI(共有のリポジトリに入るたびに動く自動の確認)では全部、と分けると待ち時間が気になりません。CI でも Docker が使えれば、同じ mvn test で本物の PostgreSQL のテストまで動きます。

試したいこと書き方
テストを全部./mvnw test
1つのクラスだけ./mvnw test -Dtest=ActionTest
網羅率(target/site/jacoco/index.html)jacoco-maven-plugin の prepare-agent と report
PIT(対象のクラスとテストを絞る)./mvnw org.pitest:pitest-maven:mutationCoverage -DtargetClasses=… -DtargetTests=…
本物の DB のテストDocker を動かしてから(Testcontainers が PostgreSQL を立てる)

まずは、自分のアプリでいちばん怖い直しを1つ選び、それを確かめるテストを1件書いて、直しを戻して落ちるのを見てください。それが、この回のすべてです。

結 — 一枚まとめと、次回

  1. 雛形のテストは起動するかだけ。直しを3つ戻しても緑だった。
  2. 表で決めた仕様は表のままテストに。Spring なしなら 65 件で 0.5 秒。
  3. 窓口だけのテストは返事の形を、本物の DBのテストは SQL・CHECK・丸めを確かめる。DB の形はソースに置く。
  4. 同時のテストは合図でそろえ、件数を多く。それでも見逃すことがある。
  5. 時計は止め、残り物は始める前に消す。
  6. テストはわざと壊して落ちるのを見る。10 か所とも気づいた。網羅率は地図、PIT は境目の抜けを見つける。

新井これで、来月だれが書きかえても安心ですね。

古井このテストが動く場所ではね。次回は最終回。この申請のアプリを、自分の PC の外で動かす。Docker、nginx、どのくらいの人数まで耐えるか、そして仮想スレッド。

参考

記録は、筆者がクラウド上の Linux(Ubuntu 24.04、カーネル 6.18)で、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、PostgreSQL 16.15 を使って 2026年10月31日に取ったものです。時間は Maven の表示から読み、107 件を3回動かした範囲で書いています(Docker の像は取得ずみ)。同時のテストの周回の数も、そのときの実測です。直したところを元に戻した実験は、スクリプトで書きかえて動かしたあと、元に戻しています。表の数字や出力はすべて、そのときのコマンドの出力から読んだ実際の値です。登場人物は架空の人物です。

いにしえのバックエンド入門 第14回 — テスト / 2026.10

← 記事一覧へ