📗

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

に公開

第2部:単体テストとその価値

「良い単体テスト」とは何でしょうか?
テストコードもまた負債となり得るため、その価値を最大限に引き出す設計が求められます。
『単体テストの考え方 / 使い方』(マイナビ出版)の内容を読んで、私が解釈した内容をまとめました。
実際に本を読んでみると理解度が変わるので、気になれば読んでみてください。違う理解等があれば、コメント等お願いします😌

https://zenn.dev/nasubibocchi/articles/88e4333fbe6ff4

4本の柱

良い単体テストは、以下の4つの柱によってその価値が支えられます。

  • 退行(regression)に対する保護
    既存の機能が、新たな変更によって壊れていないことを保証します(偽陰性をなくす)。
  • リファクタリングへの耐性
    プロダクトコードのリファクタリング(内部構造の変更)によって、テストが不必要に失敗しないこと(偽陽性を減らす)。
  • 迅速なフィードバック
    テストの実行時間が短く、問題が速やかに検知できること。
  • 保守のしやすさ
    テストコードが理解しやすく、テスト環境の構築が容易であること。

退行への保護とリファクタリングへの耐性は密接に関連しています。プロジェクト初期は、機能の追加や変更が頻繁なため、退行への保護が重視されがちです。しかし、プロジェクトが大規模になるにつれて、リファクタリングの機会が増え、偽陽性への対策も同様に重要になります。

偽陽性は、テストコードが「アプリケーションの観察可能な振る舞い」ではなく、「コードの実装の詳細」を確認している場合に起こりやすくなります。
これを避けるためには、実装の詳細(手順)ではなく、最終的な結果を確認するようにテストを記述することが重要です。

迅速なフィードバックは、テストを高速に実行できることを意味します。これにより、プロダクトコードのバグに素早く気づき、修正することができます。
保守のしやすさは、テストコード自体の可読性と、テスト環境の構築の容易さに直結します。

理想的なテストとは

理想的なテストは、リファクタリングへの耐性と保守のしやすさを最大限に備えつつ、退行に対する保護と迅速なフィードバックの達成度をバランス良く調整したものです。

テストの価値は、これら4つの柱の出来具合の掛け算で表すことができます。

テストの価値=[0..1]×[0..1]×[0..1]×[0..1]

ただし、全てを完璧にすることは難しく、4つの柱のうち、保守のしやすさ以外はお互いに干渉し合うため、どれかを完璧にしようとすると他が犠牲になるトレードオフの関係にあります。

  • リファクタリングへの耐性
    現実的には犠牲にできない要素であり、ほぼ100%を目指すべきです。
  • 保守のしやすさ
    他の指標に依存しないため、完璧を目指すことが可能です。
  • 退行に対する保護
    E2Eテストが究極の保護を提供しますが、保護を厚くすればするほど、テストは遅くなる傾向にあります。
  • 迅速なフィードバック
    速さを追求しすぎると、プロダクションコードを別の書き方で表しているだけのような、取るに足らないテストが生まれやすくなります。

以下の図は、これらの関係性を示しています。

図:テストの4本の柱の関係性

テストピラミッド

理想的なテストの配分を表す概念としてテストピラミッドがあります。これは、小さいテストほど多く、大きいテストほど少ないケースを用意するという考え方です。

図:テストピラミッド

ピラミッドの下層(単体テスト)は実行が速く、フィードバックも迅速ですが、退行に対する保護は限定的です。上層(E2Eテスト)は退行に対する保護が強力ですが、実行に時間がかかり、フィードバックも遅くなります。このバランスを考慮してテストを配置することが重要です。

ホワイトボックステストとブラックボックステスト

テストには、そのアプローチによって「ホワイトボックステスト」と「ブラックボックステスト」の2種類があります。

  • ブラックボックステスト
    システムの内部構造を知ることなく、その**機能(何をすべきか)**が仕様通りに達成できているかを検証する手法です。ユーザーから見た振る舞いをテストします。
  • ホワイトボックステスト
    アプリケーションが内部的にどのように動作するか(どのように)を検証する手法です。ソースコードからテストケースが作成されます。

単体テストで検証するものは「振る舞い」であり、特定のコード実装とは結合していない状態が望ましいです。そのため、基本的にはブラックボックステストを選択すべきです。

ただし、テストの網羅性を分析する際には、ホワイトボックステストの考え方が役立ちます。
例えば、カバレッジ計測ツールを用いて、コードのどこがまだ検証できていないかを探し、その後、ブラックボックステストに置き換えるといった活用方法があります。

モックの利用とテストの壊れやすさ(偽陽性)

テストダブルは、テスト対象が依存するオブジェクトの代わりとして使用される汎用的な用語です。その中でも、特に重要なのが「モック」と「スタブ」です。

図:テストダブルの種類

モックとスタブの違いを理解するには、操作を「Command」と「Query」に分けて考えると分かりやすいです。

  • Command(コマンド)
    副作用を伴う操作で、通常は値を返しません(例:データ更新、メール送信)。Commandに対応する操作を模擬するのがモックです。モックは、特定のメソッドが呼び出されたか、何回呼び出されたかなど、外部に与える影響を確認するために使われます。
  • Query(クエリ)
    値を返しますが、副作用を伴わない操作です(例:ユーザーリストの参照)。Queryに対応する操作を模擬するのがスタブです。スタブは、テスト対象のシステムが必要とするダミーデータを提供するために使われます。

例:

# これは実際のデータ
let(:users) {create_list(:user, 5)} 
user = users.first

# これはスタブ(Query操作の模擬)
before do
  allow(SomeClass).to receive(:new).and_return(some_class)
  before {allow(some_class).to receive(:fetch).and_return(5)}
end

# これはモック(Command操作の模擬)
before { allow(MailService).to receive(:call) }
...
it { expect(MailService).to have_received(:call).once }

「観察可能な振る舞い」と「実装の詳細」を分離したコードを目指す

良い単体テストを作成するためには、プロダクトコードの設計段階から、「観察可能な振る舞い」と「実装の詳細」を明確に分離することが重要です。

  • 観察可能な振る舞い
    クライアント(呼び出し元)が目的を達成するために利用する公開された操作(メソッド)や、システムの状態を指します。
  • 実装の詳細
    システムの内部的な処理や、クライアントには公開されないプライベートなメソッドなどを指します。

テストコードが「実装の詳細」に依存していると、リファクタリング時にテストが壊れやすくなります(偽陽性の原因)。理想的には、テストコードは「観察可能な振る舞い」のみを検証すべきです。

これを実現するためには、クライアントが達成しようとしている目的に直接関連する部分だけをpublicなメソッドとして定義し、それ以外の内部処理はprivateやinternalキーワードを使って隠蔽します。これにより、テストコードはpublicなメソッドから得られる結果を検証するだけで、システムの振る舞いを効果的にテストできるようになります(カプセル化)。

図:公開APIと内部実装の分離

例えば、ユーザーの名前を変更する際に文字数制限を行う場合、名前を正規化する内部処理をprivateメソッドにして、publicなname=メソッドがその処理をカプセル化するように設計することで、テストは最終的なユーザー名の状態だけを確認すればよくなります。

# 悪い例:クライアントの目的外のAPIがpublic状態
class User < ApplicationRecord
  def name=; end # あえて定義してるだけ
  def name; end
  
  def normalize_name(name) # クライアントの目的外のAPIがpublic状態
    name.strip
    if name.size > 50
      name[0..50]
    end
  end
end

class UsersController < ApplicationController
  def rename_user(new_name)
    user = current_user # devise前提
    normalized_name = user.normalize_name(new_name) # ここで内部実装のnormalize_nameを呼んでいる
    user.name = normalized_name
    user.save
  end
end
# 良い例:50文字以内に調整するAPIをprivateにしてカプセル化
class User < ApplicationRecord
  def name=(name)
    self[:name] = normalize_name(name)
  end
  
  private
  
  def normalize_name(name)
    name = name.strip
    if name.size > 50
      name[0..50]
    else
      name
    end
  end
end

class UserController < ApplicationController
  def rename_user(new_name)
    user = current_user
    user.name = new_name # Userクラスのname=メソッドが内部で正規化を呼ぶ
    user.save
  end
end

単体テストを作りやすいアーキテクチャ

単体テストの質を高めるためには、アーキテクチャの設計も重要です。重要なのは、ドメイン、サービス(アプリケーション)、外部クライアントが適切に分離されていることです。

  • システム内コミュニケーション
    同じ層の内部で行われるやり取り(例:ApplicationRecord同士のやり取り)。これらは「実装の詳細」にあたります。
  • システム間コミュニケーション
    異なる層間で行われるやり取り。これらは「観察可能な振る舞い」にあたります。

システム内でリファクタリングを行ったとしても、システム間コミュニケーションの仕様(振る舞い)が変わらない限り、テストが壊れることはありません。

例:ヘキサゴナル・アーキテクチャ(別名:ポートとアダプタのアーキテクチャ)

主要な登場人物
ヘキサゴナル・アーキテクチャは、主に3つの要素で構成されます。

  1. 内部(ドメイン)
    アプリケーションの心臓部です。ここには、システムの最も重要なルールやビジネスロジックが記述されます。この部分は、フレームワークやデータベースの種類といった外部の技術的な詳細を一切知りません。

  2. ポート
    内部(ドメイン)と外部をつなぐ「窓口」の役割を果たすインターフェース(仕様)です。ポートには2種類あります。

  • インバウンドポート (Inbound Port)
    外部からの要求を内部に伝えるためのポートです。「アプリケーションを動かす側」の窓口。(例:「ユーザー情報を登録して」という要求)
  • アウトバウンドポート (Outbound Port)
    内部から外部の機能を利用するためのポートです。「アプリケーションに動かされる側」の窓口。(例:「このデータをデータベースに保存して」という依頼)
  1. アダプタ
    ポート(仕様)を具体的に実装する「変換器」です。外部の技術とドメインの間のやり取りを仲介します。

プライマリアダプタ (Primary Adapter) インバウンドポートを通じて、アプリケーションに指示を出すアダプタです。(例:Webコントローラ、コマンドライン)
セカンダリアダプタ (Secondary Adapter) アウトバウンドポートを通じて、アプリケーションからの依頼を実行するアダプタです。(例:データベース接続、外部API呼び出し)

なぜ「ヘキサゴナル(六角形)」? ⬢

六角形という形自体に特別な意味はありません。中心にある「アプリケーションの核(ビジネスロジック)」と、それを取り巻く「外部要素(UI、DB、APIなど)」を明確に区別するための視覚的なシンボルです。六角形の各辺が、外部と接続するための「ポート」を表していると考えてください。

モックの使いどころは、この「システム間コミュニケーション」です。
例えば、購入完了時のメール送信など、外部システムへの依存をモックすることで、単体テストの独立性と高速性を保つことができます。

# RSpecでの例(メール送信のモック)
RSpec.describe SomeControllerClass, type: :request do
  # ...
  before { allow(MailService).to receive(:call) } # MailServiceのcallメソッドが呼ばれることを許可

  it "メールが一度送信されること" do
    # テスト対象の処理を実行
    # ...
    expect(MailService).to have_received(:call).once # callメソッドが一度だけ呼ばれたことを確認
  end
end

逆にテストが壊れやすいモック

システム内コミュニケーションのやり取りをモックすると、テストは壊れやすくなります。アプリケーション内で起こることを細かくモックするということは、テストがどのクラスがどんな処理を行っているかという「実装の詳細」に強く結合している状態だからです。

例えば、顧客が商品を購入する際に、在庫クラスのhas_enough_inventoryremove_inventoryメソッドを直接モックすることは、テストを壊れやすくします。本来検証すべきは、「顧客が何をいくつ購入できたか」という結果であり、内部でどのメソッドが呼ばれたかではありません。

# テストが壊れやすいモックの例
class Customer < ApplicationRecord
  def purchase(product, num_of_pieces)
    # 在庫が十分にあるか確認
    store = Store.new()
    store.has_enough_inventory
    # 何か買うと、在庫をその分減らす
    store.remove_inventory
    # 成功したかどうかを返す
  end
end

class Store < ApplicationRecord
  def has_enough_inventory
    # 在庫が十分にあるか?を返す
  end
  def remove_inventory
    # 在庫を減らす
  end
end

RSpec.describe Customer do
  subject(:purchase_shampoo) {described_class.purchase(shampoo, 3)}
  
  describe 'purchase' do
    context '在庫が十分にある場合' do
      let(:store) { create(:store) }
      let(:shampoo) { create_list(:product, :shampoo, 5, store:)}
      
      # テストが壊れやすいモック: storeのhas_enough_inventoryをモックしている
      before do
        allow(store).to receive(:has_enough_inventory).and_return(true)
      end
      
      it '購入が成功する' do
        success = purchase_shampoo
        expect(success).to be_truthy
        # テストが壊れやすいモック: remove_inventoryが呼ばれることを確認している
        expect(store).to have_received(:remove_inventory).once 
      end
    end
  end
end

この例の場合、CustomerクラスとStoreクラスの間の内部的なやり取りをモックしているため、Storeクラスのhas_enough_inventoryremove_inventoryの実装が変わると、Customerのテストが壊れてしまう可能性があります。

単体テストの3つの手法

単体テストには、主に以下の3つの手法があります。

  • 出力値ベーステスト
    処理によって返される値を検証する手法
  • 状態ベーステスト
    処理が終わった後のテスト対象の状態を検証する手法
  • コミュニケーションベーステスト
    テスト対象と協力者オブジェクトの間で行われるコミュニケーション(メソッド呼び出しなど)をモックを使って検証する手法

4つの柱の観点から見ると、出力値ベーステストが最も費用対効果が高いと考えられますが、多くの場合、工夫が必要です。

退行に対する保護と迅速なフィードバックについては、3つの手法の選択だけでは大きな差は出ません。これらは、テストケースの実行量とテストの高速性によって決まります。

  • リファクタリングへの耐性
    プロダクトコード(実装の詳細)との結びつきが大きくなるほど、耐性がなくなります。
  • 保守のしやすさ
    コード量が多く、実行が難しいほど、保守しづらくなります。
出力値ベース・テスト 状態ベース・テスト コミュニケーション・ベース・テスト
リファクタリングへの耐性 普通 普通
保守のしやすさ 普通

出力値ベーステストは、基本的にプロダクトコードが関数型プログラミングの考え方で書かれていることを前提としています。関数型プログラミングでは、関数が副作用を持たず、入力に対して常に同じ出力を返す不変性が重視されます。

# これは関数型プログラミング(副作用なし)
def increment(x)
  x + 1
end

# これは違う(副作用あり)
x = 0
def increment
 x += 1 # 外部の状態(x)を変更している
 x
end

関数型アーキテクチャと不変核・可変殻

この考え方を取り入れたのが関数型アーキテクチャです。ここでは、ビジネスロジックを扱うコードと副作用を起こすコードを分離します。

  • 決定を下すコード(不変核:immutable core)
    副作用を含まない、純粋なビジネスロジックを扱います。
  • 決定に基づくアクションを実行するコード(可変殻:mutable shell)
    データベースの操作やメッセージの送信など、副作用を伴う処理を行います。

図:関数型アーキテクチャの不変核と可変殻

副作用を伴う可変殻を関数の最初、または最後に実行することで、ビジネスロジックと副作用を分離しやすくなります。

関数型アーキテクチャもヘキサゴナル・アーキテクチャも、目指しているのは「関心の分離」と「依存の流れの一方向化」です。

不変核 ↔ ドメイン層
可変殻 ↔ アプリケーション・サービス層

主な違いは、ドメイン層が副作用を許容する(ただしドメイン層内に限定)のに対し、不変核は一切副作用を許容せず、全て可変殻に置く点です。

満点のアーキテクチャはない

出力値ベーステストが優れているとはいえ、全てのケースに適用できるわけではありません。プロジェクトの初期段階では、不変核と可変殻を分離するための「つなぎ」のコード量が多くなり、パフォーマンスが低下する可能性もあります。

単体テストの価値を高めるリファクタリング

良い単体テストは、良いプロダクトコードの設計があってこそ作られます。特に「退行に対する保護(偽陰性を減らすこと)」の観点から、プロダクトコードを以下の4種類に分類し、それぞれに対して適切なテストとリファクタリングを検討します。

  • ドメイン・モデル / アルゴリズム
    コードが複雑な傾向にあり、ドメインにおいて重要です。協力者オブジェクトを含まないか、少ない状態が望ましいです。
  • 取るに足らないコード
    コンストラクタ(initialize)など、協力者オブジェクトを持たない単純なコードです。
  • コントローラ
    複雑なビジネスロジックは行わず、複数のコンポーネントの連携を調整する役割を持ちます。協力者オブジェクトが多い傾向にあります。
  • 過度に複雑なコード
    複雑でビジネス的に重要でありながら、協力者オブジェクトも多く持つ、いわゆる「fatコントローラ」のようなコードです。

単体テストに最も労力をかける価値が高いのは、【ドメイン・モデル / アルゴリズム】です。これらはドメインにおける重要なコードであるため、強力な退行保護を設けるべきです。
取るに足らないコードは、テスト対象から除外しても問題ありません。
コントローラのテストは、【統合テスト】として作成することが推奨されます。
過度に複雑なコードは、【ドメイン・モデル / アルゴリズム】と【コントローラ】に分離するリファクタリングを行い、その上で適切なテストを作成します。

質素なオブジェクト(Humble Object)

「質素なオブジェクト(Humble Object)」は、複雑なビジネスロジックを持つ部分(Model)と、それをUI(View)層に連携するためのクラス(Controller)を分離するアーキテクチャパターンです。

例えばRailsでは、Modelの中で複数のドメインが関わってくる場合、Serviceクラスを用いて単一責任の原則(SRP: Single Responsibility Principle)を実現するのが一般的です。これにより、各クラスの責務が明確になり、テストしやすい設計になります。

感想

いい単体テストを作ることは、プロダクトコードの設計に大きく関わっている。いろんなアーキテクチャやプロダクトコードのリファクタリング例を用いて説明されていたが、やりたいことは一貫して、如何に少ないテストコード量で肝心な部分を十分にテストでき、壊れにくくし、早く実行でき、運用しやすくするかということ。

この章ではテストのために、という視点で設計を見直しているが、それ以外にも良い効果がある。例えば、リファクタリングのしやすさ、コードの読みやすさ、などにも繋がる。

本には書いていなかったが、TDDを行うことで自然とプロダクトコードの設計も、この本で言っている理想に近づいていくのでは?と感じた。

補足

データベースへの依存はプロセス外依存なので、モデル(単体テストの対象)が扱うべき処理ではないと考えられます。
この記事で例に挙げているApplicationRecord(ActiveRevord)はデータベースとのやりとりを担っているのでプロセス外依存を含むことになります。
ただ、テストのフレームワークでケースごとにデータを準備&クリーンアップできるようになっているので、単体テストの観点では問題にならない(モックを用いなくても良い)と考えています。

Discussion