📙

単体テストの考え方/使い方【第3部】を読んで

に公開

はじめに

単体テストの考え方/使い方の【第3部】を読んで私が解釈した内容をもとに、ソフトウェア開発におけるテスト戦略のうち、統合テストについてまとめました。

単体テストが個々のユニットの正確性を保証する一方で、複数のコンポーネントや外部システムが連携する部分の動作を検証する「統合テスト」もまた不可欠です。この章では、統合テストの定義、その範囲、そしていつ、どのように実施すべきかについて解説します。

実際に本を読んでみると理解度が変わるので、気になれば読んでみてください。違う理解等があれば、コメント等お願いします😌

https://zenn.dev/nasubibocchi/articles/27671d8948b05f

統合テストとは?

統合テストは、単体テストが満たすべき以下の3つの条件のうち、一つでも損なっているテストであると言えます。

1単位の振る舞い(a unit of behavior)を検証すること
実行時間が短いこと
他のテスト・ケースから隔離された状態で実行されること

これは、統合テストが、複数のコンポーネントが連携するシナリオや、データベース、外部APIなどの「プロセス外依存」とのやり取りを含むため、単体テストのような厳密な「単一ユニットの隔離」や「超高速な実行」の条件から外れる場合があることを意味します。

統合テストのケースはどこまで用意するか

統合テストのケースを用意する際には、以下の考え方を基準とします。

  • 単体テスト

    • ビジネスシナリオにおける異常ケースをできるだけ多く検証する。
  • 統合テスト

    • 1件のハッピーケース(ハッピーパス)

      • 全てのプロセス外依存とのやり取りを検証できるような、典型的な成功シナリオを1件考える。
      • もし適切なケースが見つからなければ、ケースを増やしていく。
    • 単体テストでは検証できない全ての異常ケース

あくまでも異常ケースは、単体テストで見ることができないものだけを対象とする。これは、テストピラミッドの適切なバランスを保つことにもつながる。

重要なのは、不必要にテストケースを作成しないことです。テストコードもまた負債であるため、必要のないケースは「作成しないほうがいい」とされます。

統合テストを作らなくてもいいケース

ある特定の異常ケースにおいては、統合テストを作成する必要がない場合があります。これは、そのケースが処理の早い段階で失敗し、副作用(データベースへの書き込み、外部システムへの通知など)を伴わずに済むためです。

例:ユーザー管理システム(書籍の例をRailsコードに置き換えています)

ユーザー登録とメールアドレス変更機能を備えたアプリケーションを想定します。

【仕様】

  • ユーザーのメールアドレスに自社のドメイン名が含まれる場合、type = employee で登録し、それ以外は type = customer で登録する。
  • メールアドレス変更時に type が変更される場合、会社の従業員数を増減する。
  • メールアドレス変更完了後、メッセージ・バスにより外部システムに通知する。
  • ユーザーのメールアドレスが一度確定されたら変更できない。
# app/models/user.rb
class User < ApplicationRecord
  # id, email, type, is_email_confirmed
  
  enum :type, { employee: 'employee', customer: 'customer' }
  validates :email, presence: true, uniqueness: true # 仮のバリデーション
  
  def change_email(new_email, company)
    return if email == new_email
    
    # メールアドレスが一度確定されたら変更できないという条件がある
    return "Can not change a confirmed email" if is_email_comfirmed # is_email_comfirmedはUserの属性と仮定
    
    # メールアドレスのドメインに基づき新しいタイプを決定
    new_type = company.email_corporate?(new_email) ? User.types[:employee] : User.types[:customer]
    
    # タイプが変更される場合、従業員数を増減
    if self.type != new_type
      delta = (new_type == User.types[:employee]) ? 1 : -1
      company.change_employees_number(delta) # companyインスタンスのメソッドを呼び出し
    end
    
    self.email = new_email
    self.type = new_type
    
    nil # エラーがない場合はnilを返す
  end
end

# app/models/company.rb
class Company < ApplicationRecord
  # domain_name, employees_number
  
  def change_employees_number(delta)
    self.employees_number += delta # selfを明示
  end
  
  def email_corporate?(email)
    email_domain = email.split("@").last
    self.domain_name == email_domain # selfを明示
  end
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :set_user, only: :change_email
  
  def change_email(new_email)
    # 自社なんで一個しか登録されてない想定
    company = Company.first
    
    # Userモデルのchange_emailメソッドでエラーが返される場合
    error = @user.change_email(new_email, company)
    
    return error if error # エラーがあれば早期リターン
    
    ActiveRecord::Base.transaction do
      company.save!
      @user.save!
    end
    
    # メールアドレス変更完了を外部システムに通知
    MessageBusService.new.call(@user.id, new_email)
  end
  
  private
  
  def set_user
    unless current_user # current_userはDeviseなどの認証システムから取得すると仮定
      # 認証されていない場合の処理
      # render json: { error: "Unauthorized" }, status: :unauthorized
      # 例のため省略
    end
    
    @user = current_user # 現在のユーザーをセット
  end
end

上記のようなプロダクトコードにおいて、コントローラのテスト(統合テスト)では、メールアドレスが変更できないケースを検証する必要がない場合があります。
これは、User#change_emailメソッドが条件を満たさない場合に早期にエラーを返し、コントローラがその後の副作用を伴う処理(データベース更新や外部通知)に進まないためです。

一方で、モデルのテスト(単体テスト)では、validationやis_email_confirmedなどの条件によってメールアドレスが変更できない全ての異常ケースを網羅したテストを作成する必要があります。このように役割分担することで、単体テストのボリュームが自然と多くなり、テストピラミッドに沿った効率的なテスト戦略が実現されます。

モックに置き換えるべきプロセス外依存とは

テストにおける「依存」とは、テスト対象のシステムが適切に動作するために必要とする他のコンポーネントやサービスを指します。これらは大きく二つに分類されます。

  • 管理下にある依存(managed dependency)
    テスト対象のアプリケーションが自由にコントロールできるプロセス外依存です。例えば、アプリケーション内部の別のモデルやサービスへの呼び出しなどが該当します。これらとのコミュニケーションは**「実装の詳細」とみなされ、テストではモックを使わず、実際のインスタンスを使ってテスト**することが推奨されます。

  • 管理下にない依存(unmanaged dependency)
    テスト対象のアプリケーションが直接コントロールできないプロセス外依存です。例としては、メールサービス、メッセージバス(非同期データ送受信)、外部API、Sidekiqによる非同期実行などが挙げられます。これらとのコミュニケーションは**「観察可能な振る舞い」とみなされ、テストの実行速度と安定性を保つためにモック(またはスパイ)を使ってテスト**することが推奨されます。

ちなみにE2Eテストは、統合テストでモックに置き換えた部分(管理下にない依存)まで全て含めて、実際の外部システムと連携しながらテストを行います。

ログ出力のテストは必要?

ログの出力がテストの対象となるかどうかは、それがアプリケーションの「観察可能な振る舞い」なのか、それとも「実装の詳細」なのかによって決めるべきです。

もしログ出力自体がビジネスロジックの重要な一部であり、その内容やタイミングがクライアントにとって「観察可能な振る舞い」として定義される場合(例: 特定の監査ログなど)は、テスト対象とすることができます。
しかし、単にデバッグや内部的な処理のために出力されるログであれば、それは「実装の詳細」であり、テストコードが特定のログ形式や内容に結びつくと、リファクタリングの際にテストが壊れやすくなります。このような場合は、コード(現在の実装)に結びついたテストを作成しないほうが良いです。

モックのベストプラクティス

これまでの内容から、モックの利用に関して以下のベストプラクティスが挙げられます。

モックを使うのは統合テストだけ

アプリケーション内に存在する依存関係(管理下にある依存)は、モックに置き換えずに実際のインスタンスを使用することが良いとされます。これは、その依存関係が変更されても統合テストが壊れないようにするためです。

アプリケーション層(Controller)でのモック/スパイの利用

コントローラはプロセス外依存(外部サービスなど)との連携を含むため、これらの「管理下にない依存」に対しては、モックやスパイ(フレームワークによるものではなく、手書きのテスト・ダブル)を使用することが効果的です。これにより、コントローラテストが高速に実行され、外部環境に依存しないものとなります。

感想

統合テストは、単体テストでは検証しきれない連携部分や外部依存に関わるケースに限定して作成するのが良いテストスイートを作成することにつながると感じました。

ドメイン・モデルをできるだけプロセス外依存を含むコントローラの処理から切り離すことで、単体テストと統合テストの区別とそれぞれの役割が明確になります。その結果、統合テストで本当に作成すべきテストケースを考えやすくなり、効率的かつ効果的なテスト戦略が実現できるのだと思います。

Discussion