テスト戦略:I/O契約を守り、内部実装を自由にする
Published on 2026年4月21日
設計はじめに
テストを書くたびに、「何のために書いているか」が曖昧なまま手を動かしている感覚があった。
カバレッジの数値を追うためか。リファクタへの保険か。ドキュメント代わりか。目的が定まらないまま書き続けると、実装を少し変えただけでテストが壊れる、あるいは テストは通っているのにバグが混入する という状態が生まれる。
DDD + クリーンアーキテクチャを採用している個人開発で、「何のためにテストを書くのか」「どの単位で何を検証するのか」を言語化した。この記事はそのメモである。
テストの目的
核心は一つ。
実装の内部がどう変わっても、期待している I/O が担保されていれば、全体を組み上げたときに期待した動作になる。
テストが守るのは 関数の入出力契約 であり、守らないのは 内部実装の詳細(分岐の書き方、一時変数、呼び出し回数)である。
この区別を持つと次が得られる。
- 内部実装を自由にリファクタできる(テストが壊れない = 振る舞いは変わっていない)
- バグの境界が明確になる(テストが落ちた = I/O 契約違反)
- 変更の影響範囲が I/O で定義される
逆に、テストが内部実装を知りすぎていると、無関係な機能を変えただけで大量のテストが落ちる。このとき取るべきは「設計に戻って疎結合化する」であって、テストの工夫で取り繕うことではない。
テストピラミッド
E2E を頂点としたピラミッド型を採用する。
/\
/ \ E2E ← 現フェーズでは導入しない
/----\
/ \ Integration ← Repository のみ最小限
/--------\
/ \ Unit ← 主軸。厚く整備
/------------\
基本思想は次の役割分担だ。
Unit Test で「ロジックとして期待通り動く」ことを担保し、 Integration Test で「実環境と繋げても動く」ことを担保する。
Integration でビジネスロジックの分岐網羅を試みたり、E2E で境界値を確認したりすると、ピラミッドが逆転して 遅く壊れやすいテスト群 になる。役割分担を守ることがピラミッドを保守可能に保つ条件になる。
積み上げ型テスト
最小単位から組み上げる実装方針に沿って、テストも積み上げる。
[Value Object / 純粋関数] ← 単独で I/O を固める
↓
[Entity / Aggregate] ← 協調の振る舞いを固める
↓
[Policy / Domain Service] ← ドメインルール
↓
[Usecase] ← 業務フロー
↓
[Repository / Entrypoint] ← 外界との境界変換
各レイヤーで検証する振る舞いは違う。同じ設問を使い回すと、どこかの層でテストが冗長になるか、どこかの層で責務が抜け落ちる。レイヤーごとに「何を問うテストを書くか」を分ける必要がある。
| レイヤー | 検証する振る舞い | 典型的なテスト対象 |
|---|---|---|
| Value Object | 値の正当性 | 形式違反で型付きエラー |
| Entity | 生成・状態遷移 | 不変条件を満たす / 違反で型付きエラー |
| Policy | ルール判定 | 正常時は通過 / 違反時に期待する型を throw |
| Usecase | 業務フロー・分岐 | 完了する / 前提違反で型付きエラー / 例外時のロールバック |
| Repository | 永続化境界の契約 | I/O・DB エラーの伝播(実装検証は Integration へ) |
| Entrypoint (API) | プロトコル変換 | Request → Usecase → Response / 認証・認可 / errorMap 経由の HTTP 変換 |
モック方針
境界の契約をモックする。内部呼び出しはしない
// ✅ 良い: Repository の契約(findBySub は User | null を返す)をモック
const userRepository = { findBySub: vi.fn().mockResolvedValue(null) };
// ❌ 悪い: 内部で使っている SQL クエリ関数をモック
vi.spyOn(internalSqlBuilder, "select").mockReturnValue(/* ... */);
前者はリファクタに強い(Repository が内部でどう動いても壊れない)。後者は実装を変えると必ず壊れる。
モックの「嘘」への対処
Unit Test のモックは 書いた人が信じている契約 であって、実装の契約そのものではない。モックと実装が乖離していた場合、Unit Test では 構造的に検出不可能 である。
この乖離は Integration Test が補う。つまり、Unit Test でモックを使う正当性は、Integration Test が Repository / 外部 API の実装と契約の一致を保証する ことに依存する。Repository の Integration Test を整備しない限り、Unit のモック戦略は前提が崩れる。
Integration / E2E はいつ書くか
判断軸は「機能の重要度」ではなく 「Unit で検出できないリスクの有無」 である。
書く対象:
- Repository の実 DB 検証(Unit のモック戦略の前提を支える)
- 複数集約をまたぐトランザクション境界
- 金銭的影響のある不可逆なフロー
書かない対象:
- Unit で十分検証できる CRUD
- 失敗時の影響範囲が限定的な管理画面機能
- めったに使われないエッジ機能
E2E Test は 必要性を感じたタイミングで導入を検討する 方針とし、現フェーズでは導入しない。Entrypoint Sociable Unit(Hono app.request())で HTTP 層の正常動作は担保でき、Repository Integration で実 DB の疎通も担保できるため、現時点では E2E を追加する必要性を感じていない。将来、クリティカルパスでモック越しには拾えないリスクが顕在化してきたら、その時点で改めて導入範囲を判断する。
アンチパターン
やらないことを列挙しておく。
1. 内部実装の詳細を検証する
// ❌ 呼び出し回数という実装詳細を検証
expect(repository.find).toHaveBeenCalledTimes(2);
// ✅ 振る舞いで検証
expect(result).toEqual(expectedValue);
2. 時間・乱数・ネットワークへの直接依存
await fetch(...) を直接呼ぶ Unit Test、new Date() で現在時刻に依存するテスト、環境変数の実値に依存するテスト——すべてモックで切る。flaky なテストは信頼性をじわじわ削る。
3. 数値カバレッジを埋めるためだけのテスト
// ❌ 呼ぶだけで何も検証していない
it("getter", () => {
user.getId();
});
カバレッジは増えるが「振る舞いが守られている」証拠にはならない。
4. ピラミッドの逆転
Integration でビジネスロジックの分岐網羅を試みる。E2E で境界値・異常系を確認する。どちらもピラミッドを逆転させ、遅く壊れやすいテスト群を生む。
まとめ
この戦略の本質は一言に集約できる。
テストは I/O 契約の自動化された証拠である。
- 各レイヤーは「その層で守るべき振る舞い」だけをテストする
- モックは境界の契約に閉じる。内部実装は自由にリファクタできる状態を保つ
- Unit 厚く、Integration は境界の疎通のみ、E2E は必要性が見えた時に検討する
- カバレッジの数値は追わない。振る舞いの網羅はレビューで担保する
「テストが書きにくい」と感じたら、まず設計に戻る。テストの書きにくさは、疎結合ではないことのサインである。書けない構造を抱えたままテストの工夫で取り繕っても、後で必ず破綻する。