目次
AssertionError: expected '勝者' not to contain '勝者'
決め手ログというアプリのテストスイートには、本当にこの失敗メッセージを出すテストがある。リポジトリの一時コピーで、決め手タグのラベル「価格」を「勝者」に書き換えてテストを走らせたら、本当にこれが出た。
ただし、先に正確なところを言っておく。リポジトリのどこに「勝者」と書いても落ちるわけではない。同じコピーで、実装ファイルのコメントに「勝者」と書いて走らせたら、対象のテスト24件はすべて通った。今回確認できた範囲では、落ちるのは共有ラベルや集計結果のような、ユーザーやデータに届く面に書いたときだ。タイトルはキャッチーな要約で、この記事はその線引きがどう作られているかの話になる。
「勝者」が存在しないサービス
決め手ログは、2つか3つの候補で迷ったとき、「自分ならどれを選ぶか」とその決め手をみんなに投票してもらう比較投票アプリだ。開発初日の最初のcommit(19d40cb、2026年8月7日17時45分)に入っている上流仕様の冒頭に、こう書いてある。
決め手ログは「どちらが上か」「どちらが優れているか」を決めるサービスではない。 候補同士に客観的な優劣・勝敗を付けず、「自分ならどれを選ぶか」と、その決め手を共有・記録する。 投票結果は多数派や選択傾向を示すものであり、「勝者」「Winner」「1位」など候補の優劣を連想させる表現は使わない。 王冠など勝敗を想起させる演出も候補結果には使わない。
このサービスに勝者はいない。比較アプリなのに、勝ち負けを決めるサービスではない。だから勝敗の語彙も、王冠のような勝敗を連想させる見た目も、最初から出さないことにした。結果を表す言葉として仕様が採用しているのは「選択傾向」と「決め手」だ。
仕様にはこの原則の由来や理屈は書かれていない。定義として書かれている。勝ち負けを決めるサービスではない、だから勝者という言葉が存在しない。それだけだ。
開発開始5分後にはテストになっていた
面白いのはここからで、この原則は仕様書とClaude Codeへのプロンプトに書かれただけでは終わらなかった。
最初のcommitの5分後、17時50分の第2commit(712cba9)で共有パッケージが実装されたとき、そこにはもうこのテストが入っていた。
describe("優劣表現の排除", () => {
it("共有定数のラベルに勝敗を連想させる表現を含まない", () => {
const allLabels = [
...CATEGORIES.map((c) => c.label),
...DECISION_TAGS.map((t) => t.label),
...SATISFACTION_RATINGS.map((r) => r.label),
...REPORT_REASONS.map((r) => r.label),
];
const forbidden = ["勝者", "Winner", "winner", "1位", "勝ち", "王冠", "優勝"];
for (const label of allLabels) {
for (const word of forbidden) {
expect(label).not.toContain(word);
}
}
});
});
カテゴリー9種、決め手タグ9種、満足度5種、通報理由5種。ユーザーの目に触れる共有ラベル28件を、7つの禁止語で総当たりする。テストはローカルで回すvitestで、この7語のリストは、導入後の履歴では一度も変更されていない。
念のため書いておくと、これは事故の再発防止ではない。全履歴を検索したが、禁止語がプロダクションコードに違反として入ったことは一度もない。同じ開発初日に、「お願い」と「破ると落ちる形」が5分差で揃っていた。
本当に「勝者」に変えて、落としてみた
このテストが本当に落ちるのか、確かめた。元のリポジトリには触らず、node_modulesごと一時コピーを作って実験する。
まずそのままテストを走らせると11件すべて通る。次にコピー側の決め手タグ定義で label: "価格" を label: "勝者" に書き換えて、もう一度走らせる。結果は2件failだった。1件は冒頭の expected '勝者' not to contain '勝者'。もう1件は別のテストで、決め手タグのラベルがモックアップ準拠の9種と完全一致することを見ているため、こちらも差分(期待「価格」/受領「勝者」)で落ちた。禁止語テストは唯一の網ですらなく、そもそもラベルを勝手に変えれば禁止語でなくても落ちる。
逆の実験もした。ラベルを「価格」に戻し、集計処理の実装ファイルのコメントに // 勝者 と書き足して走らせる。今度は24件すべて通った。
つまりこのテストが見ているのは、ソースコードの文字列全体ではない。現に本番コードには ComparisonNewInner というコンポーネント名があって、よく見ると “wInner” という並びを含んでいるが、誰にも咎められずに存続している。そして「勝者」という単語がプロダクションコードに登場する唯一の箇所は、OGP画像実装のコメントに書かれた「勝者/Winner/1位/王冠などの優劣演出は付けない。」——禁止の自己言及だけだ。
winner: false でも落ちる
もう一段進めた実験がある。今度は言葉ではなくデータ構造の話だ。
集計関数 aggregatePhaseResult が返すobjectに、winner: false というフィールドを足してみた。値はfalseなので、少なくとも値として勝者を示しているわけではない。それでもテストは落ちる。
expected '{"options":[...]}' not to match /count|total|rank|winner|votes/i
集計結果のテストには、出力をJSON文字列化して正規表現に掛ける検査がある。/count|total|rank|winner|votes/i と /勝|優|王冠|1位/。値が何であれ、フィールド名にwinnerがある時点でアウトだ。
実際の集計DTOは { optionId, percent, isTied, decisionTags } だけでできている。生票数も順位もフィールド自体が存在しない。「画面に表示しない」よりも一段強くて、結果のデータが最初から勝敗を持てない形になっている——少なくとも現在の設計では、このテストがそれを固定している。
面が増えるたびに、検査も増えた
この検査は1本のテストではない。優劣表現や票数のような「外へ出したくないもの」を確認する検査が6つのテストファイルに広がっていて、追加のされ方に規則性がある。
開発初日のPhase 1で共有ラベルの検査(17時50分)。日付が変わって深夜のPhase 3で、集計処理の実装と同じcommitに集計JSONの検査とWorker API応答の検査。早朝のPhase 4でテーマ系API応答の検査。午前のPhase 5で、公式ローンチ用seedデータの内容検査(「seed内容に禁止された勝敗表現・優劣表現を含まない」というテスト名で、投入前のデータまで見る)。2日後、比較タイトルの自動生成機能を入れたときには、生成されたタイトルが優劣表現を含まないことの検査が同じcommitで入った。
どれも後追いの事故対応ではない。ラベル、集計結果、API応答、seedデータ、自動生成文——「外へ出る場所」が新しくできるたびに、その場所の検査が実装と同時に置かれていった。
「勝敗をなくす」と「結果をなくす」は違う
誤解されそうなので線を引いておくと、決め手ログは結果を隠すサービスではない。投票すれば結果は見られる。実際のUIの見出しは「みんなの選択傾向」だ。
出すものは、候補ごとの割合(整数%で合計100%)、決め手ごとの割合(複数選択なので合計100%にならない。きれいに見せるための正規化はしない、とコメントで明言されている)、そして同率。出さないものは、生票数、総投票数、順位、勝敗の語彙。50%と50%に並んだ候補は「引き分け」ではなく「同率」と表示される。
並び順にも同じ線がある。候補の結果は投稿時の並び順のままで、集計コードのコメントに「順位付けはしない」とある。一方で決め手タグは割合の大きい順に並ぶ——ここにもコメントがあって、「タグの人気順であり候補の優劣ではない」。多数派を見せないのではない。多数派を、候補の優劣や順位に変換しないのだ。
王冠も出さない——見る方法が違うだけ
仕様の4行目にあった王冠の話も補足しておく。王冠のような勝敗を連想させる演出も、最初から出さない仕様だ。理由は言葉と同じで、勝ち負けを決めるサービスではないから。
ただ、王冠のような見た目の演出については、この開発では確認の方法が別だった。Phase 1の完了判定表にはこう並んでいた——「勝敗UIでない | スクリーンショット照合 + 禁止語grep(検出0)+ sharedテスト」。文字列とデータ構造はテストとgrepで、見た目はスクリーンショットの照合と目視で。同じ1つの思想を、確認できる手段に合わせて張り分けた形になっている。
一度も事故を捕まえていない予防線
最後に、正直に書いておかないといけないことがある。全git historyを検索した限り、禁止語が違反としてプロダクションに入ったcommitの記録はゼロで、このテスト群が実際に違反を捕まえた記録も確認できなかった。
だからこれは「テストが何度もAIの暴走を止めた」という話ではない。テストが効いたから0件だったのか、そもそも誰も書こうとしなかったのかは、証明できない。開発初日の5分後に張られた予防線は、違反を捕まえた記録が確認できないまま、今もそこにある。
プロダクトの思想を全部テストにすることはできない。でも、「この語を出さない」「このフィールドを持たせない」「このJSONを返さない」のように機械で判定できる部分は、文章でお願いするだけでなく、破ると落ちる形に変換できる。決め手ログでは、「候補に優劣を付けない」という思想の一部が、実際にテストスイートの不変条件になった。
「勝者」と書いたらテストが落ちる。少なくとも、今回確認した共有ラベルや集計結果では、本当に落ちる。