「テストはどこまで書けばいいですか」と若手に聞かれて、即答できなかったことがある。
正直なところ、その案件は予算も期間もカツカツで、「全部テストする」なんて選択肢は最初から無かった。でも「勘で間引く」と品質が読めなくなる。テストは目的ではなく手段なので、手段に予算を食われて目的(納期内に壊れないものを出す)を見失っては本末転倒になる。
この問いに自分なりの答えを出すまでに、一度盛大に失敗している。その話から書く。
最初は「ピラミッドの教科書通り」をやろうとして溶けた
テスト歴がそこそこ長くなると、テストピラミッド(単体が土台、結合が中間、E2Eが頂点)の図は何度も見ている。ある中小案件で、私はこれを真に受けて「単体を分厚く、結合もそこそこ、E2Eも一通り」を全部やろうとした。
結果、テストコードがプロダクトコードより先に膨らみ、仕様が一度変わるたびに大量のテストが赤くなって、その修正だけで半日溶ける状態になった。今思えば、ピラミッドは「比率の目安」であって「全部やれ」という意味ではなかった。中小案件の規模に翻訳しないまま持ち込んだのが間違いだった。
そこから、「どの層に何割の労力をかけるか」を案件規模で決め直すようにした。
テストピラミッドを現場サイズに翻訳する
直近の社内プロダクト(マルチテナントのノート管理アプリ)で実際に敷いた配分がこれ。教科書の比率ではなく、「壊れたときの痛みが大きい順」に労力を寄せている。
| 層 | 何を担保するか | 労力の目安 | 自動化 |
|---|---|---|---|
| API 単体(Jest) | 入力検証・分岐・例外。ビジネスルールの最小単位 | 約5割 | 必須 |
| API シナリオ(Jest) | 作成→更新→削除の一連が破綻しないか | 約2割 | 必須 |
| フロント単体(Vitest) | ストア・主要コンポーネントの状態遷移 | 約2割 | 必須 |
| 画面 E2E(Playwright・モック型) | 主要画面の振る舞い。APIはモックで固定 | 約1割 | 必須 |
| 実画面の目視確認 | レイアウト崩れ・体感。chrome-devtools で確認 | 都度 | 手動 |
カバレッジ目標も「100%」とは置かない。コアロジック(api層)は80%、共通処理(common層)は70%を下限の合意ラインにした。ここは好みが分かれるところですが、中小案件で90%以上を全層に課すと、テストのためのテストが増えて費用対効果が崩れる、というのが現場の実感です。
ポイントは、E2Eを「頂点=少数精鋭」に保つこと。E2Eは書くのも直すのも一番高い。だからAPIをモックで固定した「モック型」にして、画面の振る舞いだけを検証し、実APIとの結合は別枠に切り出した。
テストが1つも無いコードを引き継いだら、まず特性化テスト
中小案件でよくあるのが、「とりあえず動く」状態で引き継いだコードにテストが1行も無いケース。ここでいきなり「正しい仕様」のテストを書こうとすると、そもそも正しい仕様が誰にも分からなくて止まる。
こういうときは、正しさを問わずに「今の挙動」をそのまま固定する特性化テスト(characterization test)から入る。リファクタや改修で挙動が変わったら気づける、という安全網を先に張る考え方です。
// 引き継いだ請求額計算。仕様書は無い。まず「現状の出力」を記録して固定する
import { calcInvoiceTotal } from "../legacy/invoice";
describe("calcInvoiceTotal: 現状の挙動を固定(characterization)", () => {
// 正しいかどうかは今は問わない。リファクタで変わったら検知することが目的
it("税込・端数切り捨ての現状出力を記録する", () => {
expect(calcInvoiceTotal({ unit: 1980, qty: 3, taxRate: 0.1 })).toBe(6534);
expect(calcInvoiceTotal({ unit: 100, qty: 0, taxRate: 0.1 })).toBe(0);
});
});
最初に書くのは「一番触る予定の関数」と「壊れたら金額がずれる関数」の2種類だけでいい。全部を特性化しようとすると、また「全部テスト」の罠に戻る。
「重要だが壊れやすい箇所」に寄せる ―― リスクベースで集中する
労力を均等に配ると、たいてい一番こわい所が手薄になる。だから私は「重要度×壊れやすさ」でテストの密度を変える。重要度は「壊れたときの被害」、壊れやすさは「変更頻度と複雑さ」で見る。
このプロダクトで最優先に置いたのは、テナント越境(別テナントのデータが見えてしまう)の防止だった。被害が「情報漏洩」で最大、かつ権限チェックは分岐が多くて壊れやすい。ここだけは網羅的に書いた。
// テナントBのユーザーが、テナントAのノートに一切触れないことを越境テストで担保する
describe("テナント越境: 取得・更新・削除のすべてを拒否する", () => {
const noteOfTenantA = "note_a_001";
it.each(["GET", "PUT", "DELETE"])("テナントBは %s で404相当を返す", async (method) => {
const res = await callApi(method, `/notes/${noteOfTenantA}`, { tenant: "B" });
// 403で「存在は知られる」より、404で「存在も隠す」を採用
expect(res.status).toBe(404);
});
});
逆に、管理画面の表示順の並びみたいな「壊れても被害が小さい所」は、E2Eを1本通すだけにして単体は省いた。テストを「全部に均等」ではなく「こわい所に厚く」配るだけで、同じ工数でも事故の確率がかなり下がる。
手動で残す範囲と、自動化する範囲の線引き
「自動化=善」でもない。自動化は初期コストがかかるので、「何度も回す」「判定が機械的」なものから自動化し、「頻度が低い」「人の目でしか判定できない」ものは手動で残すのが費用対効果として正しい。
| 種類 | 判定 | 頻度 | 結論 |
|---|---|---|---|
| API のバリデーション・分岐 | 機械的(期待値が明確) | 毎push | 自動化 |
| 画面の振る舞い(押下→遷移) | 機械的 | 毎push | 自動化(モック型E2E) |
| レイアウト崩れ・余白・体感 | 人の目 | リリース前 | 手動(実画面確認) |
| 文言・トーンの違和感 | 人の判断 | リリース前 | 手動レビュー |
実画面の確認は chrome-devtools で主要ブレークポイントを開いて目視している。ここを無理にスクリーンショット差分の自動化に倒すと、フォントレンダリングの誤差で false positive が頻発して、結局「赤いのを無視する」文化ができてしまう。
# CI(抜粋): 単体→結合→モック型E2E を毎pushで回す
# ※ 改善の余地あり: 現状は実APIとの結合テストをCIに載せておらず、
# ステージング相当でのスモークは手動で流している。本来はnightlyで
# 実API結合を1日1回回したいが、テストデータの用意が追いついていない。
test:
steps:
- run: npm run test:unit # Jest / Vitest
- run: npm run test:scenario # API シナリオ
- run: npm run test:e2e:mock # Playwright(page.route でAPIモック)
正直に書くと、この実API結合の自動化が積み残しになっているのは、中小案件あるあるだと思う。テストデータの整備という地味な投資が後回しになりがちなんですよね。
振り返って
テスト戦略は「全部やる/勘で間引く」の二択じゃない。ピラミッドを案件規模に翻訳し、特性化テストで足場を作り、こわい所に密度を寄せ、手動と自動を費用対効果で線引きする ―― この4つを順番に決めるだけで、限られた工数でも「どこを守れているか」が言語化できる。
受け入れ基準の形にしておくと、引き継ぎや見積もりでもブレない。たとえば今は「api層カバレッジ80%以上」「テナント越境テストが全エンドポイントで緑」「主要画面のモック型E2Eが緑」を、この種のプロダクトの最低ラインとして握るようにしている。動いた=品質OK ではない、を測れる形にしておきたい。
次にやりたいのは、積み残している実API結合のnightly自動化と、リスク評価(重要度×壊れやすさ)を毎スプリントで見直す運用の定着。テスト戦略も一度敷いて終わりではなく、案件が育つにつれて配分を組み替えていきたい。