この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 16 回(全 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
対象読者は、E2E テストが不安定で信用されなくなった経験があるエンジニアです。壊れにくい E2E の作り方を実プロダクトの構成から読みます。
- スタブ API を自前で立てて、外部ネットワークに一切依存させない構成です
- テストの手順が、要件定義書の操作レビュー手順と 1 対 1 で対応しています
- ロケータ・待ち方・テストデータの作り方に、それぞれ 理由つきの選択 があります
実行してみる
npx playwright test
本数だけを数えるなら --list を付けます。実行せずに収集結果だけが出ます。
npx playwright test --list
検証時点(39ba4a1)で 23 ファイル・107 テスト。この数はテストを収集した結果なので、環境によって変わりません。
そして実際に流すと、こうなりました。
Running 107 tests using 1 worker
10 failed
97 passed (7.1m)
ここでも API キーは要りません。ただし 10 件が赤いままです。落ちたのはすべて SP-18(Gem バッジ)と SP-19(Gem 一覧)でした。
赤い 10 件の再現条件
結論から書きます。この記事の検証時(2026-08-23・39ba4a1)は、クリーンクローンから npx playwright test を流すと 10 件が赤くなりました。原因はあなたの環境ではなく、リポジトリ側の経路選択にあります。 当セッションでは 2 回とも同じ 10 件が落ちています。
条件は 2 つで、両方そろって初めて 起きます。
引き金: .open-next/assets が無い
wrangler.jsonc の assets バインディングが配る先は .open-next/assets です。
"assets": { "directory": ".open-next/assets", "binding": "ASSETS" },
このディレクトリを作るのは preview:build / preview:upload / deploy だけで、test:e2e の経路には入っていません。しかも .gitignore で追跡外なので、クリーンクローンには必ず存在しません。
欠陥: 存在しない配信元を選んだまま倒れない
export async function resolveAssetReader(): Promise<AssetReader> {
const binding = await assetsBinding()
if (binding !== undefined) {
return createWorkersAssetReader(binding)
}
return createFileSystemAssetReader(defaultPublicDir())
}
経路の判定材料が バインディングの存在だけ です。バインディングはあるのに配る中身が無い、という状態が想定されていません。だから 404 を受け取っても、ファイルシステム経路へ倒れ直しません。
実ファイル public/data/gem-index/index.json は git 追跡済みで、clone した時点で public/ にあります。そこにあるのに一度も読まれない のがこの不具合の性質です。
ログ 1 行で経路が特定できる
[AssetReader] アセットを取得できませんでした(HTTP 404): /data/gem-index/index.json
[StaticGemIndex] /data/gem-index/index.json を読めませんでした。Gem バッジ・Gem 一覧なしで継続します。
1 行目の文言は createWorkersAssetReader の中にしかありません。ファイルシステム経路が出すのは別の文言です。このログが出ていれば Workers 経路が選ばれたと確定できます。推測が要りません。
npm run check も既定で赤くなる
tools/run_checks.sh には E2E が配線されています。そのため、クリーンクローンでは品質チェック一式そのものが FAIL します。第 2 回で緑を確認したのは npm test と e2e/sp-1.spec.ts 1 本で、そこは今も緑のままです。
回避策と、その限界
.open-next/assets を先に作れば緑になります。
npm run preview:build # .open-next/assets を生成する
npx playwright test
ただしこれは バグを隠す回避策であって、修正ではありません。バインディングがあるのに中身が無いとき静かに壊れる、という経路選択の欠陥はそのまま残ります。この記事の検証時点では未解決です。
なぜ気づけたのか
落ちた 10 件が、スキップではなく明示的な失敗になっているからです。
// 🔴 スタブが候補プール(`public/data/gem-index/`)を読めなかった場合は 1 件しか返らない。
// その状態をスキップで隠さず、ここで明示的に落とす(バッジ経路が未検証のまま緑にしない)。
await expect(resultCards(page)).toHaveCount(2, { timeout: FIRST_RESULT_TIMEOUT_MS })
ここが test.skip() だったら、スイートは緑になっていました。そして読者は「Gem バッジは検証済み」と誤解したまま先へ進みます。実装に欠陥があることと、テストが誠実であることは両立します。 この 10 件は、テストが仕事をした結果です。
赤の判定そのものは第 17 回で扱います。
外部に出ない仕組み
webServer: process.env.E2E_BASE_URL
? undefined
: [
{
command: `node e2e/stub/server.mjs`,
env: { E2E_STUB_PORT: stubPort },
port: Number(stubPort),
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
{
command: `npm run build && npm start -- --port 3100`,
env: {
...buildDummyGitHubEnv({ stubPort, appUrl: baseURL }),
PORT: '3100',
},
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
],
テスト実行前に 2 つのプロセスが自動で立ち上がります。
要は GITHUB_API_ORIGIN という環境変数でアプリの向き先をスタブへ差し替えて 起動しています。アプリのコードは何も変えません。
そして E2E_BASE_URL が設定されていれば どちらも起動しません。デプロイ済みのプレビュー環境に対して同じテストを流せる、という設計です。
スタブ API はキーワードで挙動を変える
e2e/stub/server.mjs は 外部依存ゼロ(node:http だけで書かれています)。面白いのは、検索キーワードで応答を切り替える 規約です。
キーワード規約(q または repo 名に部分一致で判定):
通常のキーワード → 複数件の結果
zero-hits を含む → total_count: 0 / items: []
upstream-error を含む → HTTP 500
rate-limit を含む → HTTP 403 + x-ratelimit-remaining: 0 + x-ratelimit-reset
not-found を含む → HTTP 404(詳細 API のみ)
sp9-network-down → 応答を書かずに接続を切る(到達不可の再現)
sp9-secondary-rate-limit → HTTP 403 + retry-after(二次レート制限)
sp9-slow → 1.5 秒待ってから通常の結果(読み込み中表示の観測用)
sp9-forbidden → HTTP 403 + x-ratelimit-remaining: 42(レート制限でない 403)
readme-missing → README エンドポイントのみ 404
private-mixed → 上流が is:public を無視して private を混ぜて返す状況を再現
many-hits → 60 件データセット(ページネーション検証用)
異常系を「検索キーワード」で呼び出せます。 テスト側は特別なセットアップなしに、キーワードを変えるだけでレート制限やネットワーク断を再現できます。
特に効いているのが 2 つ。
-
sp9-network-down: 応答を書かずに接続を切る。「サーバーが落ちている」は普通のモックでは再現しにくい状態です -
private-mixed: 上流がis:publicを無視して非公開リポジトリを混ぜて返す。あってはならない状況 を意図的に作り、アプリ側の多層防御(第 9 回)が効くかを検証します
テストが仕様書と 1 対 1 になっている
/**
* SP-1: キーワードで GitHub を検索し、結果が一覧で見える。
* `docs/02_requirements/user-story-map.md` §5.3 `SP-1` の操作レビュー手順をそのまま写す。
* 対応 AC: `AC-1` / `AC-2`(一部)/ `AC-3`(必達部分)。
*/
test('SP-1: 検索して一覧が出る', async ({ page }) => {
await test.step('1. トップページを開く', async () => {
await page.goto('/')
await expect(page).toHaveURL(/\/ja(\?.*)?$/)
})
await test.step('2. 検索欄に react と入力し、ボタン(または Enter)で実行する', async () => {
await searchFor(page, 'react')
await expect(page).toHaveURL(new RegExp(`[?&]${SEARCH_PARAM_KEYS.keyword}=react(&|$)`))
})
await test.step('3. オーナーアイコンとリポジトリ名の一覧が出る', async () => {
// ...
})
})
test.step() の文言が、要件定義書に書かれた操作手順そのまま です。しかも番号まで一致しています。
この規約には理由があります。
SP 単位で 1 ファイル 1
test()にする。個別のtest()に分割しない。手順は状態を共有する一連の操作なので、分割すると重複セットアップが必要になり 1 対 1 対応が崩れる。
E2E を細かく分けたくなりますが、「仕様書の手順書と対応させる」という目的を優先して あえて 1 つの大きなテスト にしています。
ロケータの選び方
原則は 役割(role)で取る です。ただし例外の扱いが丁寧です。
// オーナーアイコン(装飾画像・alt="")。
// オーナー名がテキストとして隣接表示される文脈のためアクセシブルネームを持たない設計
// なので、ロールでは取得できない。要素そのものを見て、退行検知として
// 「オーナーのアバター URL が反映されている」「明示サイズが指定されている」ことを確認する。
const avatar = items.first().locator('img')
await expect(avatar).toHaveCount(1)
await expect(avatar).toHaveAttribute('alt', '')
await expect(avatar).toHaveAttribute('src', /^data:image\/png.*[?&]s=80$/)
await expect(avatar).toHaveAttribute('width', '40')
await expect(avatar).toHaveAttribute('height', '40')
装飾画像は alt="" なので、意図的にロールで取れません(スクリーンリーダーが読み上げないようにするため)。だから属性で取る。「なぜロールで取らないのか」を理由つきで書いてある のがポイントです。
width / height を検証しているのは、レイアウトシフト(画像読み込みで表示が飛ぶ現象)の退行検知です。
他にも細かい工夫があります。
-
:scope > liで直接の子だけを数える(結果カードの中にタグの<li>が入れ子になっているため) -
URL パラメータ名は
SEARCH_PARAM_KEYSを import して組み立てる(第 11 回の定数をテストからも参照) -
表示文言は
messages/ja.jsonを直接 import する(翻訳を変えたらテストも自動的に追随)
待ち方
固定の waitForTimeout は基本的に使いません。条件が満たされるまで待つ 書き方をします。
export async function tabUntilFocused(page: Page, target: Locator, maxPresses = 40): Promise<void> {
for (let i = 0; i < maxPresses; i++) {
const focusedCount = await page.locator(':focus').and(target).count()
if (focusedCount === 1) return
await page.keyboard.press('Tab')
}
throw new Error(
`Tab を ${maxPresses} 回押しても対象へフォーカスが到達しなかった(フォーカストラップまたはロケータの誤りの可能性)`,
)
}
キーボード操作のテストで「Tab を 5 回押す」と決め打ちにすると、UI に要素が 1 つ増えただけで壊れます。目的の要素にフォーカスが当たるまで押す 書き方なら壊れません。
そして .count() を使う理由がコメントに書かれています。target.evaluate(...) を使うと Playwright の自動待機が働き、ロケータが間違っていたときに 60 秒待たされてタイムアウトする。.count() は即座に返るので、自前のエラーメッセージで速く失敗できます。
「失敗したときに原因が分かるか」までを設計している わけです。
観測できないものを、別の方法で観測する
キャッシュが効いているかを E2E で確かめるテストが秀逸です。
await test.step('1. 検索する(1 回目)→ スタブへ検索リクエストが 1 回届く', async () => {
await page.goto('/ja')
await searchFor(page, keyword)
await expect(page.getByRole('link', { name: 'octostub/octo-widgets' })).toBeVisible()
const stats = await readStubStats()
expect(stats.searchCount).toBe(1)
})
await test.step('2. 同じキーワードで続けてもう一度検索する(2 回目)→ スタブへのリクエスト数は増えない', async () => {
await searchFor(page, keyword)
await expect(page.getByRole('link', { name: 'octostub/octo-widgets' })).toBeVisible()
const stats = await readStubStats()
expect(stats.searchCount).toBe(1)
})
2 回検索しても、スタブが受け取ったリクエストは 1 回のまま。 これがキャッシュに当たった証拠です。
ブラウザ側からはキャッシュの HIT/MISS は見えません。そこで スタブ側に受信カウンタ(/__stats)を実装して、外から観測しています。実 API には無い裏口をスタブに持たせる、という発想です。
テストデータの衝突を防ぐ
キャッシュはテスト間で共有されます。だから前のテストのキャッシュが残っていると、次のテストが誤判定します。
対策が キーワードの一意化 です。
const keyword = uniqueKeyword('sp5-cache-check')
randomBytes(4).toString('hex') で毎回違う接尾辞を付けます。16 進数だけの接尾辞なら、スタブのキーワード規約(zero-hits など)と偶然衝突することもありません。
さらに、リトライ時のために 予備のリポジトリ名プール まで用意されています。詳細ページは動的に作れないので、testInfo.retry を見て別のリポジトリへ切り替える仕組みです。
スタブ自身の堅牢性もテストする
E2E の中に、こんなテストがあります。
スタブは遅延応答中にクライアントが切断しても落ちない
sp9-slow(1.5 秒待ってから応答)のテスト中にブラウザが遷移すると、スタブが書き込み先を失って例外で死ぬ可能性があります。スタブが死ぬと、後続のテストが全部落ちます。
そこで実装側で res.writableEnded || res.destroyed を確認し、切断時にタイマーを片付けています。この対処に至った失敗の記録も、コメントに残っています。
まとめ
- スタブ API を自前で立て、環境変数でアプリの向き先を差し替えれば、E2E は外部に依存しなくなる
- 異常系は キーワードで呼び出せる 規約にしておくと、テスト側が簡潔になる
- テストの手順を 仕様書の操作手順と 1 対 1 にすると、テストが仕様のトレースになる
- ロールで取れないときは、理由を書いたうえで 属性で取る
- 固定待ちではなく 条件が満たされるまで待つ。失敗時のメッセージまで設計する
- 観測できないもの(キャッシュ)は、別の場所に観測点を作る
- 検証時のクリーンクローンでは 10 件が赤くなった。引き金(
.open-next/assets不在)と欠陥(404 でも経路を切り替えない)の両方がそろって初めて起きる。ログ 1 行で経路が特定でき、スキップで隠されていないから読者が気づける
シリーズの前後の記事
- ⬅️ 前の記事: 第 15 回 「実ネットワークに出ない」を設定1行で保証する
- ➡️ 次の記事: 第 17 回 赤くなったテストをどう判定するか、axeの限界とflakyの正体
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。