この記事で話すこと
AI にフロントエンドの作業を任せてみると、面白いことに気づきます。「間違えました」とはほとんど言わないんです。 でも成果物を開いてみると、要件と微妙に違っていることが多いんですよね。
なぜでしょう。答えは少し拍子抜けします。AI に「何が正解か」を伝えたことがないからです。
この記事は、私が使っている frontend-oracle-design という作業方式の哲学をまとめたものです。核心は三つだけです。
- 正解をテストコードでできるだけ多く再現する — AI が答案用紙を見ながら答えを探せるようにします
- ハーネスエンジニアリングとベストプラクティスで実装する — 「テストが揺れただけです」をなくします
- セルフフィードバックと独立したレビュアーを付ける — 自分が書いたものは自分では見えないからです
途中で用語がいくつか出てきますが、出てくるたびに噛み砕いて説明しますね。
まず: AI のコードはなぜ静かに間違うのか
背景知識
Ground truth(グラウンドトゥルース) は「本当の正解」という意味です。機械学習から来た言葉で、モデルが何と言おうと関係なく実際に正しい値を指します。これがないと採点そのものができません。
オラクル(oracle) はその答案用紙の役割を果たす基準です。試験の答案(コード)をいくら睨んでも正解かどうかは分かりませんよね。別に答えが必要なんです。
実際に何が起きるか
AI はリポジトリ全体を読めます。既存のコードも、既存のテストも、ブラウザでどう動くかも。だからこの推論をとても自然にやります。
「今こう動いているな。じゃあこれが要件なんだろう」
ここで事故が起きます。バグが仕様に昇格する瞬間です。
二つ目の事故はテスト側で起きます。AI に「テスト書いて」と頼むと、こういうものが返ってきます。
// 実装を一度動かして出た値をそのまま貼り付けたテスト
expect(formatDiscount(1000, 0.1)).toBe("900円");
このテストは 100% 通ります。そして何も検証していません。 丸めの規則が誤っていても通貨表記が誤っていても、実装がそう返すから期待値もそう書かれただけなんです。
結果として「後で壊れたら教えてくれる」機能は果たすのに、最初から間違っているものは絶対に捕まえられないテストになります。
だから必要なルール
正解を決められる場所を、きっちり二つだけに制限します。
| ポリシー出所(正解を決められる) | 調査資料(正解を決められない) |
|---|---|
| 利用者の明示的な回答 | 今動いているプロダクトコード |
| 承認済みの仕様・受入基準 | すでにあるテスト |
| ブラウザで観察した挙動 | |
| レビュアーの個人的な好み |
右側は**「今何が起きているか」を教えてくれますが、「何が正しいか」は教えてくれません。だから実装と要件が食い違ったら、コードに合わせずに止まって聞く**んです。
このルール一つで、さきほどの事故のほとんどを防げます。
柱 1 — 正解をテストコードでできるだけ多く再現する
なぜわざわざテストに移すのか
会話で合意した要件は、長い作業のなかで必ずぼやけます。 最初は「一覧が空なら案内文を出す」だったのに、実装しているうちにいつの間にかスピナーが回り続ける形で終わっている。誰も嘘をついていないのにそうなります。
だから要件を先に行単位の表として書きます。私はこれを Oracle Card と呼んでいます。
| # | この状況で (Given) | こうすると (When) | こうなるべき (Then) | 絶対にダメ (Never) | 出典 |
| --- | ---------------------- | ----------------- | ------------------- | -------------------- | ------------- |
| 1 | 一覧が空 | ページ進入 | 案内文を表示 | スピナーが回り続ける | 仕様書 3.2 節 |
| 2 | 要求が5秒超過 | 自動キャンセル | 再試行ボタン表示 | 無限待機 | 利用者の回答 |
| 3 | 同じ項目を連続クリック | 2回目以降は無視 | 要求は 1 回だけ | 重複要求 | 利用者の回答 |
出典の列が本当に大事です。 出典を書けない行は空欄のままにして質問します。ここで「普通こうするから」と埋めた瞬間、その行は検証ではなく推測になってしまうんです。
そのままテストに移します
表の各行がテスト一つになります。行番号をテスト名に入れておくと、後から追いやすくなります。
// O3: 同じ項目を連続で押しても要求は一度だけ
it("O3 — 重複クリックでも要求は一度だけ送る", async () => {
const calls: string[] = [];
server.use(
http.post("/api/favorite", async ({ request }) => {
calls.push(await request.text());
return HttpResponse.json({ ok: true });
}),
);
render(<FavoriteButton itemId="a1" />);
const button = screen.getByRole("button", { name: "保存" });
await userEvent.click(button);
await userEvent.click(button); // 2 回目のクリック
await waitFor(() => expect(calls).toHaveLength(1));
});
ここで**Never の行が特に価値があります。** 「こうなってはいけない」という条件は人があまり書かないのに、実際の事故はほぼそこで起きるんです。重複要求、無限待機、二重決済といったものですね。
AI が答案用紙を見ながら答えを探せるようにします
これがこの方式の核心です。順序を固定します。
1. 正解の表を固定する ← ここから変えてはいけない
2. テストを先に書く
3. 実行して「意図した理由で」失敗するのを確認する ← VALID_RED
4. そこで初めて最小限の実装をする
5. 通す ← GREEN
3 番が見た目より重要です。VALID_RED は「新しく書いたテストが意図したその理由で失敗している状態」を指します。
なぜ確認するかというと、誤字や設定の問題で赤いのを本当の失敗と取り違えると、見当違いのところを直したうえで「直った」と誤解してしまうからです。体温計が壊れて 39 度が出たのか、本当に熱があるのかを先に区別するようなものですね。
そして3 番の前にはプロダクトコードを一行も書きません。 この順序を守るだけで、「実装を先に作ってそこから期待値を逆算する」事故が構造的に起こらなくなります。
合意はテキストで固定し、ハッシュで確認します
作業が長引くと表そのものが少しずつ変わることもあります。だからファイルとして保存して内容ハッシュを書いておきます。
shasum -a 256 docs/acceptance/favorite.md
# 3f2a... docs/acceptance/favorite.md
各段階の前にハッシュを再確認します。合わなかったら?
固定し直して通してはいけません。 それまでに集めた証拠を捨てて、人に聞く状態へ戻ります。検証を通すために検証基準を変えたら、それはもう検証じゃないですよね。
柱 2 — ハーネスエンジニアリングとベストプラクティス
ハーネスって何ですか
ハーネス(harness) は、テストを実際に動かす装置をまとめて呼ぶ言葉です。テストランナー、偽のサーバー、画面の要素を探す方法、待つ方法などですね。
正解の表が「何が正しいか」を担当するなら、ハーネスは**「その判定を信じられるか」**を担当します。両方必要です。判定基準がどれだけ正確でも、テストがランダムに揺れたら結果を信じられませんから。
偽の緑を三つ禁止します
「通りました」を作る抜け道は、実は数えるほどしかありません。
| 抜け道 | 何が問題か |
|---|---|
| assertion を緩める | toBe(3) → toBeGreaterThan(0) にすると通りますが検証は消えます |
失敗するテストを skip にする | 緑なのに、実際は誰もその経路を見ていません |
任意の sleep を入れる | タイミング問題を覆い隠すだけで CI で再発します |
「手を抜かないでください」は守られませんが、名前を付けて禁止事項に書いておくと意外なほど守られます。
待つのは時間ではなく条件で
// ❌ なぜ 300 なのか誰も分からず、遅い CI で壊れます
await new Promise((r) => setTimeout(r, 300));
expect(screen.getByText("保存しました")).toBeInTheDocument();
// ✅ 「この状態になるまで」待ちます
await screen.findByText("保存しました");
sleep は「300ms あれば足りるだろう」という推測です。推測をテストに入れると、そのテストはいつか必ず裏切ります。
ネットワークは MSW で受け止めます
MSW(Mock Service Worker) は実際のネットワーク要求を横取りして偽の応答を返す道具です。
// ✅ 本物の要求が出て、それを途中で受け止めます
server.use(http.get("/api/items", () => HttpResponse.json({ items: [] })));
// ❌ fetch 自体を差し替えると、アプリがネットワークをどう使うかは検証できません
globalThis.fetch = vi.fn().mockResolvedValue(/* ... */);
たとえるなら、MSW は本物の郵便受けはそのままにして配達員だけ役者に替えるやり方で、fetch の差し替えは**郵便受けごと無くして「手紙を受け取ったことにしよう」**とするやり方です。前者のほうがずっと現実に近いですよね。
テストは所有するコードの隣に置きます
ルートに e2e/ と mocks/ を作って全部まとめたくなります。楽ですから。
でもそうすると、機能を消したときにテストが消えません。誰も見ない死んだテストが溜まって、しまいには消していいのかどうかすら判断できなくなります。
✅ src/features/favorite/
├── FavoriteButton.tsx
├── __test__/FavoriteButton.test.tsx
└── __test__/handlers.ts ← この境界の MSW handler
❌ e2e/ ← 全機能が混ざる
mocks/handlers.ts ← 誰が使っているのか分からない
一緒に生まれて一緒に死ぬようにしておくほうが、管理コストはずっと安く済みます。
ベストプラクティスは「正解」ではありません
ここは混同しやすいところです。外部のガイドやブログのベストプラクティスは、実装方法を選ぶときにだけ使います。要件を決めるのに使ってはいけません。
自分のリポジトリの規約と実際にインストールされているバージョンをまず確認して、それとぶつからない範囲でだけ参考にします。「最近はみんなこうするらしいです」はポリシー出所ではありません。
予算を決めておきます
無限リトライほど時間を燃やすものはありません。だから上限を決めます。
| 活動 | 上限 |
|---|---|
| ポリシーの質問 | 2 ラウンド |
| テストの機械的な補正(locator など) | 2 回 |
| 実装の改善 | 3 ラウンド |
| ブラウザ検証・自己改善 | 2 ラウンド |
互いに融通し合えません。使い切ったら最後の本当の失敗と一緒に失敗として報告して止まります。「できるまでやってみます」よりずっと誠実ですし、結果的に速いです。
柱 3 — セルフフィードバックと独立したレビュアー
ブラウザで実際に押してみます
ユニットテストが緑でも、画面が無事だという保証はありません。だからブラウザで開けるものは実際に操作した証拠を残します。
正解の表の各行が、どの証拠で確認されたのかを対応づけます。
O1 → unit: 「一覧が空なら案内文を表示する」
O2 → browser: ネットワーク 5 秒遅延のあと再試行ボタンをクリック → 要求の再発生を確認
O3 → unit: 「重複クリックでも要求は一度だけ送る」
D1 → browser: 320px でボタンが切れない(スクリーンショット)
対応づけに空欄が残っていたら、それはまだ終わっていないということです。「テストが通ったからいいでしょう」で先に進まないのがポイントです。
観察一つに原因一つを付けます
ブラウザやレビューで何かおかしいものを見つけたとき、反射的にコードを直してはいけません。まず分類します。
| 分類 | どういう意味か | 許される行動 |
|---|---|---|
POLICY_GAP | 正解がまだ決まっていない | 表の現在版と質問を出して止まる |
EVIDENCE_GAP | 決まっているが確認していない | 固定された範囲内で対応づけだけ追加 |
HARNESS_DEFECT | 道具の問題 | locator・fixture だけ補正(2 回の予算) |
PRODUCT_DEFECT | 本物のバグ | 失敗を確認してから実装を修正(3 ラウンドの予算) |
ENVIRONMENT_DEFECT | 環境の問題 | コードに触れず失敗として報告 |
NON_ORACLE_OPINION | 出所のない好み | 記録するだけで完了を妨げない |
こう分けておくと、**「テストが赤いからとりあえずコードを直した」**という反射がなくなります。赤い原因が道具の不備だったのにプロダクトコードを直してしまう事故は、思ったより多いんです。
最後の NON_ORACLE_OPINION も地味に重要です。出所のない好みで完了を止めるとレビューが終わりませんから。ただし出所のある美的要求はポリシーです。 承認済みのデザインと違うなら、それは好みの問題ではなく明白な不一致ですよね。
独立したレビュアーを別に付けます
自分が書いたコードを自分で見ると、同じ思い込みを繰り返します。 自分の文章の誤字は他人のほうがよく見つけますよね。同じことです。
だからメインの作業と分離したセッションにレビューを任せます。レビュアーには正解の表と成果物だけを渡して、途中の経緯や「これだけ苦労した」という文脈は渡しません。文脈を渡すとレビュアーも一緒に説得されてしまうので。
レビュアーの役割も明確です。証拠と批評は出しますが、ポリシーを新しく決めることはありません。 レビュアーが「これはこうするのが正しいと思うのですが」と言っても、それが表になければルールではなく質問になります。
完了の定義を後ろへ動かします
これが三つ目の柱の核心です。
IMPLEMENTED_GREEN → テストが実際に通った
BROWSER_VERIFIED → そこにブラウザの証拠まで付いた
REVIEW_VERIFIED → 独立レビューの指摘まで反映して再検証を通過 ← これが完了
なぜそうするかというと、テスト通過が完了なら通すことが目的になるからです。その瞬間から assertion が少しずつ痩せていきます。人が悪いのではなく、目標がそちらを向いているんです。
完了をレビューの後ろへ動かせば、緩める利点そのものが消えます。 どうせ後ろで引っかかりますから。
三つの柱はどう噛み合うのか
表を固定 ──► テストで再現 ──► 最小実装 ──► ブラウザ証拠 ──► 独立レビュー
(柱 1) (柱 1) (柱 2) (柱 3) (柱 3)
│ │ │ │ │
└────── ハーネスが全区間の信頼度を担当 (柱 2) ────────────┘
│
食い違ったら ─► 分類 ─► 定められた経路でのみ戻る
一文にまとめると、こうなります。
正解は人が決めて、テストがそれを代わりに覚えて、ハーネスがその記憶を信じられるものにして、レビュアーが最後にもう一度疑います。
この方式が過剰になるのはいつか
正直、いつでも良いわけではありません。
- 何を作るか自体が未定のプロトタイプ。 表を埋めようとすること自体が無駄です。まず作って捨てるのが正解です。
- 正解がもともと複数ある領域。 レイアウトの微調整、文言、推薦順序などは単一の期待値に固定すると、正常な変更のたびにテストが壊れます。こういうものは不変条件(重なりがない、件数が保存される)や視覚回帰で扱います。
- 一行の修正。 誤字を直すのに表は要りませんよね。
リスクに応じて強度を調節するのが現実的です。決済・権限・データ移行のように間違えると痛いところに集中させて、残りは軽く進めます。
まとめ
AI を制御する核心は、プロンプトを上手に書くことではありませんでした。「正解を決める権限」をどこに置くかでした。
| 柱 | 一行まとめ |
|---|---|
| 1. 正解をテストで再現 | 口約束はぼやけます。表に書いてテストへ移せば機械が覚えてくれます |
| 2. ハーネスエンジニアリング | 判定を信じられるものにします。sleep・skip・緩い assertion は禁止 |
| 3. セルフフィードバック + 独立レビュー | 完了をレビューの後ろへ動かせば、通すためだけに緩める理由が消えます |
そして三つを貫く原則が一つあります。
確信が足りないときは、もっともらしい答えを選ばずに「分からない」と記して止まりましょう。
面白いことに、これはまったく別のところでも同じように現れます。ウェブ文書のハイライトを復元するとき、候補区間にスコアを付けたうえで1 位と 2 位の差が小さければ候補があっても付けないというルールがあるんです。見当違いの場所に付くほうが、付かないより悪いですから。
一方は人へ質問を返し、もう一方はハイライトを諦めますが、目的は同じです。静かに誤った結果を作らないことです。