この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 17 回(全 19 回)です。
実際に動いている公開リポジトリ kai-kou/gem-hunter(MIT)を1本まるごと読み解く連載です。架空のサンプルではなく実ファイルを引用し、掲載しているコマンド結果はすべて実際に動かして採取しています。
全体を通しで読みたい方へ: 同じ内容を 1 冊にまとめた Zenn Book を無料で公開しています。
シリーズ全体の目次
- 第 1 回 動いているリポジトリを読むという学び方
- 第 2 回 clone からテストが緑になるまで
- 第 3 回 要件IDとADRで、何を解くアプリなのかを地図にする
- 第 4 回 App Router の地図。フォルダがそのまま URL になる
- 第 5 回 'use client' が6ファイルしかないアプリの境界の引き方
- 第 6 回 「クリーンアーキテクチャだから」を理由にしない層の分け方
- 第 7 回 ブランド型で「検証済みの値」を型にする
- 第 8 回 DIコンテナを使わない依存性逆転
- 第 9 回 外部APIの語彙を持ち込ませない翻訳層の作り方
- 第 10 回 依存規則を440行のPythonで機械検査する
- 第 11 回 URLのクエリが画面に出るまでを5層ぶん追跡する
- 第 12 回 ライブラリなしのi18nと、画面の変化を目で見ていない人へ伝える実装
- 第 13 回 どの層を何でテストするかを表で決めておく
- 第 14 回 失敗するテストを先に書いて Red→Green を1周する
- 第 15 回 「実ネットワークに出ない」を設定1行で保証する
- 第 16 回 外部APIに依存しないE2Eの組み方
- 第 17 回 赤くなったテストをどう判定するか、axeの限界とflakyの正体(この記事)
- 第 18 回 生IPを残さないレート制限と、第三者HTMLを安全に表示する
- 第 19 回 CIは道具ではなくゲートの集合、そして自分のプロジェクトへの持ち帰り方
TL;DR
対象読者は、CI が赤くなったとき「たぶん flaky だろう」と再実行して済ませたことがあるエンジニアです。
- axe(自動アクセシビリティ検査)は フォーカス状態を評価できません。何が測れて何が測れないかを区別します
- 「flaky だと思ったら プロダクトの実バグだった」という記録が、教材リポジトリに残っています
- 赤いテストの扱いは 手順として明文化 されています
axe をどう当てるか
export function createAxeBuilder(page: Page): AxeBuilder {
return new AxeBuilder({ page: /* ... */ })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
}
生成を 1 箇所に集約し、WCAG のルールセットを既定で付けています。使う側はこうです。
/**
* NFR-26: 自動アクセシビリティ検査(一覧画面・詳細画面)。
* 品質ゲートは「Lighthouse Accessibility 100」相当のため、重大度 serious / critical の
* 違反を 0 件とする(minor / moderate は本テストの対象外)。
*/
function seriousOrCritical(violations: Result[]): Result[] {
return violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')
}
test('一覧画面(検索結果)に serious/critical の違反がない', async ({ page }) => {
await page.goto('/ja')
await searchFor(page, 'react')
await expect(page.getByRole('link', { name: 'octostub/octo-widgets' })).toBeVisible()
const results = await createAxeBuilder(page).analyze()
const violations = seriousOrCritical(results.violations)
expect(violations, JSON.stringify(violations, null, 2)).toEqual([])
})
読みどころが 2 つ。
1. 重大度で線を引いています。 すべての違反を 0 にするのではなく、serious / critical だけを対象にします。基準を明示して、それ以外は意図的に対象外と宣言しています。
2. expect の第 2 引数に違反内容を渡しています。 これがないと、失敗時に「配列が一致しません」としか出ません。JSON をそのまま渡しておけば、CI のログを見るだけで何が違反したか分かります。
そして特筆すべきは、除外設定(exclude() / disableRules())が 1 つも無い ことです。「この違反は仕方ないから除外」を積み重ねると検査は形骸化します。
axe で測れないもの
e2e/axe.ts には、能力の限界がはっきり書かれています。
/**
* 🔴 `withTags` による WCAG 2.2 AA ルールセット指定は「axe が持つ自動検出ルールのカバレッジを
* WCAG 2.2 まで広げる」だけであり、これでフォーカスリングの非テキストコントラスト(SC 1.4.11)が
* 検出できるようになるわけではない。**axe-core は `:focus-visible` を発火させる操作(Tab 押下)を
* 行わない DOM 静的解析であり、フォーカス状態そのものを評価しない。**
*/
axe は「Tab を押した状態」を見ません。 キーボード操作したときに現れるフォーカスリングが薄すぎて見えない、という問題は axe を通しても検出されません。
そこで三層で守っています。
3 番目の実装コメントが、この連載でいちばん技術的に濃い箇所です。
/**
* 🔴 なぜ必要か: `tools/check_contrast.py` は `app/globals.css` の CSS **宣言値**しか読めない。
* Tailwind ユーティリティ側の `/NN` 不透明度修飾子(例 `ring-ring/50`)や `transition-all` の
* 遷移途中の値は宣言値に現れないため、静的検査を素通りする。
* 本関数は `getComputedStyle` が返す **実効値**(カスケード・トランジション適用後)を読み、
* `<canvas>` の 2 点サンプリング(不透明背景 1 回・黒背景 1 回に同じ色を重ね描きし、差分から
* alpha とアルファ抜き RGB を逆算する)で `oklch()` / `lab()` / `color-mix()` など
* 任意の CSS 色関数を実際のブラウザ変換で RGB へ解決する(自前の oklch 変換式を二重実装しない)。
*/
要点はこうです。
-
ring-ring/50のような 不透明度 50% の指定は、CSS ファイルの文字列には現れない → 静的検査を素通りする - そこで
getComputedStyleで 実効値 を取る - ただし
oklch()などの新しい色関数は、そのままでは RGB に直せない - そこで
<canvas>に 2 回描いて、差分から逆算する(白背景に描いた結果と黒背景に描いた結果を比べれば、透明度と本来の色が求まる)
自前で色空間の変換式を実装せず、ブラウザに計算させている のが賢いところです。変換式を自作すると、そこにバグが入ったとき検査自体が嘘をつきます。
そして最終的に「コントラスト比 3:1 以上・太さ 2px 相当以上」を判定します。
flaky との付き合い方
このリポジトリには flaky-tests.md という専用のドキュメントがあります。
# 既知の Flaky テスト レジストリ
> **目的**: `bash tools/run_checks.sh`(`npm run check`)が赤くなったとき、それが
> **自分の変更が壊した本物の失敗**なのか **既知の flaky** なのかを判断する材料をここに集約する。
>
> **判断手順**: 赤くなったテストが本表に **載っていれば**既知の flaky として扱ってよい。
> **載っていなければ本物の失敗として扱う**(推測で「flaky だろう」と片付けない)。
そして濫用防止の一文が続きます。
本レジストリを「リトライで流してよいテストの一覧」にしない。各エントリには 恒久対応の Issue 番号または対応しない理由を必ず書く。
「flaky だと思ったら実バグだった」
レジストリに載っている唯一の実エントリが、この連載でいちばん教訓的な記録です。
症状: ログイン系の E2E で、ときどきセッション Cookie が消えて落ちる。
最初の推測: OAuth のリダイレクト連鎖のタイミングの揺らぎ。いかにも flaky らしい説明です。
この推測は誤りでした。
Playwright のトレースを見ると、set-cookie で Cookie が空文字に上書きされている 瞬間が記録されていました。真因はこうです。
- ログアウトのリンクが
<Link href="/api/auth/logout">で書かれていた - Next.js の
<Link>は、ビューポートに入ったリンクを自動でプリフェッチ する - プリフェッチは
GETリクエストを飛ばす - ログアウトの Route Handler が
GETを受け付けていたので、ユーザーがクリックしていないのにログアウトが実行された
タイミングの揺らぎではなく、プロダクトの実バグです。 しかも「ログアウトリンクが画面に見えているだけで勝手にログアウトされる」という、実ユーザーにも起きる不具合でした。
対応がテスト側ではない
| 対応 | 採用 |
|---|---|
| テストにリトライを設定する | ❌ |
| 待ち時間を入れる | ❌ |
ログアウト導線を <form method="post"> に変える |
✅ |
GET ハンドラを撤去する |
✅ |
プロダクトを直しました。 第 4 回で「logout/route.ts は POST だけを export していて、GET を敢えて置いていない」と書いたのは、この記録の結果です。
直したあと「わざと壊して落ちること」を確認する
対応後の検証も記録されています。
-
--repeat-each=5で 20 回連続実行し、全部通ることを確認 - 旧実装に戻すと、当該テストが実際に落ちることを確認
2 番目が重要です。「直したら通った」だけでは、そのテストが本当にこの不具合を捕まえるのか が分かりません。わざと壊して落ちることまで確認して、初めて回帰テストとして機能します。
再発を機械で止める
さらに、同種の問題を検出するスクリプトが追加されました。tools/check_prefetchable_side_effects.py は「プリフェッチされうる GET に副作用がないか」を検査し、CI で走ります。
1 つの不具合に対して、修正・回帰テスト・機械検査の 3 段 で対応しています。
演習 — 目の前の 10 件を判定する
ここまでは記録を読んできました。今度は自分で判定します。素材は第 16 回にあります。クリーンクローンで npx playwright test を流すと、検証時(2026-08-23)は 10 件が赤くなりました。
3 択にします。
- テストが悪い(期待値が実装に追いついていない)
- 実装が悪い(プロダクト側のバグ)
- 環境が悪い(自分のマシンだけの問題)
まず flaky を切り離す
レジストリを引くのが第一手です。落ちた 10 件は載っていません。既定に従えば、これは本物の失敗です。
そして これは flaky ではありません。当セッションで 2 回流して、2 回とも同じ 10 件が落ちました。決定論的です。flaky は「同じコードなのに通ったり落ちたりする」もので、毎回同じ結果が出るならその定義に当てはまりません。ここを混ぜると分類が濁ります。
証拠を集める
推測しません。ログを読みます。
[AssetReader] アセットを取得できませんでした(HTTP 404): /data/gem-index/index.json
この文言は createWorkersAssetReader の中にしかありません。ファイルシステム経路なら別の文言が出ます。つまり Workers 経路が選ばれ、そこに配信物が無かった と確定できます。
もう 1 つ、public/data/gem-index/index.json は git 追跡済みで、clone した時点で手元にあります。データが無いのではなく、あるものを見に行っていない。
3 択に戻る
3(環境が悪い)ではありません。 原因はマシン固有の状態ではなく、リポジトリに書かれた経路選択そのものです。npm ci をやり直しても Node を入れ直しても変わりません。
1(テストが悪い)でもありません。 テストは「候補プールが読めているなら 2 件返る」と書いているだけで、期待値は仕様どおりです。
残るのは 2(実装が悪い) です。ただし「Gem バッジ機能が壊れている」ではありません。壊れているのは 経路選択の 1 箇所 です。
三者を分けて見る
同じ現象の中に、性質の違う 3 つの判断が同居しています。ここを混ぜないのが要点です。
| 対象 | 設計 | 評価 |
|---|---|---|
| プロダクト(Gem バッジ) | fail-open — バッジを取れなくても検索ごと落とさず継続する(D-28) | 正しい。バッジは注釈であって検索の必須要素ではない |
テスト(sp-18.spec.ts) |
fail-loud — 読めなかった状態をスキップで隠さず落とす | 正しい。隠していたら緑になり、未検証のまま先へ進んでいた |
経路選択(resolveAssetReader) |
バインディングの存在だけで配信元を決める | ここだけが欠陥。 404 を受けても倒れ直さない |
fail-open と fail-loud は矛盾しません。利用者の前では落とさず、開発者の前では隠さない。 向き先が違うだけです。そのうえで、どちらの設計も「配信元の選択が正しい」ことを前提にしています。前提が崩れたときに気づかせたのが、fail-loud だったテストのほうでした。
手順に当てはめると
次節の手順で言えば ③ が分岐点です。原因はテスト側ではなくプロダクト側なので、テストを緩めません。preview:build を先に打てば緑になりますが、それは症状を消しただけで、経路選択は直っていません。リトライ回数を増やすのと同じ性質の対処です。
恒久対応は「.open-next/assets を E2E の前提に組み込む」か「404 を受けたらファイルシステムへ倒れる」かのどちらかで、いずれにせよ 同じ間違いが再発したら機械が止める ところまでを含みます。
赤いテストの扱い方(手順)
テストが赤くなった
↓
① flaky レジストリに載っているか?
→ 載っていない: 本物の失敗として調べる(既定)
→ 載っている: 記載の緩和策・恒久対応の状況を見る
↓
② 原因を推測ではなく証拠で特定する(トレース・ログ・実測)
↓
③ 原因はテスト側か、プロダクト側か
→ プロダクト側: プロダクトを直す(テストを緩めない)
→ テスト側: 待ち方・ロケータ・データの独立性を直す
↓
④ 直したあと「わざと壊して落ちること」を確認する
↓
⑤ 同種の再発を機械で止められないか検討する
既定が「本物の失敗」 であることが要点です。「たぶん flaky」から始めると、実バグを何度も見逃します。
まとめ
- axe は フォーカス状態を評価しません。何が測れて何が測れないかを把握したうえで、足りない部分を別手段で埋めます
- 色の実効値はブラウザに計算させます(変換式を自前で二重実装しない)
- flaky レジストリは「リトライしてよい一覧」ではありません。載っていなければ本物の失敗 として扱います
- 原因の推測を検証します。トレースを見れば「タイミングの問題」が実バグだったと分かることがあります
- 直したら わざと壊して落ちることまで確認 し、再発は機械検査で止めます
- 演習の 10 件は 毎回同じ結果になるので flaky ではありません。原因は環境でもテストでもなく実装(経路選択)の側にあります
- プロダクトは fail-open・テストは fail-loud・欠陥は経路選択だけ。同じ現象の中で 3 つを分けて評価します(素材と再現条件は第 16 回)
シリーズの前後の記事
- ⬅️ 前の記事: 第 16 回 外部APIに依存しないE2Eの組み方
- ➡️ 次の記事: 第 18 回 生IPを残さないレート制限と、第三者HTMLを安全に表示する
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。