CI 23分 → 11分。8.6万行のテストコードを消すまでにやったこと

1. はじめに — 「テストは全部 結合テスト」だったプロジェクト

出前館で商品ドメインを扱うマイクロサービスの開発を担当している左官です。 今回は、そのマイクロサービスのチームで取り組んだ「結合テストに偏りきったテストコードを、単体テストへ移し替える」という改善についてお話しします。 CI は23分から11分になり、テストコードは8.6万行減りました。どう判断して消していったのか、その過程を書いていきます。

さて、私たちが扱っているのは、メニュー情報(メニューパターン・カテゴリ・商品・サイズ・オプション・品切れ…)を一手に引き受けるサービスです。 こちらのコードは Kotlin + Spring Boot のマルチモジュール構成で、Controller → Usecase → Domain → Repository というレイヤードアーキテクチャを取っています。 (以降の具体例は Kotlin ですが、話の中身は言語やフレームワークに依存しません。「レイヤードアーキテクチャ」「DBを立てて動かすテスト」に心当たりがあれば、そのまま読み替えられるはずです)

このサービスのテストには、ひとつ大きな特徴がありました。

Usecase のテストが、ほぼ全て結合テスト(Integration Test, 以下 IT)だったのです。

1本の IT はこういう作りをしています。

  1. Docker で立ち上げた DB に、setup/insert_all.sql で初期データを流し込む
  2. アプリのコンテキストを丸ごと起動する(この中の Repository が、上の DB に実際に繋ぐ)
  3. MockMvc でエンドポイントを叩く(HTTPサーバは立てず、Spring MVC に直接リクエストを流し込む仕組み)
  4. レスポンス JSON を期待値ファイルと突き合わせる
  5. さらに、更新後の DB の状態を期待値 CSV と突き合わせる

エンドツーエンドで通るので、信頼度はとても高いです。問題は、それを全てのテストケースでやっていたことでした。

この記事では、チームで約2ヶ月かけてこのテスト構成を作り替えた話を書きます。結果として8.6万行のテストコードを消すことになったのですが、振り返ると「どう書き換えるか」より 「どうすれば安心して消せるか」 のほうに時間を使っていました。

なお「2ヶ月」といっても、この作業だけに専念していたわけではありません。通常の機能開発と並行しながら、手が空いたタイミングで1ドメインずつ少しずつ進めていった結果としての2ヶ月です。まとまった時間を確保できなくても進められる形にしたことが、そのまま後述の「ドメインごとに分割する」進め方につながっています。

💡 なぜこうなったか 悪意はなく、むしろ合理的でした。既存の巨大なレガシーシステムから機能を切り出してくる過程で、「移行前と同じ振る舞いをすること」を担保する必要があり、DBまで含めて丸ごと検証するのが一番確実だったのです。ただ、その形のまま機能追加を続けた結果、テストの総量が想定を超えて膨らんでいきました。

2. 何が困っていたのか

2-1. とにかく遅い

CI が1回まわるのに 約23分(キュー待ちを除いた実行時間)。その大半をテストが占めていました。

カテゴリドメインの Usecase テストだけ取り出しても 127ケース / 約198秒。ドメインは他にもメニューパターン・商品・サイズ・オプション・品切れ…とあり、CI のたびに全部が動きます。

これが効いてくるのは、自分の待ち時間よりレビューの流れのほうでした。CI がグリーンになってからレビュー依頼を出すので、「実装完了」から「レビュー依頼」までに毎回20分以上のラグが入ります。指摘を受けて直せば、また待ってから再依頼。1回の待ちは数十分でも、往復に乗るとチーム全体のリードタイムがじわじわ伸びていきます。

2-2. フィクスチャが本体より重い

IT 1ケースを足すには、SQL・リクエストJSON・レスポンス期待値JSON・DB期待値CSV がセットで必要です。「オプションのバリエーションを1個増やす」だけで、数ファイルの追加と既存CSVの手直しが発生する。

最終的にどれくらいあったかというと、移行後に削除できたリソースファイルは 1,614 個でした。テストコード(Kotlin)96ファイルに対して、その16倍以上のフィクスチャがぶら下がっていたことになります。

2-3. 何をテストしているのか分からなくなる

一番効いたのはこれかもしれません。IT は「全部通す」ので、1本のテストが同時に色々なことを検証してしまいます。

- 400BAD_REQUEST: ID が不正
- 401UNAUTHENTICATED: APIキーが不正
- 403UNAUTHORIZED: APIを呼び出す権限がない
- 400BAD_REQUEST: APIパスが不正
- 500INTERNAL_SERVER_ERROR: 外部APIからエラー

これは、とある Usecase の IT に実在したテストケース名です。よく見ると、Usecase のビジネスロジックを検証しているものが1つもない。 ValueObject のバリデーション、Spring Security の設定、Spring MVC のルーティング。どれも Usecase の責務ではないものを、Usecase のテストとして、DBを立ててまで検証していました。

しかも同じ 401 / 403 のテストが、Usecase の数だけコピーされて存在していました。

3. 方針 — 「減らす」ではなく「置き直す」

そこで立てた方針が、テストの責務をレイヤーごとに分けることです。

テストの責務分離

ポイントは、テストを消しているわけではないということです。IDの不正値に関する検証は削除せず、責務に合わせて Value Object 側のテストへ移しました 同様に、401/403 は Controller テスト、SQL の正しさは Repository テストに移管されています。

図中の @WebMvcTest は、アプリ全体ではなく Controller 層だけを起動する Spring Boot のテスト機能です。DBにも他のレイヤーにも繋がないので、起動が一瞬で済みます。

引っ越し先の Controller テストも、ついでに作りを見直しました。当初は「別コンポーネントのAPIキーで叩いて403を確認する」方式で書いていたのですが、これだと権限マッピングを変えるたびに、テストの意図と関係なく落ちます。 そこで Spring Security Test(テスト時に任意の権限を持つユーザーを差し込める仕組み)で権限を直接注入する方式に統一し、正常系は「そのエンドポイントに必要な権限だけを持つユーザー」で叩くようにしました。

// 403: 権限を1つも持たないユーザー
mockMvc.perform(get("/v1/xxx").with(noAuthorityUser))
// 200: そのエンドポイントに必要な権限だけを持つユーザー
mockMvc.perform(get("/v1/xxx").with(authorizedUser(FunctionId.XXX)))

こうすると正常系テストが「このエンドポイントの @PreAuthorize が正しい権限を要求していること」の検証も兼ねます。 さらに 401(認証)は認証フィルタ共通の話なので、各Controllerから剥がして専用テスト1箇所に集約しました。この整理だけで差し引き −467行です。

そして、エンドツーエンドで通ることの確認も必要なため、ドメインごとに「HappyPath IT」を1本ずつ残しました。 CRUD を一周する E2E テストです。DB もレスポンスも全部見る、従来の IT と同じ作りのものを、網羅のためではなく配線確認のために残す。

この形にすると、テストケース数は当然減ります。 カテゴリドメインの GetCategoryDetailUsecase のテストケース数を例に取ると:

カテゴリ IT方式 UT方式 移行先 / 理由
正常系バリエーション 7 3 ソート確認・空リストのみ残す
CATEGORY_NOT_FOUND 4 1 分岐は同じなので代表1件
ValueObject 不正 3 0 → ValueObject テストへ
認証・認可 2 0 → Controller テストへ
500エラー(外部API) 1 0 モックでは意味が薄い
合計 17 4

17 → 4。数字だけ見ると乱暴ですが、消えた13件のうち検証そのものが失われたのは0件、というのがこの表の意味です。

4. 進め方 — ドメイン単位で、ITは最後まで消さない

一気にやると絶対に事故ってしまうため、1PR = 1ドメインで進めました。全8本、こういう順番です。

PR 対象
PR1 カテゴリ
PR2 メニューパターン
PR3 商品
PR4 オプション
PR5 品切れ
PR6 認可テストの共通化
PR7 その他ドメイン
PR8 旧ITの一括削除

そして、移行中は旧ITを1本も消しませんでした。UTを追加し、ITはそのまま残す。当然その期間、CI は移行前より遅くなります。 それでも、「新しいテストが本当に同じものを守れているか」を確かめる比較対象を、最後まで確保するため残しておきました。

全ドメインの移行が終わってから、まとめて削除しました。

96 Kotlin files, 1,614 resource files
+0 / −86,101 lines

8.6万行の削除。移行ドキュメント5本(2,907行)も、役目を終えたので一緒に消しました。

5. 「消していい」と判断するための4つのゲート

この記事で一番共有したいのはここです。テストを消す作業で怖いのは「気づかないうちに守れていない場所ができる」ことなので、各PRで必ず次の4つを通しました。

ゲート① テストケースの分類表を先に作る

コードを書く前に、対象ドメインの IT を全ケース洗い出し、1件ずつ移行先を決めた表をPRに貼ります。

No. テストケース 移行先 理由
1 ITEM_NOT_FOUND UT 存在チェックのビジネスロジック
2 ID 不正 不要 ValueObject テストでカバー済み
3 APIキー不正 Controller 認証

「不要」と書く欄があるのが大事で、どこにも移さない判断には理由を書くことを必須にしました。これがあると、レビュアーは「本当にそれ他でカバーされてる?」という一点だけを見ればよくなります。

ゲート② カバレッジを IT と UT で直接比較する

Kover(Kotlin のカバレッジツール)で、旧IT だけを流したカバレッジ新UT+Controller だけを流したカバレッジを取り、クラスごとに並べてPR本文に貼りました。合格ラインは「BRANCH カバレッジの差が −5% 以内」。

Usecase Metric IT UT+Ctrl Diff
GetMenuPatternTreeUsecase BRANCH 90.5% 95.2% +4.8%
GetItemDetailUsecase LINE 97.9% 100.0% +2.1%
CreateAndUpdateItemUsecase LINE 96.3% 96.9% +0.6%
DeleteStockoutUsecase BRANCH 100.0% 100.0% ±0.0%

面白かったのは、UTのほうがカバレッジが高くなるケースが普通に出てきたことです。 IT では準備が大変すぎて誰も書かなかった分岐(セット商品の組み合わせなど)が、モックなら3行で書けてしまう。 「単体テストにすると検証が薄くなる」は、少なくともこのプロジェクトでは逆でした。

ゲート③ Repository ギャップ分析

IT が暗黙に守っていたものの筆頭が SQL です。UTでモックにした瞬間、SQLは誰も検証しなくなります。

そこで各PRで、「この Usecase が呼ぶ Repository メソッド」を全部列挙し、Infrastructure 層のテスト(こちらは実DBを使います)が存在するかを1つずつ確認しました。 無ければ、そのPRの中で追加します。品切れドメインの移行では、この分析の結果として Master/Read 両方のリポジトリテストを大量に追加することになりました。

ゲート④ HappyPath IT を先に用意する

削除する前に、ドメインごとの E2E を1本用意しておく。作成 → 取得 → 更新 → 削除を通しで叩き、レスポンスJSONとDBの両方を検証します。

「テーブルは更新されているのにレスポンスが古い」「Usecase単体では通るが DI が壊れている」といった、モックでは原理的に検出できない事故の受け皿がこれです。網羅はしないので、1ドメインあたり数本で済みます。

6. 結果

CI 全体

項目 CI 実行時間
移行前 約23分
移行後 約11分

半分以下になりました。これは一直線に下がったわけではなく、移行の途中は旧ITと新UTが二重に走るのでむしろ遅くなっていて、最後の一括削除で一気にここまで落ちています。

テスト単体で見ると

カテゴリドメインの Usecase テストで比較すると:

項目 ケース数 実行時間
移行前(IT) 127 197.9 s
移行後(UT) 72 1.2 s

約160倍。しかも UT は Docker も DB も要らないので、ローカルで気軽に回せます。「テストを回すために Docker を立ち上げる」という手順が消えたことの効果は、秒数以上に大きいものでした。

規模

項目
移行した Usecase の IT 622 ケース
移行後の Usecase UT 249 ケース
追加した Controller テスト 90 ケース
削除したファイル Kotlin 96 / リソース 1,614
削除した行数 86,101 行
期間 約2ヶ月(PR 8本)

正直な話:1件だけ、カバレッジが落ちたところ

品切れドメインのある Usecase だけ、BRANCH カバレッジが 72.9% → 60.4%(−12.5%) と基準を割りました。733行ある巨大な Usecase で、そもそも IT 時点でも72.9%しかありません。

ここは「基準を割った」ことをPRに明記した上で、差分が内部検索メソッドの細かいフィルタ条件の組み合わせであり、ビジネスロジックの分岐・エラーコード・ルーティングは全てカバー済みであることを確認して、チームで合意して通しました。 隠さずに書いて議論の対象にする。この手の移行では、これが一番効きました。

(その後、カバレッジのギャップを洗い出して優先度の高い5つの Usecase にテストを追加する作業も別途行っており、そこでは移行前のITを上回るカバレッジまで戻したものもあります。)

7. 学び

① 「減らす」ではなく「置き直す」と言い切る

「テストを減らすPR」はレビューが通りません。「この検証はこのレイヤーへ移す」という表を先に作り、消える1件ごとに引っ越し先を書く。言葉と成果物の両方をそう設計すると、議論が「消していいか」から「引っ越し先は適切か」に変わります。

② 消す前に測る。測れないものは分析する

カバレッジ比較(測れるもの)と、Repository ギャップ分析(測りにくいもの)を両方やる。ITが暗黙に守っていたものは、たいていカバレッジに表れません。

③ 安全網は最後まで残す

移行中ずっと旧ITを残したのは、CI時間を犠牲にする判断でした。それでも「いつでも比較できる」状態があることが、チームとして思い切った削減に踏み切る支えになりました。

④ 1PR = 1ドメイン

8.6万行を1PRで消したのは最後だけで、そこに至るまでは小さいPRの積み重ねです。ドメインごとに区切ったことで、各PRのレビュアーが「自分の詳しいドメインだけ」を深く見られました。

8. おわりに

テスト戦略の見直しは、機能開発と違って誰からも依頼されない仕事です。それでも、CIが速いこと・テストの意図が読めること・テストを追加するのが億劫でないことは、その後の全ての機能開発の速度に効いてきます。

移行はまだ完全ではなく、未着手のドメインも残っています。同じような「全部IT」に悩んでいるチームの参考になれば嬉しいです。