メインコンテンツへ移動
SEMentor
設計 約12分 設計 curriculum

テストしやすい設計の基本

単体テストを書きやすいコードにするために、責務分離、依存性注入、副作用の分離を実務例で学ぶ

テストしづらさは設計のサイン

テストを書こうとしたときに、準備が大変すぎる、外部サービスが必要になる、結果が毎回変わる。こうした状態は、単にテスト技術が足りないのではなく、設計に問題があるサインです。

テストしやすいコードは、多くの場合そのまま変更しやすいコードでもあります。責務が小さく、依存が明確で、副作用が分離されているからです。

初心者がまず分けるべきもの

最初に意識したいのは、次の3つを混ぜないことです。

種類テストしづらくなる理由
計算税込金額を計算する本来は簡単にテストできる
I/ODB、ファイル、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原則、よく使う設計パターンはデザインパターン入門で確認できます。

Beginner to Intermediate

テストしやすい設計の基本を実務につなげる学び方

設計は、言葉を知るだけでなく「どこで使う知識か」まで結びつけると定着します。初学者は全体像をつかみ、中級者は切り分けや設計判断に使える形へ伸ばしていきましょう。

初心者の到達点

  • クラスや関数の責務を、入力・処理・出力の単位で小さく分けて考える。
  • 設計原則は暗記ではなく、変更しやすさを保つための判断軸として使う。

中級者の観点

  • 抽象化を増やす前に、実際に変化しそうな要件と重複の大きさを確認する。
  • 依存方向、テスト容易性、変更範囲をレビューで説明できるようにする。

手を動かす練習

  • 既存コードの1関数を選び、責務が1つに見えるかを点検する。
  • 変更要求が来たときに、どのファイルへ影響するかを先に予測する。

このレッスンは未完了です。