🍡

「単体テストの考え方/使い方」勉強会03 〜単体テストの3つの手法〜

2025/03/04に公開

Specteeでエンジニアをしている永野、峯田、和山、國久です。

今回は社内輪読会で読んだ書籍「単体テストの考え方/使い方」から、単体テストの3つの手法についてまとめました。

本記事は数回に分けて輪読会参加メンバーが執筆しています。
前回の記事(モックの概要・使いどころ)はこちら。

https://zenn.dev/spectee/articles/spectee-unit-test-mock

単体テストの3つの手法

単体テストには次の3つの手法があります。

  • 出力値ベーステスト
  • 状態ベーステスト
  • コミュニケーションベーステスト

以下でそれぞれの手法について説明していきます。

出力値ベーステスト

出力値ベーステストは、テスト対象のコードに入力値を渡し、そこから返される結果を検証する手法です。

基本的に処理を行った後にテスト対象や協力者オブジェクトの状態が変わらない場合にのみ適用でき、検証対象は戻り値だけになります。

例えば以下のようなテストです。
なお、テストコード内の sut は System Under Test(テスト対象システム)の略で、テスト対象のコードを表します。

sut.cs
public class PriceEngine
{
  public decimal CalculateDiscount(params Product[] products)
  {
    decimal discount = products.Length * 0.01m;
    return Math.Min(discount, 0.2m);
  }
}
test.cs
// 商品が2個ある場合の割引率
[Fact]
public void Discount_of_two_products()
{
  var product1 = new Product("Hand wash");
  var product2 = new Product("Shampoo");
  var sut = new PriceEngine();

  decimal discount = sut.CalculateDiscount(product1, product2);

  Assert.Equal(0.02m, discount);
}

状態ベーステスト

状態ベーステストは、検証する処理の実行が終わった後にテスト対象の状態を検証する手法です。

ここでいう「状態」とは、テスト対象システムの状態やその協力者オブジェクトの状態、データベースやファイルシステムなどのプロセス外依存の状態を指します。

例えば以下のようなテストです。

sut.cs
public class Order
{
  private readonly List<Product> _products = new List<Product>();
  public IReadOnlyList<Product> Products => _products.ToList();

  public void AddProduct(Product product)
  {
    _products.Add(product);
  }
}
test.cs
// 注文に商品を追加する
[Fact]
public void Adding_a_product_to_an_order()
{
  var product = new Product("Hand wash");
  var sut = new Order();

 sut.AddProduct(product);

  Assert.Equal(1, sut.Products.Count);
  Assert.Equal(product, sut.Products[0]);
}

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

コミュニケーションベーステストは、モックを用いてテスト対象システムとその協力者オブジェクトの間で行われるコミュニケーションを検証する手法です。

特定のメソッドが呼び出されたかどうか、呼び出された回数や引数が正しいかどうかなどを検証します。

例えば以下のようなテストです。

test.cs
// 挨拶のメールを送信する
[Fact]
public void Sending_a_greetings_email()
{
  var emailGatewayMock = new Mock<IEmailGateway>();
  var sut = new Controller(emailGatewayMock.Object);

  sut.GreetUser("user@example.com");

  emailGatewayMock.Verify(
    x => x.SendGreetingsEmail("user@example.com"),
    Times.Once
  );
}

3つの手法の比較

比較の観点として、本書では「良い単体テストを構成する4本の柱」を使っています。
まず、この良い単体テストを構成する4本の柱を軽く紹介します。

良い単体テストを構成する4本の柱

1. 退行に対する保護
テストをすることで退行 (regression) もしくはバグの存在をいかに検出できるかを示す性質

2. リファクタリングへの耐性
いかに偽陽性を生み出すことなく、プロダクションコードに対してリファクタリングを行えるかを示す性質

3. 迅速なフィードバック
テストの実行時間がどのくらい短くなるのかに影響する性質

4. 保守のしやすさ
以下の2点から評価される性質

  • 何をテストしているのかを理解することがどのくらい難しいか
  • テストを実施することがどのくらい難しいか

今回はこの4本の柱のうち、比較したときに差が出る 「リファクタリングへの耐性」「保守のしやすさ」 の観点で3つの手法を比較していきます。

リファクタリングへの耐性の観点での比較

リファクタリングへの耐性は、テストが実装の詳細と結び付くほど低くなり、リファクタリングへの耐性が低いと偽陽性が発生しやすくなります。

リファクタリングへの耐性が最も高いのは出力値ベーステストです。
なぜなら、この手法が見ることになるのはテスト対象メソッドだけであり、出力値ベーステストが実装の詳細と結び付く唯一のケースはテスト対象メソッド自体が実装の詳細である場合だけだからです。

一方、状態ベーステストは出力値ベーステストと比べてリファクタリングへの耐性が低くなります。
なぜなら、状態ベーステストはテスト対象のメソッドの実行に加え、そのメソッドの実行によって変更されたオブジェクトの状態も見ることになるからです。
テストとプロダクションコードの結び付きが増えるほど、テストが実装の詳細と深く結び付く機会が多くなり、結果としてリファクタリングへの耐性が低くなりやすくなります。

最後に、コミュニケーションベーステストですが、この手法はリファクタリングへの耐性が最も低いです。
テストダブルとのやり取りを検証しているテストは壊れやすくなる傾向があります。

保守のしやすさの観点での比較

保守のしやすさは、次の2つのことからどのくらい備わっているのかを把握できます。

  • テストを理解することがどのくらい難しいか
    • この度合いはテストのコード量によって変わる
  • テストを実施することがどのくらい難しいか
    • この度合いはテストで直接扱うことになるプロセス外依存の数によって変わる

3つの手法の中では、出力値ベーステストが最も保守のしやすさが高いです。
理由として次の2つが挙げられます。

  • コード量が少なく簡潔になる
  • テスト対象システムや協力者オブジェクトの状態を変えてはいけないので、プロセス外依存を扱わない

状態ベーステストは出力値ベーステストと比べて保守のしやすさが低くなります。
多くの場合、戻り値よりも状態を検証する方がより多くのコードを必要とするからです。

最後にコミュニケーションベーステストですが、この手法は保守のしやすさが最も低いです。
なぜなら、コミュニケーションベーステストはテストダブルを用意し、テスト対象システムと用意したテストダブルとのコミュニケーションを確認できるようにしなくてはならないため、テストに多くのコードが必要になるからです。

3つの手法の比較のまとめ

上記の比較結果を簡単に表にまとめると次のようになります。

出力値ベース
テスト
状態ベース
テスト
コミュニケーションベース
テスト
リファクタリングへの耐性を
維持するのに必要なコスト
低い 普通 普通
保守のしやすさを
維持するのに必要なコスト
低い 普通 高い

この表が示すように、出力値ベーステストが最も費用対効果の高いテストを作成できる手法となります。
こうなる理由は、出力値ベーステストが実装の詳細と結び付くことがほとんどなく、その結果、リファクタリングへの耐性を適切に維持するための労力もそれほど必要にならないからです。
加えて、出力値ベーステストは簡潔でプロセス外依存も扱わないため、保守のしやすいテストを作成することができます。

まとめ

この記事では出力値ベーステスト、状態ベーステスト、コミュニケーションベーステストの3つの手法の紹介と比較をしました。

出力値ベーステストが最も費用対効果の高いテストを作成できるので、なるべく出力値ベーステストを使うようにしたいところです。

しかし、アプリケーションを作るうえで副作用を完全に排除することはできないため、全てのテストを出力値ベーステストにすることはできません。

そのため、ビジネスロジックと副作用を分離し、ビジネスロジックも純粋関数にすることでなるべく出力値ベーステストを適用できるようにコードを書くことが重要だと感じました。

ここまで読んでいただきありがとうございました。

Spectee Developers Blog

Discussion