ウェブエンジニア問題集
第5章

単体テストのメソッド

6
この章の目次開く
学習者学習者

テストって結局、何をどうやって検証するのが正解なの?戻り値を見ればいいの?

テスト手法を選んでいる人のイラスト

3つの単体テストの手法

  • 出力値ベース(戻り値を検証)
  • 状態変化ベース(状態の変化を検証)
  • コミュニケーションベース(オブジェクト間のやり取りを検証)

単体テストで何を検証するかは、テスト対象の性質によって変わります。

大きく分けると、戻り値を見る、状態の変化を見る、外部とのやり取りを見る、の3つです。

出力値ベースの単体テスト

入力を与えて戻り値だけを検証する手法です。

この手法を使用できるケースは、基本的にテスト対象とその依存先オブジェクトの状態が変わらない場合です。 そのため検証値は戻り値のみです。

function calculateTaxIncludedPrice(price: number, taxRate: number) {
  return Math.floor(price * (1 + taxRate));
}
 
it('税込価格を返す', () => {
  const result = calculateTaxIncludedPrice(1000, 0.1);
 
  expect(result).toBe(1100);
});
typescript

出力値ベースのテストは、準備が少なく、実行が速く、リファクタリングに強いです。

ビジネスルールや計算処理は、できるだけこの形でテストできるように設計すると保守しやすくなります。

状態変化ベースの単体テスト

メソッド呼び出し後にオブジェクトや DBの状態が変化したかを検証する手法です。

class Counter {
  count = 0;
 
  increment() {
    this.count += 1;
  }
}
 
it('incrementを呼ぶとcountが1増える', () => {
  const counter = new Counter();
 
  counter.increment();
 
  expect(counter.count).toBe(1);
});
typescript

状態変化ベースのテストは、戻り値を返さない処理に向いています。

ただし、状態を見る範囲が広くなるほど、テストは読みづらくなります。検証する状態は、その振る舞いに必要なものだけに絞ります。

コミュニケーションベースの単体テスト

テスト対象が外部オブジェクト(依存先)を正しく呼び出したかを検証する手法です。モックを使います。

it('注文完了イベントを発行する', () => {
  const eventBus = {
    publish: vi.fn(),
  };
  const service = new OrderService(eventBus);
 
  service.complete('order-1');
 
  expect(eventBus.publish).toHaveBeenCalledWith('order.completed', {
    orderId: 'order-1',
  });
});
typescript

コミュニケーションベースのテストは、外部への通知やAPI呼び出しが重要な振る舞いである場合に使います。

一方で、内部オブジェクトの呼び出しを細かく検証しすぎると、リファクタリングに弱くなります。

先生先生

迷ったら、まず戻り値で確認できないかを考えます。戻り値が難しければ状態変化、それでも難しければ外部とのやり取りを見ます。

3つの手法の比較

テストは出力値ベースが最もリファクタリング耐性が高く、保守コストが低いです。

すべてを出力値ベースにできるわけではありませんが、重要なビジネスロジックはできるだけ出力値ベースでテストできる形に寄せるべきです。
手法リファクタリング耐性維持性保守コスト
出力値ベース◎ 高い◎ 高い◎ 低い
状態変化ベース○ 中程度○ 中程度○ 中程度
コミュニケーションベース△ 低い△ 低い△ 高い

リファクタリング耐性とは、実装を変更しても(振る舞いが同じなら)テストが壊れない度合いです。

維持性とは、テストコードの読みやすさ・メンテのしやすさです。

保守コストとは、テストが壊れたときの修正コストや、テストコードを変更し続けるコストです。

手法の選び方

どの手法を選ぶかは、次の順番で考えます。

たとえば、料金計算は出力値ベースに向いています。

一方、カートに商品を追加する処理は、カートの中身が変わるので状態変化ベースになります。

メール送信やイベント発行は、外部への出力が本質なのでコミュニケーションベースになります。

出力値ベースのテストと関数型プログラミング

関数型プログラミングは、副作用を最小限に抑えるプログラミングパラダイムです。

そして出力値ベースのテストは対象が純粋関数であることが前提であるため、関数型プログラミングと相性が良いです。

関数型プログラミングとは、数学的関数を扱います。

数学的関数とは、入力に対して唯一の出力を返すものです。純粋関数とも呼ばれます。

逆に純粋関数でない隠れた出力があると、テストが壊れやすくなります

純粋関数と副作用の分離に気づく人のイラスト
副作用
オブジェクトの状態変化、DBへの書き込み
例外
例外が発生した場合の処理は、定義されていない出力といえます
状態の参照
Date.now() のように、現在時刻を参照する関数は純粋関数ではありません

関数型プログラミングの導入で、ビジネスロジックと副作用の分離ができます

単体テストしやすい設計にするには、ビジネスロジックと副作用を分けます。

たとえば、注文合計を計算してDBに保存する処理があるとします。

async function createOrder(items: Item[]) {
  const total = items.reduce((sum, item) => sum + item.price, 0);
 
  await orderRepository.save({
    items,
    total,
  });
}
typescript

この関数は、合計金額の計算とDB保存を同時に行っています。

テストではDB保存をモックする必要があり、計算ロジックだけを確認したい場合でも準備が重くなります。

計算を純粋関数として切り出すと、出力値ベースのテストを書けます。

function calculateOrderTotal(items: Item[]) {
  return items.reduce((sum, item) => sum + item.price, 0);
}
 
async function createOrder(items: Item[]) {
  const total = calculateOrderTotal(items);
 
  await orderRepository.save({
    items,
    total,
  });
}
typescript
it('商品の合計金額を返す', () => {
  const items = [
    { name: 'Book', price: 1000 },
    { name: 'Pen', price: 200 },
  ];
 
  const total = calculateOrderTotal(items);
 
  expect(total).toBe(1200);
});
typescript

副作用を完全になくすことはできません。

しかし、ビジネスロジックを副作用の外側に押し出すことで、重要な仕様を軽く、速く、壊れにくい単体テストで守れるようになります。

まとめ: まずは出力値ベースを目指す

単体テストの手法は、どれか1つだけを使えばよいわけではありません。

ただし、保守しやすさの観点では、次の順で考えると判断しやすくなります。

  1. ビジネスロジックは出力値ベースでテストする
  2. 状態を持つオブジェクトは状態変化ベースでテストする
  3. 外部への通知やAPI呼び出しはコミュニケーションベースでテストする

この順番を意識すると、モックに頼りすぎず、リファクタリングに強いテストを書きやすくなります。

ユニットテストクイズに挑戦するこの章で学んだユニットテストの知識を、4択クイズでアウトプットして定着させよう