テストしづらさは設計のサイン
テストを書こうとしたときに、準備が大変すぎる、外部サービスが必要になる、結果が毎回変わる。こうした状態は、単にテスト技術が足りないのではなく、設計に問題があるサインです。
テストしやすいコードは、多くの場合そのまま変更しやすいコードでもあります。責務が小さく、依存が明確で、副作用が分離されているからです。
初心者がまず分けるべきもの
最初に意識したいのは、次の3つを混ぜないことです。
| 種類 | 例 | テストしづらくなる理由 |
|---|---|---|
| 計算 | 税込金額を計算する | 本来は簡単にテストできる |
| I/O | DB、ファイル、HTTP APIを呼ぶ | 実行環境に依存する |
| 現在時刻・乱数 | new Date(), Math.random() | 結果が毎回変わる |
計算だけの関数は、入力と出力が決まっているためテストが簡単です。
export function calcTaxIncluded(price: number, taxRate: number): number {
return Math.round(price * (1 + taxRate));
}
calcTaxIncluded(1000, 0.1); // 1100
一方で、計算の中にDB更新や現在時刻の取得が混ざると、テストの準備が急に重くなります。
悪い例:1つの関数に責務が集まりすぎている
async function createInvoice(userId: string, items: Item[]) {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) throw new Error("ユーザーが見つかりません");
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const issuedAt = new Date().toISOString();
const invoice = await db.invoice.create({
data: { userId, total, issuedAt },
});
await mailer.send(user.email, "請求書を発行しました");
return invoice;
}
この関数は、ユーザー取得、金額計算、時刻取得、DB保存、メール送信を同時に行っています。テストするにはDBとメール送信を用意しなければならず、失敗原因も分かりにくくなります。
良い例:純粋な処理と副作用を分ける
まず、金額計算を独立させます。
export function calcInvoiceTotal(items: Item[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
次に、外部依存を引数として受け取れるようにします。
interface InvoiceDeps {
db: Database;
mailer: Mailer;
now: () => Date;
}
async function createInvoice(userId: string, items: Item[], deps: InvoiceDeps) {
const user = await deps.db.user.findUnique({ where: { id: userId } });
if (!user) throw new Error("ユーザーが見つかりません");
const total = calcInvoiceTotal(items);
const issuedAt = deps.now().toISOString();
const invoice = await deps.db.invoice.create({
data: { userId, total, issuedAt },
});
await deps.mailer.send(user.email, "請求書を発行しました");
return invoice;
}
この形なら、テストでは偽物の db, mailer, now を渡せます。現在時刻も固定できるため、結果が安定します。
依存性注入は大げさな仕組みではない
依存性注入という言葉は難しく見えますが、基本は「関数やクラスの中で直接作らず、外から渡す」だけです。
// 直接作る: テストで差し替えにくい
const client = new PaymentClient();
// 外から渡す: テストで差し替えやすい
function createOrder(paymentClient: PaymentClient) {
// ...
}
DIコンテナを導入しなくても、引数やコンストラクタで依存を渡すだけで十分な場面は多くあります。
中級者向け:境界を決める
すべてを細かく分ければよいわけではありません。テストしやすさのために見るべき境界は、変更理由と副作用です。
| 境界 | 分ける理由 |
|---|---|
| 計算ロジック | 入力と出力だけで検証できる |
| DBアクセス | スキーマやクエリ変更の影響を閉じ込める |
| 外部API | 通信失敗・タイムアウトを模擬しやすくする |
| 日時・乱数 | テスト結果を安定させる |
| 認可チェック | 許可/拒否ケースを網羅しやすくする |
特に外部APIやDBは、直接呼ぶコードが散らばるほどテストも変更も難しくなります。薄いラッパーを作り、アプリケーション側はその抽象に依存する形が扱いやすいです。
テストしやすい設計チェックリスト
コードレビューでは、次の観点を見ると設計の問題に気づきやすくなります。
□ 計算とI/Oが同じ関数に混ざっていないか
□ 現在時刻・乱数・環境変数を直接読んでいないか
□ DBや外部APIをテストで差し替えられるか
□ 失敗ケースを明示的にテストできるか
□ 1つの関数が複数の変更理由を持っていないか
□ エラー処理が呼び出し側から観察できるか
まとめ
- テストしづらいコードは、責務や依存が混ざっていることが多い
- 計算、I/O、時刻・乱数を分けるとテストが安定する
- 依存性注入は、外部依存を差し替えられるようにする基本技術
- 境界を分ける目的は、過剰な抽象化ではなく変更と検証を楽にすること
- テストしやすい設計は、結果として保守しやすい設計になりやすい
設計原則の全体像はSOLID原則、よく使う設計パターンはデザインパターン入門で確認できます。