05. 設計時にテストを織り込む
目的
- テスト容易性を「後付け作業」にしないための設計原則を押さえる
- 観測できる振る舞い・境界・依存の扱いを整理し、保守性を上げる
得られる判断能力
- 何を単体で守り、何を統合/E2E で守るべきかを設計段階で判断できる
- 観測できる振る舞い(契約)にテストを寄せ、実装詳細依存を避けられる
- モック/スタブを「境界」に集中させ、テストの変更耐性を上げられる
前提/用語
- 観測性: 期待する結果が、テストで確実に検証できる性質
- CQS: Command-Query Separation(状態変更と参照の分離)
- スタブ/モック: 外部依存を代替し、制御可能にする手段
要点
- テストは「内部実装」より「振る舞い(契約)」を守る
- 副作用境界を明確にし、コアは単体テストで守れる形にする
- モックが増える設計は、境界の置き方が不適切なシグナルになり得る
良いテストの4本柱(本書の評価軸)
本書では、自動テストを次の 4 観点で評価します。
- 価値: 重要なリスク(価値導線、障害影響、損失)を守れているか
- 保守性: 実装変更に強いか(テストが頻繁に壊れないか)
- 壊れにくさ: 非決定性(時刻/乱数/外部I/O)によりフレークしないか
- フィードバック速度: 実行時間と診断容易性が適切か(失敗時に原因が追えるか)
統合テストの境界(本書の定義)
本書では「統合テスト」を、domain/usecases と adapters の境界を跨ぐ契約を検証するテストとして扱います(章 04 の最小アーキテクチャ前提)。
- 単体テストで無理に扱わない(統合で扱う)典型:
- DB 永続化(スキーマ/クエリ/トランザクション)
- HTTP 入出力(ルーティング、入力検証、エラー形式)
- 認可(ロール/所有者ルールと API の接続)
- 外部通知 I/F(失敗/遅延/リトライの扱い)
- 小規模での線引き(現実解):
- DB は「実物」を使い、repository adapter 経由で検証する
- ネットワーク越しの外部サービスは、統合テストでは直接叩かず、adapter の契約をテストダブルで固定する(フレーク源になりやすい)
- HTTP 入出力(入出力/エラー/冪等性)は API 契約として残すと、期待値(契約)が揃いやすい(任意: Appendix B(B-14) / 記入例: Appendix D(D-22))
モック/スタブ方針(境界に寄せる)
モック/スタブは「依存を制御する」ための手段であり、設計上の境界を表します。本書の基本方針は次の通りです。
domain: 原則としてテストダブル不要(純粋関数に寄せる)- 時刻/乱数などはグローバル参照せず、引数で受ける(例:
now: Date)
- 時刻/乱数などはグローバル参照せず、引数で受ける(例:
usecases: port(依存先インターフェース)を引数で受け、スタブ/フェイクで差し替える- 「呼ばれたこと」を見る必要がある場合は、最小のスパイ(呼び出し記録)に留める
- 呼び出し順序や内部の回数など、契約でない要素をテストに含めない(変更耐性を落とす)
adapters: I/O を含むため、統合テストで契約を検証する- 外部 I/F は adapter の入力/出力と失敗時の扱いを固定する(例: タイムアウト時は再送キューに積む。任意: Appendix B(B-16) / 記入例: Appendix D(D-24))
この方針で「モック地獄」を避けやすくなります。モックが増え続ける場合は、境界が薄い/責務が混ざっているサインとして扱ってください。
依存注入(依存を引数で受ける)
小規模 TS では、DI コンテナを導入する前に「依存を引数で受ける」だけで十分なことが多いです。
例: ユースケースが port を引数で受ける(疑似コード)
export type AssignTaskDeps = {
now: () => Date;
saveAssignment: (input: {
taskId: string;
assigneeId: string;
assignedAt: Date;
}) => Promise<void>;
notifyAssigned: (input: { taskId: string; assigneeId: string }) => Promise<void>;
};
export async function assignTask(
deps: AssignTaskDeps,
input: { taskId: string; assigneeId: string }
): Promise<void> {
const assignedAt = deps.now();
await deps.saveAssignment({ ...input, assignedAt });
await deps.notifyAssigned(input);
}
テストでは now/saveAssignment/notifyAssigned をテストダブルに差し替え、外部 I/O を含まずに「期待する振る舞い」を検証できます。統合テストでは saveAssignment を実 DB に向け、notifyAssigned は adapter の契約として別途検証します。
成果物駆動(仕様→テスト観点)
テスト容易性を上げる近道は「実装からテストを考える」のではなく、仕様(Behavior)を成果物として固定し、そこからテスト観点を導出することです。
本書では Appendix B のテンプレを次の順で使うことを推奨します。
- 仕様(B-3): 受け入れ条件/例外系/観測点を確定する(何を見れば合否が分かるか)
- テスト観点(B-3内): 仕様の各項目から「守るべき契約」を箇条書きに落とす
- 配分(B-8): その契約を単体/統合/E2E のどこで守るか決める
導出のコツ(B-3 → テスト観点):
- 受け入れ条件(Given/When/Then): 価値導線として E2E 候補になる(ただし外部I/Fは直接観測しない)
- 例外系/失敗時(Behavior): 境界(認可、入力検証、競合、外部I/F)として統合テスト候補になる
- 観測点: 何を assert するかを決める(観測点が薄いとフレークと過剰モックが増える)
例(割り当て + 通知)での観点の出し方(抜粋):
- 受け入れ条件: 「割り当てが成功し、UI 表示が更新される」→ E2E の最小導線
- 例外系: 「非管理者は
403」→ 統合(HTTP+認可)の契約 - 外部I/F失敗: 「通知失敗でも業務継続し、検知と再送起点が残る」→ 統合(境界)の契約
例(ランニング例)
タスク管理では、次の分離がそのままテスト方針になります。
- domain(単体を厚く):
- 期限判定(期限超過/残日数)
- 状態遷移(
todo → in_progress → done) - 入力バリデーション(タイトル必須、期限形式)
- adapters(統合/E2E):
- DB(保存/検索)
- メール送信(外部I/F、失敗/遅延)
- 認可(ロール、所有者)
CQS を小規模TSで扱う
- Query: 現在の状態を返す(副作用なし)
- Command: 状態を変える(副作用は境界に集約)
例: domain の Query(純粋)
export type DueStatus = "ok" | "due_soon" | "overdue";
export function calcDueStatus(now: Date, dueAt?: Date): DueStatus {
if (!dueAt) return "ok";
const diffMs = dueAt.getTime() - now.getTime();
const dayMs = 24 * 60 * 60 * 1000;
if (diffMs < 0) return "overdue";
if (diffMs <= 2 * dayMs) return "due_soon";
return "ok";
}
例: 単体テスト(Vitest)
import { describe, it, expect } from "vitest";
import { calcDueStatus } from "./calcDueStatus";
describe("calcDueStatus", () => {
it("期限なしは ok", () => {
expect(calcDueStatus(new Date("2026-01-01T00:00:00Z"))).toBe("ok");
});
it("期限超過は overdue", () => {
expect(
calcDueStatus(
new Date("2026-01-02T00:00:00Z"),
new Date("2026-01-01T00:00:00Z")
)
).toBe("overdue");
});
});
ポイントは「時刻を引数で受ける」ことです。Date.now() を直接使うと、テストが非決定的になりやすいです。
演習(最小1個)
ランニング例のユースケースを 1 つ選び、次を分類してください。
- 純粋(単体で厚く守る): 入力→出力で検証できるもの
- I/O(統合/E2E): DB/HTTP/メールなど外部に依存するもの
分類結果をもとに、Appendix B の「仕様(Behavior)テンプレ(B-3)」のうち テスト観点 を 5〜10 行で埋めてください。
例(選択肢):
- タスク作成
- タスク割り当て(通知あり)
- タスク完了
よくある失敗
- テストのために抽象化を増やし、設計が複雑化する(目的が逆転する)
- モックの状態がテストの本質になり、変更に弱くなる(モック地獄)
- 例外系の振る舞い(失敗時、ログ、リトライ)が曖昧なまま実装する
チェックリスト
- 単体テスト対象(コア)と統合テスト対象(境界)が分けられている
- 統合テストの境界(DB/HTTP/認可/外部通知など)が列挙されている
- 例外系の振る舞いが要件・受け入れ条件と整合している
- モック/スタブの利用理由が説明できる(依存の制御)
- port(依存先)を引数で受けられる設計になっている(DI)
- テストが実装詳細ではなく振る舞い(契約)を検証している
- 非決定性(時刻、乱数、外部I/O)を制御できている