この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 13 回(全 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
対象読者は、テストを書いてはいるものの「どの層を何でテストするか」をチームで決めたことがないエンジニアです。
- 層 × 道具の対応表 が設計ドキュメントに書かれています
- テストダブルの優先順位は フェイク > MSW >
vi.mock。最後の手段が要るのは設計の問題、という立場です - カバレッジ率のゲートを設けていません。代わりに受け入れ基準との対応表を使います
戦略を決めている 2 つの制約
gem-hunter のテスト戦略は、2 つの制約から出発しています。
制約 1: データ取得を伴う Server Component はユニットテストで描画できない。
async な Server Component は React のテストツールで素直にレンダリングできません。Next.js 公式も E2E を推奨しています。そこから逆算した結論がこれです。
ロジックを
asyncServer Component の中に書かない。
テストしにくい場所にロジックを置かない。第 11 回で見た「page.tsx は薄く、判断はユースケースへ」という形は、この制約の帰結でもあります。
制約 2: テストは外部ネットワークに出ない(NFR-24)。
第 2 回で見たとおり、環境変数ゼロでテストが通ります。これは方針ではなく 要件 として書かれています。
層 × 道具の対応表
| 層 | テストの種類 | 道具 | 特記事項 |
|---|---|---|---|
domain/ |
ユニット | Vitest | モックを使わない(依存が無いので要らない) |
usecases/ |
ユニット | Vitest + 手書きフェイク | MSW を使わない |
infrastructure/github/ |
結合 | Vitest + MSW 2 | 実レスポンスを縮めた固定 JSON を使う |
infrastructure/platform/ |
結合 | Vitest | 事業者 SDK を上位層でモックしない |
ui/ |
ユニット | Vitest + Testing Library | スナップショットを主な検証手段にしない |
app/(同期 SC・Route Handler) |
ユニット | Vitest | |
app/(async SC・画面遷移・URL 状態) |
E2E | Playwright | ユニットで描画できないため(例外が 1 件 → 後述「E2E が届かない分岐」) |
| アクセシビリティ | E2E | Playwright + axe |
「どこを何でテストするか」で迷わなくなる のが、この表の価値です。新しいファイルを書いたら、その置き場所から自動的にテストの種類が決まります。
テストファイルは実装の隣に置く
src/domain/model/per-page.ts
src/domain/model/per-page.test.ts ← 隣
__tests__/ にまとめる方式ではありません。理由は 2 つあります。
- 実装を開いたときにテストが目に入る
- ファイルを移動するときテストも一緒に動く
そしてこの配置は、後述する TDD のコミット順チェックの前提にもなっています。
TEST_PATH_GLOBS = (
"**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx",
"**/*.test.mjs", "**/*.spec.mjs",
"e2e/**", "**/__tests__/**", "**/*_test.py", "tests/**",
)
.mjs の 2 行は後から足されたものです。ビルド時ツール(tools/)のテストが *.test.mjs で書かれるようになったとき、このタプルに無いとテストファイルが「実装コミット」として数えられ、そのディレクトリでは順序チェックが恒久的に無効になる、とソースコメントに残っています。テストの対象が増えたら、テストかどうかを判定する側も一緒に広げる。
テストダブルの優先順位
順序に理由があります。
| 手段 | 使いどころ | 理由 |
|---|---|---|
| ① 手書きフェイク | 既定 | インターフェースに satisfies で型を合わせるので、契約が変わればテストが壊れる(気づける) |
| ② MSW | ACL のテストだけ | HTTP のレイヤーで止めるので、リクエストの組み立ても検証できる |
③ vi.mock |
最後の手段 | モジュールのパスに依存するので、リネームで壊れる |
戦略ドキュメントの一文が要点です。
vi.mockで自作モジュールを差し替えたくなったら、それは依存性注入ができていないサイン。テストではなく設計を直す。
「テストが書きにくい」を 設計のフィードバック として扱う姿勢です。
実際に数えると、vi.mock は 7 ファイル・14 箇所 でした。使われているのは、Cloudflare の SDK や Next.js の headers() など、自作でないモジュール が中心です。
grep -rn "vi\.mock(" src app | wc -l
出てくる 7 ファイルは すべて「引数で依存を渡せない層」 です。Route Handler(export async function GET(request))はフレームワークが呼ぶ関数なので deps を足せませんし、composition root は配線そのものが対象なので差し替える先が他にありません。規約の「自作モジュールに vi.mock を使わない」は、それが可能な層では守られていて、構造上できない層にだけ残っている ということです。
② には「何も返さない」使い方がある
setupServer を張っているファイルは 4 つで、そのうち src/composition/container.test.ts だけは ACL のテストではありません。しかも ハンドラを 1 つも登録していません(setupServer() の引数が空)。
狙いは server.listen({ onUnhandledRequest: 'error' }) のほうです。ハンドラが 0 個なら どのリクエストも「未登録」になり、その場でテストが落ちる。composition root の配線が外部へ出ないことだけを、ここで担保しています。MSW を「偽の応答を返す道具」ではなく「出口を塞ぐ道具」として使う形です(第 15 回)。
E2E が届かない分岐
対応表には「async Server Component はユニットで描画しようとしない」と書きました。その例外が 1 件だけあります。app/[locale]/gems/page.test.tsx は、async Server Component であるページ関数をユニットテストしています。
理由は E2E の側の限界です。このページのレート制限は rateLimiterBinding() が Workers 実行環境の外では undefined を返す、つまり フェイルオープンする 設計になっています。ローカルで Playwright を回しても制限に引っかかる分岐へは決して入れません。Playwright にはこの分岐が存在しない。
そこで render() を呼びません。ページ関数を直接 await して、返ってきた 未描画の要素ツリー をそのまま読みます。
function renderPage(searchParams: Record<string, string>) {
return GemListPage({
params: Promise.resolve({ locale: 'ja' }),
searchParams: Promise.resolve(searchParams),
})
}
// 各テストはこのヘルパー越しに呼ぶ
const tree = await renderPage({ q: 'react' })
描画していないので、子コンポーネントは「型と props」のまま並んでいます。element.type === ErrorNotice で同定して、retryHref に検索語とページ番号が保持されているかを DOM を介さず直接読める。だから「ユニットで描画しようとしない」には抵触しません。
代償は vi.mock 4 箇所です。ただし現物のコメントには 例外の適用範囲 も書いてあります。ここで固定するのは「配線の順序と条件」だけで、文言・見た目の検証は引き続き src/ui/*.test.tsx と E2E の担当。例外を作るときに、例外の外側まで一緒に書いてある のが要点です。
カバレッジ率をゲートにしない
カバレッジ率のゲートは設けない(数値を追うと薄いテストが増える)。
カバレッジ 80% を必達にすると、「通るだけで何も検証していないテスト」が生まれます。行は通るがアサーションが弱い、という状態です。
代わりに使っているのが、受け入れ基準(AC-n)とテストの対応表 です。
| 受け入れ基準 | どの層のテストで担保するか |
|---|---|
AC-2 検索できる |
E2E(sp-1.spec.ts) |
AC-12 非公開リポジトリを出さない |
domain ユニット + ACL 結合 + E2E(多層) |
AC-1 / AC-11
|
自動テスト対象外 と明記 |
「自動テストで守らないもの」も明記されている のが誠実なところです。
禁止事項
戦略ドキュメントには、やってはいけないことも並んでいます。
- テストのスキップ・無効化で緑にする
- 仕様と矛盾するテストを通すために、正しい実装を黙って書き換える
- 実行せずに「テストが通る」と報告する
- ユニットテストから実ネットワークへ出る
- スナップショットを主な検証手段にする
-
getByTestIdを第一選択にする
最後の項目には理由が添えられています。
役割・ラベルで取れないなら、それはアクセシビリティの問題でもある。
getByTestId(テスト用の属性で要素を取る)を避けて getByRole(役割で取る)を使うと、取れない=スクリーンリーダーからも扱いにくい ことが分かります。テストの書き方が、アクセシビリティの検査を兼ねます。
実際に数えると、getByTestId はリポジトリ全体で 1 箇所だけ でした。
TDD の回し方
「どう書くか」の正本は別のルールにあり、Red → Green → Refactor を 二重ループ で回すと定義されています。
内側を 値オブジェクト → ユースケース → ACL → UI の順 で書く、というところまで決まっています。中心から外側へ、という順序です。
コミットを分ける
TDD を実践した証拠をどう残すか。gem-hunter の答えは「コミットを分ける」です。
-
test: ...… テストファイルだけを変更するコミット -
feat: ...… 実装ファイルだけを変更するコミット
この順で並んでいれば、テストが先に書かれたことが履歴から分かります。独自のマーカー(red - / green - のような)は導入せず、既存のコミット規約に test: を足すだけ、というのが設計判断です。
検査は 2 段階
| 段階 | 何をするか | コスト |
|---|---|---|
| Tier 1 |
git diff --name-only でパスを見るだけ。テストファイルと実装ファイルが同じコミットに混ざっていないか |
ほぼゼロ |
| Tier 2 | PR 内の 最初の test:/feat: ペアだけ、一時 worktree でその時点をチェックアウトしてテストを実行し、本当に赤かったか を確認 |
PR あたり定数 |
Tier 2 が「PR 内の最初のペアだけ」なのには理由があります。全コミットでやると、CI 時間が増分の数 × PR 数に比例して膨らむ からです。
そして設計ドキュメントには、こう明記されています。
強制ではなくゲートである。
git commitに割り込むフックは無いのでリアルタイムには止めず、PR 作成前のゲートで止める。
この Tier 2 は現時点では稼働していません。 設計として定義されていますが、実行するのは CI の役割です。そしてこのリポジトリの GitHub Actions ワークフローは 1 本だけで、それは テスト実行 CI ではありません(gem-pool-refresh.yml=Gem 候補プールを日次生成し週次で反映する PR を作るジョブ・第 19 回)。テストを走らせるステップは入っていないので、CI 自体は存在するのに Tier 2 の担い手は不在 という状態です。実際に走っているのは Tier 1 のパス判定だけ。設計に書いてあることと、いま動いていることは別、という例としても読めます。
この教材で TDD の履歴は追えない
正直に書いておくと、このリポジトリのコミット履歴から test: → feat: の並びを確認することはできません。
$ git rev-parse --is-shallow-repository
true
git log --oneline を叩いても、返ってくるのは直近のマージコミット 1 件だけです。理由は 2 つです。squash マージ運用(PR 内の複数コミットが 1 つにまとめられる)と、shallow clone(履歴が 1 コミットしか無い)。
だから次回は、履歴を読む代わりに 自分で 1 周回します。実際に失敗するテストを書いて、赤くなるのを見て、実装して緑にする。TDD は読んで分かるものではなく、回して分かるもの なので、そのほうが確実です。
まとめ
- 層 × 道具の対応表 を決めておくと、テストの置き場所で迷わなくなる
- テストダブルは フェイク > MSW >
vi.mockの順。最後の手段が要るのは設計のサイン - カバレッジ率ではなく、受け入れ基準との対応 で十分性を見る
-
getByTestIdを避けると、テストがアクセシビリティの検査を兼ねる - TDD は コミットの分割 で証拠を残し、PR ゲートで検査する(ただし本教材では Tier 2 は未稼働)
シリーズの前後の記事
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。