連載「AIにコードを書かせる技術 — 品質とセキュリティのガードレール設計」第 2 回。
第 1 回では、規約を L1 規約本文 → L2CLAUDE.md→ L3 権限 → L4 ローカルゲート → L5 CI ゲート の 5 層に落とす方法を書きました。
その中で「テスト全件 PASS まで完了とみなさない」を最重要ルールに置きましたが、今回はその 「全件 PASS」を実際に成立させる側 の話です。
この記事のゴール
前回の記事では、こう書きました。
「テストを全件 PASS させてから完了とする」と書いても、PASS したことを検証する仕組みがなければ、PASS したという報告が返ってくるだけです。
これは「エージェントが嘘をつく」という話ではありません。エージェントはタスクを完了状態に持っていくよう最適化されているので、テストを通す最短経路を探します。その最短経路が「実装を直す」ではなく「テストを弱める」であるとき、そちらを通ります。そして厄介なことに、弱めたテストも緑になります。
この記事では、「PASS」という報告を信用できる状態にするための設計を、実運用しているリポジトリの規約・実装・実行結果を素材に書きます。潰す対象と手段の対応は次のとおりです。
| エージェントの最小コスト解 | 塞ぎ方 | 本記事 |
|---|---|---|
| FAIL するテストを削る / skip する / 期待値を実測に合わせる | 「テストを弱める変更」を名指しで禁止し、代替手順を書く | §1 |
| どの層が壊れたか分からないまま上層で総当たりする | テストを 5 区分に割り、ID で層を分ける | §2 |
| 何でも E2E で確認する(遅い・不安定) | テストピラミッド(下層優先)を規約化する | §3 |
| 到達しにくい行をカバレッジ除外に追加する | 除外禁止・mock 必須・下限ラチェットで FAIL させる | §4 |
toBeVisible() / toMatchObject で「出た」だけ確認する |
期待値を全項目一致にする | §5 |
| 画面だけ見て DB を見ない | 投入 → 実行 → 出力照合 → 事後照合 → 後始末の 5 段 | §6 |
| 時刻は「揺れるから」比較対象外にする | 時刻を 3 分類し、分類ごとに検証方法を固定 | §7 |
| テストを実行せず PASS マーカーだけ書く | 証跡(スクリーンショット + result.md)を完了条件にする |
§8 |
全体像
左から右へ進むほど遅く・不安定になるので、検証できる最も左の層に置くのが原則(§3)。そして「PASS」という文字列は、カバレッジ下限判定と証跡生成の両方を通過して初めて書かれます(§4 / §8)。
1. まず「テストを弱める変更」を名指しで禁止する
テスト設計の話をする前に、規約側でやることがあります。FAIL を消す方法は 2 つあり、片方は禁止だと明示することです。
実際に使っているテスト規約から、そのまま引用します。
### 1.3 テストを弱めて PASS にしない (= 要件の回避禁止)
| 禁止する変更 | 代わりにやること |
| --- | --- |
| ❌ テストケースの削除 | 実装を直す。テストが誤りなら **なぜ誤りかを PR 本文に書いてから**直す |
| ❌ `test.skip()` / `it.skip` / コメントアウト | 一時 skip が要るなら **skip 理由と復帰条件をコード内コメントに書き**、対応 Issue を立てる |
| ❌ 期待値を実際の出力に合わせて書き換える | 「仕様がそうなのか、実装が壊れているのか」をまず判別する |
| ❌ アサーションを緩める (完全一致 → 部分一致、`toBe` → `toBeTruthy`、`toEqual` → `toMatchObject`、全列照合 → 件数のみ、値の確認 → 存在の確認) | 期待値が厳しすぎるなら **仕様書 (テスト仕様書 / 画面仕様書) を先に直す** |
| ❌ 時刻の照合を諦めて比較対象外にする | 現在時刻由来は**幅で確認**、入力から導出される値は**値で確認**する (§4.7) |
| ❌ カバレッジ下限を下げる / 計測対象から除外する | mock で通す (§3.2)。除外するなら理由を明記して個別承認 |
| ❌ タイムアウトを伸ばして通す | 遅い原因 (待ち条件の誤り・不要な sleep) を特定する |
ポイントは、具体的な変換の形で書くことです。「テストを弱めるな」だけでは、toEqual → toMatchObject が「弱めた」に当たるかどうかの判断がぶれます。エージェントは「これは整理であって弱化ではない」と自己判断して通してきます。
why(なぜ弱化を禁止するか)
規約には理由も書いています。
- **テストは要件の凍結物**。テストが表現しているのは「実装の現状」ではなく「そうあるべき仕様」であり、
弱めた瞬間に **要件が黙って消える**。レビュアには「テストが減った」ことしか見えず、
どの要件が失われたかは差分から読み取れない。
- 特に **要件由来の制約** (「写真は保存しない」「リマインド日時は必須」等) は、テスト 1 本でしか
守られていないことがある。これを消すと、次の実装者は制約の存在自体を知り得なくなる。
そのうえで、正規の逃げ道を 1 本だけ用意します。
> **要件を変えるなら、テストではなく仕様書から直す**。
> 順序は **仕様書 (画面仕様書 / テスト仕様書) → テスト → 実装**。
第 1 回で書いた「逃げ道を潰したうえで正規の逃げ道を 1 本だけ用意する」の適用例です。完全に塞ぐと、エージェントは別の予期しない抜け道(たとえば「テストファイルごと新規に作り直す」)を探します。
「構造的に通らない」ときは止める
もう 1 つセットで要るのが、PASS まで直し続けさせない条件です。
下記のような場合は、機械的な再実行ではなく **対応案を Markdown でユーザに提示して停止** する:
- テストが**古い仕様**を前提にしており、新仕様に書き換える判断が要る (= 仕様変更の意思決定)
- 失敗が**外部要因** (Docker daemon 停止、ポート占有、ネットワーク不通) で、内部修正で直せない
- **同じ FAIL を直しても再発する** (= 根本原因が別のレイヤにある可能性)
提示するコメントには「観測された FAIL」「想定される根本原因」「対応案 A/B」を含める。
これが無いと、エージェントは 5 回でも 10 回でも同じテストを直しては流し、最終的に「通らないので skip しました」に着地します。
2. テストを 5 区分に割り、ID で層を分ける
「テスト全件 PASS」を判定可能にする第一歩は、テストが何層あって、どれが走ったのかを機械可読にすることです。
| 区分 | 対象 | 検証視点 | 自動テスト ID | 実行モード |
|---|---|---|---|---|
| 静的検証 | 構成ファイル・設定 | ファイル存在・構文・.env キー網羅(Docker 不要) |
TC-MAC-NN |
test.sh static |
| ユニットテスト | 関数・モジュール単体 | ロジックの入出力(純粋関数・分岐・境界値・異常系) | TC-UT-NN |
test.sh static |
| 起動前チェック | 起動条件 | ポート空き・依存導入・環境変数の整合 | TC-PF-NN |
test.sh preflight |
| 起動後検証 | 稼働中のサービス | ヘルスチェック・実 DB 疎通 | TC-RT-NN |
test.sh runtime |
| 機能テスト | 画面単位・要素単位 | 仕様どおりに各機能が単体で動くか | TC-UI-NN |
tests/ui/specs/*.spec.ts |
| シナリオテスト | 複数画面・業務フロー | 実業務ストーリーが完遂できるか | TC-SCN-NN |
tests/ui/specs/scenario.spec.ts |
2.1 TC-MAC(静的検証)と TC-UT(ユニット)を分ける
両方 test.sh static で走るのに ID を分けているのは、壊れたときの直し方が違うからです。
`TC-MAC` の FAIL は「ファイルが無い / 構文が壊れている」= 構成の問題、`TC-UT` の FAIL は
「ロジックが仕様と違う」= 実装の問題。同一プレフィックスに混ぜると、result.md を見ても
どちらのレイヤが壊れたか分からず、原因特定が 1 段遅れる。カバレッジ下限判定 (§3.2) も
ユニット側だけに掛けたいので、ID で切り分けておく。
エージェントに「原因を特定して直せ」と言うとき、ID のプレフィックスが最初のヒントになります。ここが混ざっていると、構成の問題なのに実装を触り始めます。
2.2 結果は Markdown テーブルに集約する
tests/test.sh(bash)は結果を配列に貯めて、最後に tests/results/<run-id>/result.md を書き出します。
RESULTS=(); PASS=0; FAIL=0; SKIP=0
record(){
local id="$1" st="$2" name="$3" detail="${4:-}"
name="${name//|/\\|}"; detail="${detail//|/\\|}" # | エスケープ
RESULTS+=("${id}|${st}|${name}|${detail}")
}
ok(){ PASS=$((PASS+1)); record "$1" PASS "$2"; printf ' [PASS] %s %s\n' "$1" "$2"; }
ng(){ FAIL=$((FAIL+1)); record "$1" FAIL "$2" "${3:-}"; generate_report; exit 1; }
skip(){ SKIP=$((SKIP+1)); record "$1" SKIP "$2" "${3:-}"; printf ' [SKIP] %s %s\n' "$1" "$2"; }
実際の出力(一部)です。
## サマリー
- run id: `20260812-141749`
- PASS: 165 (内訳: bash 29 / TC-UI 126 / TC-SCN 10)
- FAIL: 0 (内訳: bash 0 / TC-UI 0 / TC-SCN 0)
- SKIP: 0 (内訳: bash 0 / TC-UI 0 / TC-SCN 0)
## TC-MAC: 静的検証 / TC-UT: ユニットテスト
| ID | Status | Name | Detail |
| --- | --- | --- | --- |
| TC-MAC-03 | PASS | docker compose 構文 | |
| TC-MAC-04 | PASS | .env.example キー網羅 | |
| TC-UT-01 | PASS | API ユニットテスト (C0+C1) | |
| TC-UT-02 | PASS | API カバレッジ下限判定 | line=100 branch=100 func=100 stmt=100 (下限 100/100/100/100) |
| TC-MAC-10 | PASS | 設定値が compose まで配線されている | |
| TC-MAC-11 | PASS | 依存に high 以上の既知脆弱性が無い | |
内訳の件数が出ているのが重要です。「PASS: 165」だけだと、TC-UI が 0 件でも成立してしまいます。区分ごとの件数を出しておくと、「UI テストが 1 件も走っていない PASS」を人間もエージェントも一目で検出できます。
3. テストピラミッド — 「その内容を検証できる最も下の層で」
エージェントに「この機能のテストを書いて」と頼むと、だいたい E2E を書きます。画面を触るテストは仕様書との対応が取りやすく、書いた感があるからです。結果として、境界値 20 パターンをブラウザ操作で確認する遅くて不安定なテスト群ができます。
規約側で、置き場所を指定します。
### 3.1 テストピラミッド原則 (下層優先)
**検証は、その内容を検証できる最も下の層で行う**。下層ほど高速・安定・原因特定が容易なため、
ユニットテストで検証できるロジック (境界値・型違反・形式検証・計算結果・分岐・
権限判定ロジック) は **ユニットテストで担保** し、機能テスト / シナリオテストは
**ユニットテストでは検証できない内容** (画面表示・DOM 操作・画面遷移・業務フロー・
複数画面横断の整合) に専念する。
- 同じ検証を上層で重複させない (= ユニットで担保済の境界値を機能テストで再網羅しない)
- 機能テストは「ロジックが UI に正しく結線されているか」を代表ケースで検証する
- シナリオテストは「業務ストーリーが通しで完遂するか」だけを見る
そのうえで、ファイルを物理的に分けます。
> 機能テストは速く狭く、シナリオテストは長く広く。混在させると目的が曖昧になるため
> spec ファイルを分離する (`*.spec.ts` vs `scenario.spec.ts`)。
3.1 「上層で重複させない」の具体例 — ログインを毎回 UI で通さない
全 API がログイン必須になった構成で、機能テストを毎回ログインフォームから始めると、1 テストあたり数秒が積み上がります。ここは fixture でセッションを注入し、「ログインできること」自体は専用のテストケースだけで見ます。
export const test = base.extend<Fixtures>({
authRole: ['admin', { option: true }],
// ページ遷移前に localStorage へセッションを仕込む。
// 実 UI のログインフォームを毎回通すと 1 テストあたり数秒増えるうえ、
// 「ログインできること」は TC-UI-90 番台で個別に検証しているので二重にしない
// (テスト規約 §3.1 = 同じ検証を上層で重複させない)。
_authenticate: [async ({ page, authRole }, use) => {
if (authRole !== null) {
await page.addInitScript(session => {
localStorage.setItem('app.auth.v1', JSON.stringify(session));
}, storedSession(authRole));
}
await use();
}, { auto: true }],
});
コメントに 規約の節番号を書いておくのがコツです。エージェントがこのファイルを読んだとき、「なぜログインを踏まないのか」を規約に照会できます。これが無いと、次のセッションで「テストの網羅性を上げます」と言ってログイン手順を全テストに足してきます。
権限違いのテストは、spec の冒頭で切り替えるだけで済みます。
test.use({ authRole: 'staff' }); // 権限による表示制御・403 の検証
test.use({ authRole: null }); // 未ログイン → /login への強制遷移の検証
4. カバレッジ — 100% 目標・除外禁止・下限ラチェット
第 1 回でも触れた「カバレッジ 100% を目標とする」の抜け道潰しを、実装まで含めて書きます。
4.1 除外ではなく mock
| 到達困難の類型 | 例 | 必須対応 |
| --- | --- | --- |
| **外部依存** | 子プロセス起動 / ネットワーク / 外部 API / ファイル I/O / 時刻・乱数 | **mock / stub / 一時資源で当該経路を強制実行**し計測対象に含める |
| **例外・異常系** | `catch` 節 / タイムアウト / 外部コマンド非 0 終了 / パース失敗 | mock を失敗させて `catch` を実際に通す |
| **環境差分** | env 未設定 / フラグ off / 権限なし | env を一時上書きして両分岐を通す |
- 外部依存は **依存注入 (引数で `fetchImpl` / `runner` 等を渡す) を第一選択**とし、
困難な場合のみ `jest.mock` 等のモジュール mock を使う (= テスト容易性は設計で確保する)。
- 正常系だけでなく **異常系 (例外 throw / 非 0 終了 / 空応答) を必ず別ケースで通す**。
- どうしても除外する場合は **コメントで理由を明記**し、レビューで個別承認する (= 無言の除外禁止)。
「依存注入を第一選択」と書いておくと、設計にフィードバックが効きます。モジュール mock は「テストのために後から被せる」ものですが、依存注入は「テストしやすい形に実装を書く」ことなので、エージェントが最初からその形で実装してきます。
4.2 下限ラチェット — 「目標」だけでは完了条件にならない
100% は目標であって FAIL 条件ではありません。目標だけだと、テストを足さずにコードだけ増えても全体は緑のままです。そこで下限を定数で持ち、下回ったら FAIL させます。
# ---------------------------------------------------------------------
# カバレッジ下限ラチェット (テスト規約 §3.2.1)
#
# 「目標 100%」だけでは、テストを足さずにコードだけ増えても全体が緑のままになる。
# 実測値のすぐ下に下限を定数で置き、下回ったら FAIL させる。
#
# - 下限は目標ではなくラチェット (= 今より下がったら止める線)。到達目標は 100%。
# - カバレッジを上げたらこの定数も上げる (上げないと次の退行を検出できない)。
# - **下限を下げる変更は禁止**。下げたくなったらテストを足す場面。
# ---------------------------------------------------------------------
COV_MIN_LINE=100
COV_MIN_BRANCH=100
COV_MIN_FUNC=100
COV_MIN_STMT=100
判定は「計測」と「下限判定」を 別の TC ID に分けます。計測が落ちたのか下限割れなのかを、result.md の 1 行で区別できるようにするためです。
local below=()
# 小数を含みうるので awk で比較する (bash の [ -lt ] は整数のみ)
awk -v v="${line}" -v m="${COV_MIN_LINE}" 'BEGIN{exit !(v+0 < m+0)}' && below+=("line=${line}<${COV_MIN_LINE}")
awk -v v="${branch}" -v m="${COV_MIN_BRANCH}" 'BEGIN{exit !(v+0 < m+0)}' && below+=("branch=${branch}<${COV_MIN_BRANCH}")
awk -v v="${func}" -v m="${COV_MIN_FUNC}" 'BEGIN{exit !(v+0 < m+0)}' && below+=("function=${func}<${COV_MIN_FUNC}")
awk -v v="${stmt}" -v m="${COV_MIN_STMT}" 'BEGIN{exit !(v+0 < m+0)}' && below+=("statement=${stmt}<${COV_MIN_STMT}")
if [ ${#below[@]} -eq 0 ]; then
ok "${tc_cov}" "${label} カバレッジ下限判定" \
"line=${line} branch=${branch} func=${func} stmt=${stmt} (下限 ${COV_MIN_LINE}/${COV_MIN_BRANCH}/${COV_MIN_FUNC}/${COV_MIN_STMT})"
else
ng "${tc_cov}" "${label} カバレッジ下限判定" "下限割れ: ${below[*]}"
fi
result.md にはこう出ます。
| TC-UT-02 | PASS | API カバレッジ下限判定 | line=100 branch=100 func=100 stmt=100 (下限 100/100/100/100) |
数字を出すだけでは誰も見ません。下回った時点で FAIL にして初めて「気付ける」状態になります。
4.2.1 【追記】下限の「上げ忘れ」も FAIL にする — ラチェットの片効きを塞ぐ
この節は、公開後にコメントでいただいた指摘を受けて追記しました。
上の運用ルールのうち 「カバレッジを上げたら COV_MIN_* も上げる」だけが、機構ではなく人の規律に依存しています。ここが本記事で唯一残っていた穴でした。上げ忘れると、上げた分だけ黙って下がれる余地が残り続けます(ラチェットが片側にしか効かない)。
塞ぎ方は、実測が下限を一定幅以上上回った状態そのものを FAIL にすることです。
# 下限の更新漏れ検出 (= ラチェットの片効き防止)
# 実測が下限を COV_SLACK_MAX 以上上回っていたら「引き上げ忘れ」として FAIL。
COV_SLACK_MAX=2.0
# 下げる方向は下限判定が、上げ忘れはここが見る (= 両方向で止まる)
local stale=()
awk -v v="${line}" -v m="${COV_MIN_LINE}" -v s="${COV_SLACK_MAX}" 'BEGIN{exit !((v+0)-(m+0) > s+0)}' && stale+=("line=${line}>${COV_MIN_LINE}")
awk -v v="${branch}" -v m="${COV_MIN_BRANCH}" -v s="${COV_SLACK_MAX}" 'BEGIN{exit !((v+0)-(m+0) > s+0)}' && stale+=("branch=${branch}>${COV_MIN_BRANCH}")
# ... function / statement も同様
if [ ${#stale[@]} -eq 0 ]; then
ok "${tc_slack}" "${label} カバレッジ下限の更新漏れ判定" "実測と下限の乖離は ${COV_SLACK_MAX}pt 以内"
else
ng "${tc_slack}" "${label} カバレッジ下限の更新漏れ判定" \
"実測が下限を ${COV_SLACK_MAX}pt 超えて上回っています。COV_MIN_* を実測値まで引き上げてください: ${stale[*]}"
fi
実際に入れて動かした結果です。
[PASS] TC-UT-05 API カバレッジ下限の更新漏れ判定 — 実測と下限の乖離は 2.0pt 以内
(下限を 95 に下げて「引き上げ忘れ」を再現)
[FAIL] TC-UT-05 API カバレッジ下限の更新漏れ判定 —
実測が下限を 2.0pt 超えて上回っています。COV_MIN_* を実測値まで引き上げてください: line=100>95
判定を下限判定とは別の TC ID に分けるのがポイントです。直し方が逆になるためで、下限割れは「テストを足す」、更新漏れは「定数を上げる」。同じ ID に混ぜると result.md を見ても次の一手が決まりません(§2.1 で TC-MAC と TC-UT を分けたのと同じ理由)。
why(なぜ「実測値を定数に自動で書き戻す」を選ばないか)
「実測値を COV_MIN_* に書き戻すスクリプトを完了条件に入れる」でも穴は塞がります。それでも FAIL を選んだ理由は、規約側の値がレビューを通らずに動いてしまうからです。
§1 で「下限を下げる変更は禁止」と決めた以上、下限が動いた事実は PR の差分として人の目に入る必要があります。自動書き戻しにすると、下限は静かに上がり、下がるときも静かに(誰もレビューしないまま)動く経路ができます。FAIL で止めて実装者に上げさせれば、引き上げは必ず差分に残ります。
補足を 2 つ。
- 「一定期間続いたら」ではなく、乖離が閾値を超えた時点で即 FAIL にしています。履歴を持たずに済み、上げ忘れはその場で判明します
- 目標 100% に到達した時点で乖離は構造的に 0 になるので、この判定は自然に無害化します(100% の PJ では常に PASS)
4.3 計測の落とし穴(実測で踏んだもの)
| 落とし穴 | 症状 | 対策 |
|---|---|---|
| 計測が呼び出し元の環境に依存する | ランチャ経由(.env を export 済)だと process.env.X ?? 既定値 の分岐が通らず、同じコードなのに branch が 33% → 7% に落ちて下限判定の結果が変わる
|
ユニットテスト実行時は対象 env を env -u で落としてから走らせる。設定は buildConfig(env) のように引数で渡す
|
| 動的 import でモジュールを読み直す |
import('./config.ts?x=' + Math.random()) は別 URL のモジュール扱いになり カバレッジ計測に乗らない。テストは通るのに対象ファイルが branch 5.56% のまま |
動的 import で env を変えて読み直さない。env は引数で渡す |
| 計測のためだけにツールを増やす | ランタイム依存が増え、非力な本番機でのビルドが重くなる | 標準機能で足りるならそれを使う(node の --experimental-test-coverage 等)。得られるのが表の体裁だけなら足さない |
1 つ目は実装がこうなっています。
# why env -u: run.sh は `set -a; . .env` で VITE_*/SALES_NOTIFY_* を export する。
# これがユニットテストの ambient env に混入すると「env 未設定時のフォールバック分岐」が
# 被覆されず、同じコードなのに実行経路で branch が落ちる (テスト規約 §3.2.2)。
local unset_args=()
local k
for k in $(env | sed -n -e 's/^\(VITE_[A-Za-z0-9_]*\)=.*/\1/p' -e 's/^\(NOTIFY_[A-Za-z0-9_]*\)=.*/\1/p'); do
unset_args+=(-u "${k}")
done
# ${arr[@]+...}: set -u 下で空配列を展開してもエラーにしないための書式 (bash 3.2 対策)
if (cd "${dir}" && env ${unset_args[@]+"${unset_args[@]}"} npm test --silent) > "${cov_log}" 2>&1; then
「手元では 92%、CI では 87%」のような差が出たら、まずこれを疑う価値があります。エージェントに調べさせると、たいてい「テストを追加します」という方向に行って原因に到達しません。
4.4 計測対象に入っていないモジュールは一覧化する
これが最後の抜け道です。カバレッジツールは 「テストから import されたファイル」しか対象にしないので、ユニットテストを 1 本も持たないモジュールは表にすら現れません。全体 100% でも、モジュール半分が未計測ということが起こります。
意図的にユニットテストを持たせない場合は、docs/rules-project/(PJ 固有規約)に一覧と理由を書きます。判断基準は「外部 I/O の外側に判断(条件分岐・変換・順序)があるか」です。
| モジュール | 種別 | なぜユニットテストを持たせないか |
|---|---|---|
src/server.ts / src/routes/*.ts
|
HTTP 配線 | ルート定義とハンドラの結線しか無く、実際に動くかは HTTP を叩かないと分からない。ここは TC-RT(ランタイム)と TC-SCN(実 API・実 DB)が担当する。フレームワークを mock して「登録したルートが登録されている」ことを確かめても、本番で 404 になる原因(リバースプロキシ / パス / 認証)は 1 つも捕まらない |
src/db/pool.ts |
接続プール |
new pg.Pool(config) を組み立てるだけ。mock すると「渡した設定が渡っている」ことしか言えない。接続の成否は TC-RT-02(/health/db)が実接続で見る |
src/*/aggregate.ts / repo.ts
|
SQL 集約 | 中身の大半が SQL 文そのもので、mock しても検証できるのは「文字列を渡した」ことだけ |
逆に、DB を触るがロジックを持つモジュールは mock(偽の PoolClient)で 100% 近くまで通します。
`insertSamples` は SQL を投げる前に「0 件なら投げない」「1 バッチ内の自然キー衝突を畳む」を、
投げた後に「`xmax=0` の行だけを新規と数える」をやっている。ここが壊れても実 DB のテストでは
「件数が合わない」としか見えず、どの判断が壊れたか分からない。`archive.ts` も同じで、
**「外部ストレージへ書けたぶんだけ DB から消す」順序**はこの関数にしか無く、逆になれば
データは消えて戻らない。
mock して「渡した設定が渡っている」ことしか言えないテストは書かない。
本番で 404 になる原因を 1 つも捕まえられないためです。
5. 期待値 — 「出た / 動いた / 落ちた」で終わらせない
カバレッジは「その行を通ったか」しか言いません。通ったうえで出力が仕様どおりの値かを担保するのは期待値の書き方です。原則は「1 入力 → その入力から決まる出力のすべてを 1 ケースで確認」。
| 検証対象 | ❌ 不十分 | ✅ 必須 |
|---|---|---|
| 戻り値(スカラ) |
toBeTruthy / toBeDefined / 型だけ |
値そのものを完全一致(toBe) |
| 戻り値(DTO) |
toMatchObject / つまみ食い |
全プロパティを 1 回の toEqual。省略可能項目・null・空配列も明示 |
| 配列・一覧 |
length だけ / 先頭要素だけ |
件数 + 並び順 + 各要素の全項目 |
| 副作用(mock 呼び出し) | toHaveBeenCalled |
呼び出し回数 + 引数の全項目 |
| 例外 |
toThrow()(引数なし) |
型 + message + 独自プロパティ(code / details 等) |
| 画面の一覧 | toHaveCount(3) |
件数 + 並び順 + 各行の全列値 |
// ❌ status しか見ていない → 他の項目が全部壊れても緑
expect(result).toMatchObject({ status: 'accepted' })
// ✅ DTO は全項目を 1 回で完全一致させる
expect(result).toEqual({
id: 'RSV-0001',
status: 'accepted',
guestName: '山田 太郎',
seats: 4,
note: null, // 省略可能項目も「null であること」を書く
tags: [], // 空配列も書く
})
例外は stack が含まれるので完全一致が難しく、型・message・独自項目を「全て」個別に確認します。
// ❌ 型しか見ていない → メッセージ・エラーコードが別物でも緑
await expect(reserve(input)).rejects.toThrow(ValidationError)
// ✅ 型 + message + 付随情報まで
const err = await reserve(input).catch((e) => e)
expect(err).toBeInstanceOf(ValidationError)
expect(err.code).toBe('SEATS_OUT_OF_RANGE')
expect(err.message).toBe('席数は 1〜8 で指定してください') // 文言も完全一致
expect(err.details).toEqual([{ field: 'seats', given: 9, max: 8 }])
画面側も同じです。Playwright では toHaveText(string[]) が 件数・順序・各値を 1 行で同時に固定できるので第一選択にします。
// ❌ 件数だけ → 中身と並び順が全部壊れていても緑
await expect(page.getByTestId('sale-row')).toHaveCount(3)
// ✅ 件数 + 並び順 + 各列の値を配列で一致させる
await expect(page.getByTestId('sale-row-name')).toHaveText(['たこ焼き', 'かき氷', '焼きそば'])
await expect(page.getByTestId('sale-row-price')).toHaveText(['¥600', '¥400', '¥500'])
await expect(page.getByTestId('checkout-submit')).toBeDisabled()
why(なぜ部分一致では足りないか)
- 部分一致は **書いた項目だけを凍結**し、残りの項目は「仕様が無い」のと同じ状態になる。
DTO に項目が増えたとき、`toMatchObject` のテストは**何も言わずに通る**
(= 新項目が未検証で本番へ出る)。
- 例外は「落ちたこと」ではなく **どう落ちたか** が仕様。呼び出し側の分岐 (リトライするか /
画面に何を出すか) は `code` や `message` で決まるため、型だけの確認では**呼び出し側の仕様が
守られているか分からない**。
- 全項目一致にすると項目追加でテストが落ちるが、**それが正しい挙動**。落ちたら
「仕様書 → テスト → 実装」の順で直す。テスト側を部分一致に緩めて回避しない。
最後の 1 行が要点です。「項目を足したらテストが落ちる」を仕様として受け入れると決めておかないと、エージェントは真っ先に toMatchObject へ書き換えます。
判定基準を 1 行で書いておく
レビュー時に使える基準を規約に置いています。
> 判定の基準: **そのテストが PASS したとき「出力は仕様どおりの値と状態である」と言い切れるか**。
> 言い切れないなら、確認が足りていない。
要素の掴み方は data-testid に固定する
- ❌ CSS クラス / テキスト内容 / DOM 階層 (`div > div:nth-child(2)`) で掴む
- ✅ `page.getByTestId('checkout-cash')`
- why: クラス名や文言はデザイン変更・文言修正で変わるが、それは**機能の変更ではない**。
構造で掴むとテストが「見た目を変えたら落ちる」ものになり、落ちても意味が読み取れない
ノイズになる。testid は「このテストが何を掴んでいるか」を名前で示す役割も兼ねる。
そして「UI に新しい操作対象・判定対象を足したら、実装と同じコミットで data-testid を付ける」を必須にします。これを別コミットにすると、テストを書く番になったときに「掴めないので構造で取る」が発生します。
6. DB は「投入 → 実行 → 出力照合 → 事後照合 → 後始末」の 5 段
画面のテストだけを書かせると、「画面には正しく出たが DB には別の値が入っている」が丸ごと素通りします。翌日のバッチや別画面で初めて露見する、いちばん高くつく壊れ方です。
規約では 5 段構成を必須にしています。
| 段 | やること |
|---|---|
| 1. 投入 | そのテストが依存する行をデータセットとして宣言的に投入。対象テーブルは DELETE → INSERT(DBUnit の CLEAN_INSERT 相当)で毎回既知の状態から始める |
| 2. 実行 | 対象操作(関数 / API / UI 操作)を実行 |
| 3. 出力照合 | 戻り値・画面・レスポンスを §5 の粒度で確認 |
| 4. 事後照合 |
DB の状態を期待データセットと全行全列で比較(DBUnit の assertEquals 相当) |
| 5. 後始末 | テスト終了後にデータを初期化する(成功時も失敗時も) |
6.1 DBUnit 相当のヘルパを 3 本だけ持つ
Java なら DBUnit をそのまま使えますが、Node / TypeScript には無いので薄いヘルパを tests/db/ に置きます。API は規約の表と 1 対 1にしておくと、エージェントが規約から実装を引けます。
// DB データセットヘルパ (テスト規約 §4.6 — 投入 → 実行 → 出力照合 → 事後照合 → 後始末)。
//
// | 本ファイル | DBUnit 相当 | 役割 |
// | loadDataset() | CLEAN_INSERT | 対象テーブルを DELETE → INSERT |
// | assertDataset() | assertEquals | 期待データセットと**全行全列**比較 |
// | cleanDatabase() | DatabaseOperation.DELETE_ALL | 後始末 (5 段目) |
//
// why 期待データをファイルで持つか: テストコード内に散らばった SELECT + 個別 expect は、
// 列が増えたときに追随されず**抜けが静かに生まれる**。データセットファイルなら
// 「列が増えた = ファイルを直す」が強制され、FAIL の再現条件もそのまま残る。
/**
* 削除順 (子 → 親)。外部キー制約に引っかからない順序で並べる。
* **新しいテーブルを足したらここにも足す** — 足し忘れると後始末が効かず、
* 前のテストの残骸が次のテストに漏れる。
*/
const DELETE_ORDER = ['sale_items', 'sales', 'products', 'registers', 'owners', 'events', 'users'] as const;
const INSERT_ORDER = [...DELETE_ORDER].reverse();
事後照合の実装で効いているのは、データセットに書いていないテーブルも「空であること」を期待する点です。
/**
* 事後照合 (5 段目)。期待データセットと **全行全列** を比較する。
* データセットに書いていないテーブルは「空であること」を期待する
* = 余計な副作用 (別テーブルへの書込み) を検出できる。
*/
export async function assertDataset(name: string, options: AssertOptions = {}): Promise<void> {
const expected = readDataset(name);
const ignore = new Set(options.ignoreColumns ?? []);
for (const table of DELETE_ORDER) {
const expectedRows = (expected[table] ?? []).map(r => strip(r, ignore));
const actual = await getPool().query<Row>(`SELECT * FROM ${table}`);
// 並び順は DB が保証しないので、両者を同じ規則で正規化してから比較する
// (= 行の並びではなく「集合として一致するか」を見る。DBUnit の PK ソート相当)。
const exp = expectedRows.map(canonical).sort();
const act = actual.rows.map(r => strip(r, ignore)).map(canonical).sort();
// ... 件数 → 各行の順に差分を集めて 1 度に投げる
}
}
比較除外列のルールも規約で縛ります。
- **比較除外列は §4.7 (a) の「現在時刻をそのまま格納する列」と DB 採番のサロゲートキーのみ**。
除外列は**データセット側に明示列挙**する (無言の除外禁止 = §3.2 と同じ理由)。
**§4.7 (b) の「入力から導出される時刻」を除外列に入れてはならない**。
6.2 実装で踏んだ罠 2 つ
(1) 後始末は DELETE ではなく TRUNCATE
// why DELETE ではなく TRUNCATE か:
// テストは 1 実行で数百回ここを通る。DELETE は削除済み行 (dead tuple) を残すため、
// テーブルは空でもファイルが肥大し続け、**回を追うごとに削除が遅くなる**
// (実測で 1 テストあたり 7 秒 → 15 秒 → 27 秒 → 48 秒と倍増し、タイムアウトに至った)。
// TRUNCATE は領域ごと捨てるので毎回一定時間で終わり、肥大も残さない。
「テストが後半になるほど遅くなる」という症状で、原因が後始末側にあると気づくまで時間がかかりました。テストの実行時間が単調増加したら、まず後始末を疑う価値があります。
(2) TRUNCATE のロックが次のテストを止める
// why lock_timeout + リトライが要るか:
// TRUNCATE は **ACCESS EXCLUSIVE ロック**を取る (DELETE の ROW EXCLUSIVE と違い読み取りも止める)。
// Playwright は `afterEach` の**後**に page を破棄するため、後始末の時点でまだ画面が生きており、
// アプリの API 要求と競合しうる。既定では TRUNCATE が無期限に待ち、さらに後続の読み取りが
// その後ろに並ぶため、**次のテストのページロードが 30 秒タイムアウトする**。
// 短いロック待ちで諦めて数回やり直すことで、詰まりを持ち越さずに終わらせる。
const MAX_ATTEMPTS = 6;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const client = await getPool().connect();
try {
await client.query("SET lock_timeout = '2s'");
await client.query(`TRUNCATE TABLE ${DELETE_ORDER.join(', ')} RESTART IDENTITY CASCADE`);
return;
} catch (err) {
// 最後の 1 回で駄目なら、原因が分かる形で落とす (握りつぶさない)
if (attempt === MAX_ATTEMPTS) throw new Error(`cleanDatabase: ${MAX_ATTEMPTS} 回試行してもロックを取得できませんでした: ${(err as Error).message}`);
await new Promise(resolve => setTimeout(resolve, 300));
} finally {
client.release();
}
}
このとき症状は「特定の 3 テストが page.goto の load 待ちで 30 秒タイムアウトする」でした。画面側のテストが落ちているのに、原因は後始末の DB ロックです。エージェントに任せると、まず page.goto のタイムアウトを伸ばそうとします(= §1 で禁止した「タイムアウトを伸ばして通す」)。禁止条項を先に書いておくと、この分岐に入らずに原因側を探します。
6.3 並列実行とは両立しない
> **並列実行との関係**: CLEAN_INSERT は対象テーブルを消すため、**同じ DB を共有したままの
> 並列実行と両立しない**。並列にするならスキーマ / テーブル prefix / コンテナで分離し、
> 分離しないなら **直列実行 (`workers: 1`) にする**。「並列のままデータセットを使う」は
> 不安定なテストの典型なので選ばない。
これを書いていないと、テストが遅いという理由でエージェントが workers を増やし、ランダムに落ちるテストが出来上がります。そして次に「flaky なのでリトライを設定します」と言い出します。
7. 時刻 — (a) は幅、(b) は値、(c) は完全一致
時刻項目は、テストを弱める言い訳に最も使われます。「実行のたびに変わるので比較対象外にしました」は一見もっともらしく、レビューでも通りがちです。
分類してから決めます。
| 分類 | 例 | 検証方法 | 事後照合での扱い |
|---|---|---|---|
| (a) 現在時刻をそのまま設定 / 表示 |
created_at / updated_at / 「最終更新 12:34:56」 |
幅で確認: 実行前後に t0 / t1 を取り、t0 - 余裕 ≤ 値 ≤ t1 + 余裕
|
比較除外列にする(理由を明示) |
| (b) 入力から導出される時刻 | 受付日時 + 24 時間の期限 / 開始 + 所要時間の終了時刻 | 値で完全一致。基準時刻を入力として与えると期待値は一意に決まる | 除外にしない。値まで照合する |
| (c) 固定値・マスタ由来 | 営業開始 09:00 / 定期実行時刻 | 通常どおり完全一致 | 通常どおり照合 |
// ❌ 存在確認だけ / 現在時刻を取り直して完全一致 (処理時間の分だけ必ず落ちる)
expect(res.createdAt).toBeDefined()
expect(res.createdAt).toBe(new Date().toISOString())
// ✅ (a) 現在時刻由来は幅で確認する
const t0 = Date.now()
const res = await createReservation({ ...input, receivedAt: '2026-04-01T09:00:00+09:00' })
const t1 = Date.now()
const created = new Date(res.createdAt).getTime()
expect(created).toBeGreaterThanOrEqual(t0 - 5_000)
expect(created).toBeLessThanOrEqual(t1 + 5_000)
// ✅ (b) 入力から導出される時刻は値で確認する (基準時刻を入力で与えて一意にする)
expect(res.expiresAt).toBe('2026-04-02T09:00:00+09:00') // 受付 + 24h
expect(res.elapsedHours).toBe(24)
幅の決め方にも下限を置きます。
- 既定は **±5 秒**。処理時間の実測 + 余裕で決め、低速環境 (CI / ラズパイ等) を含めても収まる値にする。
- **幅の下限は「実行開始時刻 - 余裕」より前にしない**。幅を広げすぎると
「固定値やゼロ値が入っていても通る」テストになり、検証の意味が消える。
(b) を「揺れるから」と除外させないのがこの節の主目的です。有効期限・締切・経過時間はビジネスロジックそのもので、ここを除外すると「期限計算が壊れていても緑」になります。
固定時計(fake timer)は (a) をユニット層で厳密化できますが、DB 側の
now()/DEFAULT CURRENT_TIMESTAMPには効きません。DB が入れる列は「幅で確認 + 事後照合では除外列」で扱います。
8. 証跡 — PASS の証拠を画像とファイルで残す
ここが第 1 回の CI ゲート(L5)と接続する部分です。第 1 回では、CI 側で 「ジョブ開始後に証跡ファイルが増えていなければ、PASS 報告でも異常扱いに格上げ」 する設定を紹介しました。
# テスト実走証跡 path。
# ジョブ開始後に新規ファイルが作られていない場合 result=PASS でも ABNORMAL に格上げ
# (= log file に PASS マーカーだけ書く「手抜き PASS」を検出)。
test-evidence-paths: |
tests/results/
これが成立する前提は、テストを実行すると副作用として証跡が生成されることです。今回はその生成側を作ります。
8.1 最終状態は fixture で自動撮影する
各テストの最終状態は、test.afterEach ではなく auto fixture で撮ります。
// =====================================================================
// テストごとに最終状態のスクリーンショットを保存する `test` を提供する。
// - ファイル名: `<TC-UI-NN>-<viewport>.png`
// - PASS/FAIL いずれでも撮る (エビデンスとして残すため)
//
// 実装は Playwright の auto fixture を使う。これだと test.afterEach よりも
// 確実に「テスト本体の直後・page 破棄前」に呼ばれる。
// =====================================================================
_captureScreenshot: [async ({ page }, use, testInfo) => {
await use();
// skip されたテストはページ未遷移なので空白画像になってしまう。撮らない。
if (testInfo.status === 'skipped') return;
const tcMatch = testInfo.title.match(/^(TC-(?:UI|SCN)-[A-Za-z0-9_-]+)/);
const tcId = tcMatch?.[1] ?? testInfo.title.replace(/[^A-Za-z0-9_-]+/g, '_').slice(0, 60);
const file = path.join(dir, `${tcId}-${testInfo.project.name}.png`);
// ... 撮影
}, { auto: true }],
ファイル名を <TC-ID>-<viewport>.png にしておくと、あとで結果表に機械的に埋め込めます。テスト名から生成すると、文言を直した瞬間に過去の証跡と対応が取れなくなります。
撮影範囲にも罠があります。
// 余白を削るため、コンテンツの実際の高さで切り抜いて保存する。
// - fullPage:true だと長いページ (商品 80 件など) は縦長になりすぎ、
// fullPage:false (= viewport 全面) だと短いページで下半分が真っ白になる。
// - body.scrollHeight は `html,body,#root { height: 100% }` のせいで
// 常に viewport 高さになるため使えない。
// 代わりにヘッダ + バナー + .app-main の各子要素の bottom を計測して
// 「実コンテンツの最下端」を求める。
// - position:fixed のモーダル (削除確認など) は層が違うので getBoundingClientRect
// の bottom を別途 candidate に加える。
const contentHeight = await page.evaluate(() => {
const bottoms: number[] = [];
const push = (el: Element | null) => {
if (!el) return;
const r = el.getBoundingClientRect();
if (r.height > 0) bottoms.push(r.bottom);
};
push(document.querySelector('.app-header'));
document.querySelectorAll('.app-main > *').forEach(push);
document.querySelectorAll('[role="dialog"]').forEach(push);
return Math.ceil((bottoms.length === 0 ? 0 : Math.max(...bottoms)) + 16);
});
証跡は「あとで人間が見て判断するためのもの」なので、下半分が真っ白な画像が 100 枚あっても意味がありません。ここはコストを掛ける価値があります。
8.2 シナリオは各ステップで撮る
通しテストは「どこで壊れたか」が分からないと調査に使えないので、ステップごとに撮ります。
test('TC-SCN-01: イベント作成 → カートに商品 → 計上 → 売上確認', async ({ page }, testInfo) => {
await captureStep(testInfo, page, '01-イベント作成画面-空フォーム');
// ... 入力
await captureStep(testInfo, page, '02-イベント作成画面-入力済-作成ボタン有効');
// ... 作成
await captureStep(testInfo, page, '03-イベント作成完了-一覧に表示');
captureStep のラベルは日本語のまま使えるようにしています(ファイルシステムで使えない文字と空白だけを - に潰す)。結果表にそのまま載るので、画像を開かなくても流れが読めます。
8.3 result.md にスクリーンショット表 + 判定列を出す
証跡ファイルが散らばっているだけでは使われません。result.md に 2 つの表として組み立てます。
### TC-UI スクリーンショット (各テストの最終状態)
| TC ID | 判定 | テスト名 | iPad-portrait | iPad-landscape |
| --- | --- | --- | --- | --- |
| TC-UI-01 | ✅ PASS | ヘッダ + ハンバーガーメニューが iPad で見える | [png](...) | [png](...) |
| TC-UI-02 | ✅ PASS | イベント未選択時のガイダンス | [png](...) | [png](...) |
### TC-SCN スクリーンショット (ポートレイトのみ、各ステップを展開)
| TC ID | 判定 | テスト名 / 操作概要 | スクリーンショット |
| --- | --- | --- | --- |
| TC-SCN-01 | ✅ PASS | **イベント作成 → カートに商品 → 計上 → 売上確認** | |
| | | 01-イベント作成画面-空フォーム | [png](...) |
| | | 02-イベント作成画面-入力済-作成ボタン有効 | [png](...) |
| | | (テスト終了時点) | [png](...) |
判定列の作り方には 1 つルールがあります。
判定情報は **Playwright JSON reporter** (`PLAYWRIGHT_JSON_OUTPUT_FILE`) を
`scripts/playwright-result-tables.mjs` でパースして組み立てる
(= 文字列 grep ではなく構造化データに依存)。
標準出力を grep して判定を作ると、出力書式が変わった瞬間に全件 PASS 扱いになり得ます。「壊れたときに FAIL 側に倒れる」実装を選ぶのが原則です。複数 viewport の合算も決めておきます。
- 機能テスト (TC-UI): 2 viewport の status を集約。どちらかが FAIL → FAIL、
両方 PASS → PASS、両方 SKIP → SKIP。
- シナリオテスト (TC-SCN): portrait のみ実行するので portrait の status をそのまま採用。
8.4 証跡は「捏造しにくいもの」を選ぶ
第 1 回で書いた原則の具体化です。
- スクリーンショットは実際にブラウザが起動して画面が描画されないと生成されない
-
result.mdの件数内訳は、テストが走らないと増えない - カバレッジ HTML は、計測付きでユニットテストを走らせないと出ない
逆に、エージェントが自分で書けるもの(ログの PASS マーカー、サマリーの数字だけ)を証跡にしてはいけません。第 1 回の「守れ」ではなく「守った証拠を出せ」は、証拠の側に偽造コストの非対称性があって初めて機能します。
9. シナリオは仕様書から全パターンを導出する
「シナリオテストを書いて」と頼むと、正常系のハッピーパスが 1 本出てきます。例外・リカバリ経路は、頼まない限り永遠に増えません。書き漏らした業務パターンは差分に現れないので、レビューでも気づけません。
そこで導出手順を固定します。
1. **因子の洗い出し** — 業務フロー上の **条件分岐 / 状態遷移 / 権限 (ロール) / 例外イベント**
(差戻し・キャンセル・タイムアウト・通信断・在庫不足 等) を仕様書から列挙する。
2. **パターン表を作る** — 各因子の取りうる値を軸に、**組合せを行として列挙**する。
3. **各行に TC を割り当てる** — 1 行 = 1 `TC-SCN-NN`。実施しない行があるなら
**「なぜ不要か」を同じ表に書く** (無言の未実装は禁止)。
4. **表を テスト仕様書 に載せる** — これが網羅の証明になり、レビュアは表と TC の対応だけ見れば
漏れを判定できる。
出来上がる表はこうなります。
| # | 申請区分 | 承認結果 | 権限 | 期待終了状態 | TC ID | 備考 |
|---|---|---|---|---|---|---|
| 1 | 交通費 | 承認 | 一次のみ | approved |
TC-SCN-01 | — |
| 2 | 交通費 | 差戻し → 再申請 → 承認 | 一次のみ | approved |
TC-SCN-02 | — |
| 3 | 交通費 | 却下 | 一次のみ | rejected |
TC-SCN-03 | — |
| 4 | 接待費(10 万超) | 承認 | 一次 + 最終 | approved |
TC-SCN-04 | 二段承認 |
| 5 | 接待費(10 万超) | 一次で却下 | 一次 | rejected |
— | 却下経路は #3 と同一実装のため all-pairs で省略 |
5 行目のような「TC 未割当 + 理由」が書けることが、この表の価値です。組合せが爆発する場合は「各因子の各値を最低 1 回通す(all-pairs 相当)」まで落としてよく、ただし落とした根拠を表に書く、というルールにしています。
網羅基準の最低ラインも決めておきます。
| 観点 | 網羅基準 |
|---|---|
| 状態遷移 | 仕様書の状態遷移の 全遷移(辺)を最低 1 回通す。到達可能な全状態を最低 1 回作る |
| 条件分岐 | 条件付き仕様は 成立 / 不成立の両方を別 TC で書く |
| 権限 | ロールごとに独立したシナリオを作る |
| 例外イベント | 正常 / 例外 / 権限横断 / データ連携 / リカバリ の 5 分類を最低 1 本ずつ |
各ステップの確認粒度も、機能テストと同じにします。
> **確認粒度は機能テストと同じ**。シナリオだからといって
> 「最後に一箇所だけ見る」「遷移したことだけ確認する」は不可。
> **各ステップの操作直後に、その画面の出力項目を全確認する**。
> 通しで動かす意味は「途中の状態が正しく積み上がること」の確認にあり、
> 最終状態だけ見るなら機能テストの寄せ集めと変わらない。
10. セキュリティ観点はテスト側に持たせる
第 1 回では DAST(ZAP ベースラインスキャン)を同じランチャのモードに載せる話を書きました。ここではテストケース側の話です。
10.1 OWASP Top 10 は「全カテゴリに行を持たせる」
公開を伴う PJ(LAN 外から到達できる経路を持つもの)では、テスト仕様書に OWASP Top 10 の 10 カテゴリすべてを行に持つ表を置き、各行に「担保する自動テスト ID」か「適用外の根拠」を必ず書きます。
> **why 全カテゴリに行を持たせるか**: 「該当しない」と「見ていない」は成果物からは
> 区別できない。行が無いカテゴリは**誰も判断していない**のと同じで、
> 公開後に「そもそも検討していなかった」が起きる。適用外なら根拠を書けば 1 行で済む。
エージェントに「セキュリティテストも書いて」と頼むと、XSS と SQLi のケースが出てきて終わります。表の行を先に固定しておくと、A04(安全でない設計)や A08(完全性の不備)のような、思いつきでは出てこないカテゴリが埋まります。
実装のヒントも表に持たせています。
| カテゴリ | よくある実装 |
|---|---|
| A01 アクセス制御の不備 | ロール検証を サーバ側で行う。画面を隠すだけにしない |
| A02 暗号化の失敗 | API 応答のキー集合を完全一致で固定し、項目が増えたら落とす |
| A04 安全でない設計 | 登録時のロールは入力を見ずに固定する |
| A06 脆弱で古いコンポーネント |
npm audit --omit=dev 等を静的テストの 1 ケースとして常時実行し、high 以上で FAIL |
| A07 識別と認証の失敗 | 認証失敗の文言を一本化して存在の有無を漏らさない |
| A08 完全性の不備 | 別の鍵で署名した権限昇格トークンを拒否することを確認 |
10.2 「設定したのに効いていない」を静的テストで検出する
これは実際に効いた 1 本です。
設定ファイル (`.env` 等) にキーを足しても、コンテナ定義 (`docker-compose.yml` の
`environment` / `build.args` 等) に書かなければ**アプリには届かない**。
このとき多くの実装は「未設定 → 既定値」にフォールバックするため、
**「設定したのに効いていない」ことに動作確認では気付けない**。
- `.env.example` のキー集合と、コンテナ定義に渡しているキー集合の**差分を検査する
テストケースを 1 本持つ** (`TC-MAC-NN`)。
- 特に **ビルド時に埋め込まれる値** (Vite の `VITE_*` 等) は、実行時に差し替えられないため
配線漏れが致命的になる。
このテスト 1 本で、VITE_* 4 件と通知設定 12 件の配線漏れが実際に見つかりました。どれも「設定は書いてあるが効いていない」状態で、画面は既定値で動いていたため誰も気づいていませんでした。
10.3 「出力されないこと」のテストは空振り防止を先に書く
否定の検証は、検査対象が空でも成立します。
// ❌ ログ取得に失敗しても通る (= 何も検証していない)
expect(logs).not.toContain(password)
// ✅ 取得できたことを先に固定してから、非出力を見る
expect(logs.length).toBeGreaterThan(0)
expect(logs).toContain('/auth/login') // 直前の操作が記録されている
expect(logs).not.toContain(password)
同じ理由で、「設定値が UI に反映される」テストは期待値が既定値と一致していないかを確認します。既定値と同じ値を設定していると、配線が切れていても通ってしまいます。
この「空振りするテスト」は、エージェントが書くテストで最も頻出する欠陥です。指示どおりのアサーションは書かれているのに、前段が無いので何も保証していません。
11. レビュー基準をチェックリストにしておく
規約の最後に、レビュー合格条件を列挙しています。エージェントに自己レビューさせるときの入力にもなります。
- 曖昧表現が含まれない (「正しく」「適切に」「問題なく」が 1 つも無い)
- **出力項目が「存在」ではなく内容・状態まで確認されている**。一覧は件数 + 並び順 + 各行の全列値
- **ユニットテストの期待値が全項目確認になっている** — DTO は `toEqual` で完全一致、
例外は 型 + message + コード + 付随情報、部分一致 (`toMatchObject`) で済ませていない
- **テストデータの投入 (CLEAN_INSERT) と事後照合 (全行全列) がある**。
比較除外列が明示され、除外理由が書かれている
- **時刻項目が (a) 現在時刻 / (b) 導出値 / (c) 固定値に分類されている** —
(a) は幅で確認し事後照合では除外、**(b) は値で確認 (除外していない)**
- **シナリオが仕様書由来のパターン表と 1 対 1 で対応している**。
TC 未割当の行に「なぜ不要か」が書かれている
- **公開を伴う PJ では OWASP Top 10 の 10 カテゴリすべてに「TC」か「適用外の根拠」がある**
- **設定値がコンテナ定義まで配線されているかを検査する TC がある**
- **「出力されないこと」のテストに空振り防止の前段がある**
12. 導入チェックリスト
既存プロジェクトに入れるなら、この順番が費用対効果が高いです。
-
テストを 5 区分に割り、ID プレフィックスで層を分ける(
TC-MAC/TC-UT/TC-UI/TC-SCN) -
結果を
result.mdに集約し、区分ごとの件数内訳を出す(「UI テスト 0 件の PASS」を検出可能にする) - 「テストを弱める変更」の禁止表を規約に書く(削除 / skip / 期待値の書き換え / アサーション緩和 / 除外 / タイムアウト延長)
-
カバレッジ下限(
COV_MIN_*)を 実測値のすぐ下に置き、下回ったら FAIL させる -
ユニットテストを
env -uで環境から独立させて走らせる - 計測対象に入っていないモジュールを一覧化し、理由を書く
-
期待値を全項目一致に寄せる(
toMatchObjectを禁止し、一覧はtoHaveText(string[])) - DB を触るテストに 投入 / 事後照合 / 後始末 を入れる(ヘルパ 3 本)
- 時刻を (a) 幅 / (b) 値 / (c) 一致 に分類する
-
スクリーンショットを fixture で自動撮影し、
result.mdに判定列付きの表として載せる -
CI の
test-evidence-pathsに証跡ディレクトリを指定する(第 1 回の CI ゲートと接続)
まとめ
第 1 回の原則をテスト設計に適用すると、こうなりました。
| 第 1 回の原則 | 本記事での適用 |
|---|---|
| 抜け道を先に塞ぐ | 「テストを弱める変更」を具体的な変換の形で禁止し、正規の逃げ道(仕様書 → テスト → 実装)を 1 本だけ残す |
| 「守れ」ではなく「守った証拠を出せ」 | 証跡(スクリーンショット / result.md / カバレッジ HTML)の生成を完了条件に組み込み、捏造コストの高いものを証拠に選ぶ |
| 検出方法が思いつかないルールは書き直す | 「適切なテスト」ではなく「全項目一致」「全行全列比較」「(b) は値で確認」と、機械判定可能な形に落とす |
エージェントは、規約の穴を最短で見つけます。テスト設計とは、その最短経路を全部塞いだうえで、正しい道だけを残す作業でした。
次回
第 3 回は、AI が書いた PR をレビューする話を書く予定です。レビュー負荷が決まるのはレビューを始める前だという話(再生成物を差分に混ぜない、PR 本文に証跡と変更理由を必須化する)、レビューコメントへの追従を自動化したときに必ず出る「対応したふり」をどう機械検出するか、そして人間が見る点と機械に任せる点の切り分けが中心になります。
筆者について
個人開発で PWA・API・IaC 周りを触りつつ、AI エージェントに実装を任せるための足場(規約・CI・自動化)を整えるのが最近の関心です。この記事で紹介したテスト規約・ヘルパ・CI 設定は、テンプレートリポジトリとして手元で運用しています。
- GitHub: @KenichiOsakada
「うちではこの抜け道をこう塞いだ」という知見があれば、コメントで教えてもらえると嬉しいです。