Old-School Backend / 2026 ・ No.14
いにしえのバックエンド入門
第14回 テスト ── 直したことを、機械に覚えておいてもらう
公開
この連載では、13 回かけて申請のアプリを何十か所も直してきました。確かめたのは、そのたびに筆者が手で送ったリクエストです。来月だれかが(あるいは筆者が)うっかり元に戻したら、気づけるでしょうか。
今回は、最初から入っていたテストを動かすところから始めます。Spring を起動しないテスト、窓口だけのテスト、本物の PostgreSQL を使うテスト、同時に押すテストを書き、最後に直したところを10か所わざと元に戻して、テストが気づくかを確かめます。網羅率と、PIT というテストを試す道具も使います。
テストは1件だけあった
新井テストなら、最初からありますよ。mvn test は、ずっと緑でした。
古井中身を見たことは?
全15回の路線図(いまここ:第14回)
# 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 万円を超えたら理由が要る」にも引っかかっていました。テストを書くと、自分が仕様をどう覚えているかも試される。理由を書いた申請に直して通しました。
表で決めた仕様は、表のままテストにする。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件以上通った周」の数)
壊れた版に気づいた割合(実測)
合図でそろえれば、同時 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 件を全部動かしました(戻すのも、元に戻すのも、スクリプトでやります)。
| 直したところ | 元に戻した書き方 | 落ちたテスト(件数)と、落ちたときの言葉 |
|---|---|---|
| 第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 になったのです。守りが二重だと、片方を外してもテストが通ることがある。それぞれの守りを、別々に確かめる行が要ります。
テストを書いたら、一度わざと壊して、落ちるのを見る。落ちないテストは、何も守っていない。
網羅率と、テストを試す道具
テストがどこまで届いているかは、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 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
生き残った書きかえ(実測)
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つの種類
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件書いて、直しを戻して落ちるのを見てください。それが、この回のすべてです。
結 — 一枚まとめと、次回
- 雛形のテストは起動するかだけ。直しを3つ戻しても緑だった。
- 表で決めた仕様は表のままテストに。Spring なしなら 65 件で 0.5 秒。
- 窓口だけのテストは返事の形を、本物の DBのテストは SQL・CHECK・丸めを確かめる。DB の形はソースに置く。
- 同時のテストは合図でそろえ、件数を多く。それでも見逃すことがある。
- 時計は止め、残り物は始める前に消す。
- テストはわざと壊して落ちるのを見る。10 か所とも気づいた。網羅率は地図、PIT は境目の抜けを見つける。
新井これで、来月だれが書きかえても安心ですね。
古井このテストが動く場所ではね。次回は最終回。この申請のアプリを、自分の PC の外で動かす。Docker、nginx、どのくらいの人数まで耐えるか、そして仮想スレッド。
参考
- 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
記録は、筆者がクラウド上の 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