この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 14 回(全 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
対象読者は、TDD の説明は読んだことがあるが自分で回したことはない、というエンジニアです。この回は 手を動かします。
- 教材リポジトリに あなた自身が失敗するテストを追加 し、赤 → 緑 → 元に戻すまでを 1 周します
- 掲載している失敗メッセージと件数は、実際に実行して採取したもの です
- あわせて、モックを使わないテスト・フェイクを手書きするテストの実例を読みます
まず読む: モックゼロのテスト
手を動かす前に、既存のテストを見ます。
import { describe, expect, it } from 'vitest'
import type { Gem } from './gem'
import { gemIndex, gemIndexValue } from './gem-index'
import { GEM_INDEX_SHORTLIST_SIZE, byGemIndexAsc, selectGemIndexShortlist } from './gem-shortlist'
function gem(packageName: string, gi: number): Gem {
return {
packageName,
repositoryFullName: `owner/${packageName}`,
dependentCount: 100,
stars: 10,
gemIndex: gemIndex(gi),
}
}
モックが 1 つもありません。 ドメイン層は外部に依存しないので、モックする対象がそもそも存在しません。
gem() はテストデータを作る小さな関数です。テストごとに全フィールドを書くと 何を検証しているのかが埋もれる ので、ファクトリ関数で「今回意識したい値」だけを引数にします。
このファイルで白眉なのがこのテストです。
it('同値は packageName asc でタイブレークする(入力順に依存しない)', () => {
const tied = ['zeta', 'alpha', 'mike'].map((name) => gem(name, 0))
const shortlistA = selectGemIndexShortlist(tied, 2)
const shortlistB = selectGemIndexShortlist([...tied].reverse(), 2)
expect(shortlistA.map((g) => g.packageName)).toEqual(['alpha', 'mike'])
expect(shortlistB.map((g) => g.packageName)).toEqual(['alpha', 'mike'])
})
同じ配列を逆順にしても結果が同じ ことを検証しています。「並び順が入力順に依存しない」という仕様を、これ以上ないほど直接的に表現しています。
フェイクを手書きする
ユースケースのテストでは、ポートの偽実装をその場で書きます。
function fakePort(received: SearchQuery[]): RepositoryQueryPort {
return {
async search(query) {
received.push(query)
return { totalCount: 1, incompleteResults: false, items: [summary] } satisfies SearchResult
},
async findDetail() {
return null
},
async findReadme() {
return null
},
}
}
vi.fn() すら使っていません。 「呼ばれた引数を配列に貯める」だけの機能を手書きしています。
satisfies RepositoryQueryPort が効いていて、インターフェースが変わればこのテストが型エラーになります。契約の変更に気づけます。
it('キーワードを値オブジェクトへ変換してポートへ渡す', async () => {
const received: SearchQuery[] = []
const searchRepositories = makeSearchRepositories({ repos: fakePort(received) })
const result = await searchRepositories({ keyword: ' react ', page: 2 })
expect(received).toHaveLength(1)
expect(received[0].keyword).toBe('react') // 前後の空白が落ちている
expect(received[0].page).toBe(2)
expect(result.items[0].fullName).toBe('facebook/react')
})
「ポートに何を渡したか」を検証しています。 戻り値だけでなく、境界を越えていく値も見る。第 8 回で見た「入口で値オブジェクトへ変換する」が、ここで検証されています。
姉妹テストに「不正なキーワードではポートを呼ばずに落ちる」(expect(received).toHaveLength(0))があります。何かが起きないこと を検証する型です。
時刻と乱数を引数にする
「日替わりで内容が変わる」機能をどうテストするか。
it('同じ seed では毎回同じ items を返す(決定論性)', async () => {
const seed = '20260820' as DateSeed
const getDailyDigest = makeGetDailyDigest({ port: fakePort(candidates) })
const a = await getDailyDigest({ seed, limit: 5 })
const b = await getDailyDigest({ seed, limit: 5 })
expect(a.items.map((x) => x.packageName)).toEqual(b.items.map((x) => x.packageName))
})
vi.useFakeTimers() は使っていません。そもそも実装が Date.now() を呼んでいない からです。日付は seed として引数で渡されます。
実装側のコメントにこうあります。
Math.random()を使わない(テストの決定性を壊すため)。日付シードから決定論的に選ぶ。
テストしにくいものは、テストしやすい形に設計を変える。 これも「テストが書きにくいのは設計のサイン」の実例です。
ここから手を動かす
ここからが本題です。あなた自身が失敗するテストを書きます。
お題
「表示件数に 200 を追加してほしい」という要望が来た、としましょう(第 7 回で見たとおり、いまは 20 / 50 / 100 の 3 択です)。
TDD の順序は「テストを先に」です。
始める前に、変更前の状態を控えておいてください。最後に比べます。
npm test 2>&1 | tail -3
Step 1: 失敗するテストを書く(Red)
src/domain/model/per-page.test.ts の末尾に追記します。
describe('200 件表示(追加仕様)', () => {
it('parse は 200 を受け入れる', () => {
expect(parse(200)).toBe(200)
})
it('tryParse は "200" を 200 として解釈する', () => {
expect(tryParse('200')).toBe(200)
})
})
実行します。
npx vitest run src/domain/model/per-page.test.ts
赤くなります。 実際の出力がこれです。
× parse は 200 を受け入れる 3ms
× tryParse は "200" を 200 として解釈する 4ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/domain/model/per-page.test.ts > 200 件表示(追加仕様) > parse は 200 を受け入れる
DomainValidationError: 表示件数は 20/50/100 のいずれかで指定してください
FAIL src/domain/model/per-page.test.ts > 200 件表示(追加仕様) > tryParse は "200" を 200 として解釈する
AssertionError: expected 20 to be 200 // Object.is equality
Test Files 1 failed (1)
Tests 2 failed | 3 passed (5)
Vitest 4 の設定はプロジェクト分割(第 15 回)になっているので、手元では失敗行の頭に project 名(app)が付きます。
2 つの失敗の種類が違う ところに注目してください。
| テスト | 失敗の仕方 | 何を意味するか |
|---|---|---|
parse |
DomainValidationError が投げられた |
「不正な値だ」と 拒否された |
tryParse |
20 が返ってきた(期待は 200) |
既定値に倒された |
第 7 回で読んだ二段構えが、失敗メッセージの違いとして目に見えています。読んで理解するのと、失敗を自分の目で見るのとでは定着が違います。
赤を確認するのを飛ばさないでください。 テストを書いてすぐ実装すると、「そのテストが本当に壊れたコードを捕まえるか」が分からないままになります。赤を見て初めて、そのテストに意味があると確認できます。
Step 2: 実装を変えて緑にする(Green)
src/domain/model/per-page.ts の 4 行目を 1 箇所だけ変えます。
export const ALLOWED_PER_PAGE = [20, 50, 100, 200] as const
もう一度実行します。
npx vitest run src/domain/model/per-page.test.ts
Test Files 1 passed (1)
Tests 5 passed (5)
緑になりました。 変更したのは配列に 200 を足した 1 箇所だけです。
第 7 回で見た isAllowedPerPage が ALLOWED_PER_PAGE を参照しているので、定数を変えるだけで検証も型も追随します。これが「許可リストを 1 箇所に持つ」設計の効果です。
Step 3: 全体を確認する
npm test
総数はあなたの環境と教材の版で違います。確認するのは 2 点だけです。
-
Testsが、Step 1 の前に控えた数から +2 されていること - 増えた 2 件以外が全部通ったままであること
ファイル数が変わらないのは、既存ファイルに追記したからです。そして 2 番目のほうが本命で、触っていない層のテストが 1 つも壊れていない と分かる。これがテストがある状態の一番の効用です。
件数そのものを assert しないでください。 toBe(596) のようなテストは、無関係な 1 件が増えただけで落ちます。数を検証したいときは toHaveLength(before + 2) のように 差分 で書きます。
Step 4: 元に戻す
実験なので戻しておきます。
git checkout src/domain/model/per-page.ts src/domain/model/per-page.test.ts
本来ならもう一歩ある
実務ではここで終わりません。表示件数を増やすなら、
- UI の選択肢(
per-page-picker.tsx)にも 200 を足す - 「任意値を許さずキャッシュ断片化を防ぐ」という当初の判断(第 7 回)に照らして、200 を足すのが妥当かを考える
- E2E で 200 件表示の動作を確認する
「テストが緑になった」と「仕様が完成した」は別 です。ただ、TDD の 1 周としてはここまでで十分体験できます。
Refactor はいつやるか
Red → Green → Refactor の 3 番目は、今回は出番がありませんでした(1 行の変更だったので)。
Refactor の安全性は「テストが緑のまま構造を変える」ことで担保されます。全部緑の状態から始めて、変更後も同じ数が緑なら、外から見た振る舞いは変わっていない。テストが十分にあるほど、大胆に構造を変えられます。
まとめ
- ドメイン層のテストは モックゼロ。テストデータはファクトリ関数で意図だけを見せる
- ユースケースのフェイクは 手書きで十分。
satisfiesで契約に縛れば変更に気づける - 時刻や乱数は 引数にする。テストしにくいものは設計で解く
- 赤を自分の目で見てから実装する。失敗の種類の違いが、設計の理解を助ける
- 実装を変えたら全体を回す。他が壊れていないこと が最大の効用
シリーズの前後の記事
- ⬅️ 前の記事: 第 13 回 どの層を何でテストするかを表で決めておく
- ➡️ 次の記事: 第 15 回 「実ネットワークに出ない」を設定1行で保証する
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。