💬

単体テスト入門

2024/10/02に公開

webエンジニア2年目のkenshinといいます!普段はRubyを書いています!
未だに雰囲気で単体テストを書いていたので、基礎から勉強して内容を整理しました!

1. テストをする理由と目的

なぜテストするのか?

「コード書くのに精一杯なのに、テストまで書かなきゃいけないの?」と自分は最初思っていましたが、単体テストにはとても重要な目的があります。

それは、プロジェクトの持続可能な成長です。

プロジェクトが大きくなるにつれて、コードは複雑になっていきます。
最初はどんどん成長していたプロジェクトも、時間が経つにつれ同じ成長により多くの時間が必要になっていきます。

新しい機能を追加したり、バグを修正したりするたびに、思わぬところで別の問題が発生...なんて経験、ないでしょうか?

テストは、そんな予期せぬ問題(これを退行と呼びます)を早期に発見するための強力な武器となります。つまり、テストがあることで問題を検出するセーフティネットとなり、新しい機能の追加やリファクタリングを、自信を持って行えるようになります!**

良いテスト vs 悪いテスト

ここで注意したいのが、やみくもにテストを書けばいいわけではないということ。実は、悪いテストは書かないほうがマシです。

なぜでしょうか?悪いテストは誤った安心感を与えたり、メンテナンスコストが高くなったりして、かえって開発の足かせになることがあるからです。

そこで大切なのが、テストを「資産」ではなく「負債」として捉える考え方です。テストにもコストがかかりますが、以下はその例です。

  • コードのリファクタリングに伴い、テストコードをリファクタリング
  • コードを変更するたびにテストを実施
  • テストが間違って失敗した際にその対処をする
  • コードの振る舞いを理解するためにテストコードを読む

作成したテストの保守コストがかかりすぎると、価値がなくなるどころかマイナスになります。
そのコストに見合う価値があるテストだけを残すようにしましょう。

優れたテストスイートの特徴

では、良いテスト、もしくはテストスイート(テストの集まり)ってどんなものでしょうか?
3つの重要なポイントがあります。:

  1. 開発サイクルに自然に組み込まれている
    • テストを導入しても、使われないと意味がありません。
    • コードに変更を加える度に実施するのが理想です。
  2. コードの特に重要な部分だけをテストしている
    • ビジネスロジックを含む部分(ドメインモデル)のテストに特に労力をかけましょう
    • ドメインモデルを他のコードから隔離することで、単体テストがしやすくなります
  3. 最小限の保守コストで最大限の価値を生み出す
    • テストケースの価値を保守コストより高くすることが大事です。

これらを意識することで、徐々にテストの質を高められるはずです!
具体的な内容も後述します。

2. 単体テストの基本を押さえよう

単体テストとは?

さて、ここで単体テストの定義を確認しておきます。単体テストとは:

  1. 1単位の振る舞いを検証する
  2. 実行時間が短い
  3. 他のテストから隔離されている

これらの条件を満たすテストを単体テストと呼びます。

ここで重要なのは、「隔離」という概念です。この「隔離」の意味の捉え方に関して多くの議論がなされており、その結果として2つの主要な学派が生まれました:

ロンドン学派

  • 「単位」を1つのクラスと捉える
  • テスト対象のクラスを他のすべての協力オブジェクトから隔離する
  • ほぼすべての依存をテスト・ダブル(主にモック)で置き換える
  • 外部から内側へとテストを進める傾向がある

古典学派

  • 「単位」を1つの振る舞いと捉える
  • テストケース同士を隔離する
  • 共有依存(例:データベース)のみをテスト・ダブルで置き換える
  • 実際の協力オブジェクトをより多く使用する
  • 内側から外側へとテストを進める傾向がある

依存を理解する

テストを書く上で重要なのが、コードにおける依存の理解です。
依存の種類には主に以下があります:

  1. 共有依存:テストケース間で共有される依存
  2. プライベート依存:テストケース間で共有されない依存
  3. プロセス外依存:アプリケーションの外部で動作する依存
  4. 揮発性依存:呼び出すたびに異なる振る舞いをする依存

これらの依存関係をどう扱うかで、テストの書き方が変わってきます。

  1. 共有依存:テストケース間で共有される依存

共有依存は、複数のテストケース間で共有されるリソースです。
例:

  • データベース
  • 静的変数やクラス変数
  • グローバルオブジェクト
  • アプリケーションの設定ファイル
class User
  def self.count
    DB.query("SELECT COUNT(*) FROM users").first['count']
  end
end

describe User do
  before { DB.query("DELETE FROM users") }
  
  it "ユーザ数を正常にカウント" do
    DB.query("INSERT INTO users (name) VALUES ('Alice')")
    expect(User.count).to eq(1)
  end
  
  it "ユーザを追加した後に正常にカウント" do
    DB.query("INSERT INTO users (name) VALUES ('Bob'), ('Charlie')")
    expect(User.count).to eq(2)
  end
end

この例では、データベースが共有依存です。テストの独立性を保つため、各テストの前にデータベースをクリーンアップしています。

  1. プライベート依存:テストケース間で共有されない依存

プライベート依存は、テストケースごとに新しく作成される依存です。
例:

  • テストケース内でインスタンス化されたオブジェクト
  • ローカル変数
class Cart
  attr_reader :items
  def initialize
    @items = []
  end
  def add_item(item)
    @items << item
  end
end

class Order
  def initialize(cart)
    @cart = cart
  end
  def total
    @cart.items.sum
  end
end

describe Order do

  it "合計を計算する" do
    cart = Cart.new
    cart.add_item(100)
    cart.add_item(200)
    expect(Order.new(cart).total).to eq(300)
  end

  it "割引ありで計算する" do
    cart = Cart.new
    cart.add_item(100)
    cart.add_item(200)
    order = Order.new(cart)
    order.apply_discount(10)
    expect(order.total).to eq(270)
  end
end

この例では、各テストで新しいCartオブジェクトを作成するため、テスト間の干渉がありません。

  1. プロセス外依存:アプリケーションの外部で動作する依存

プロセス外依存は、アプリケーションの外部にあるシステムやサービスです。
例:

  • データベース
  • Web API(外部API)
  • ファイルシステム
  • メールサーバー(SMTP サーバーなど)
class WeatherService
  def self.get_temperature(city)
    # 実際のAPIコールはここで行われる
    # この例ではシンプルさのために省略
  end
end

describe WeatherService do
  it "気温を取得する" do
    allow(WeatherService).to receive(:get_temperature).with("Tokyo").and_return(25)
    expect(WeatherService.get_temperature("Tokyo")).to eq(25)
  end
end

この例では、外部APIがプロセス外依存です。テストではメソッドをスタブ化して、実際のAPIコールを避けています。

  1. 揮発性依存:呼び出すたびに異なる振る舞いをする依存

揮発性依存は、同じ入力に対して異なる結果を返す可能性のある依存です。
例:

  • システムの現在時刻を返す関数
  • 乱数生成器
  • ネットワークの状態を確認する関数
  • センサーからのリアルタイムデータ読み取り(IoTアプリケーションなど)
  • ファイルシステムの空き容量を確認する関数
  • 動的に変化するシステムリソース(CPU使用率、メモリ使用量など)を取得する関数
class ExpirationChecker
  def self.is_expired?(expiration_date)
    Time.now > expiration_date
  end
end

describe ExpirationChecker do
  it "期限切れを検出できるか" do
    allow(Time).to receive(:now).and_return(Time.new(2023, 5, 1))
    expect(ExpirationChecker.is_expired?(Time.new(2023, 4, 30))).to be true
    expect(ExpirationChecker.is_expired?(Time.new(2023, 5, 2))).to be false
  end
end

この例では、Time.nowが揮発性依存です。テストでは固定の時間を返すようにスタブ化しています。

統合テスト、E2Eテストとの違い

単体テスト以外にも、統合テストやE2E(エンドツーエンド)テストがあります。

  • 統合テスト(インテグレーションテスト):複数のコンポーネントを組み合わせてテスト
  • E2Eテスト:ユーザーの視点からシステム全体をテスト

それぞれ目的が異なるので、適切に使い分けることが大切です。

テストピラミッド
テストピラミッドは、異なる種類のテストのバランスを表す概念です。

単体テストを基礎として多く、上に行くほどテスト数を減らしていくイメージです。これにより、テストの実行速度と信頼性のバランスを取ることができます。
(以下引用図)

https://zenn.dev/jyoppomu/articles/52844385940140

AAAパターンでテストを構造化

テストを書くときは、AAAパターンを意識すると良いでしょう。

  1. Arrange(準備):テストに必要な事前条件を設定
  2. Act(実行):テスト対象の処理を実行
  3. Assert(検証):結果が期待通りかを確認

このパターンを使うと、テストの構造が明確になり、読みやすくなります。

# テスト対象のクラス
class Calculator
  def add(a, b)
    a + b
  end
end

# テストコード
describe Calculator do
  describe '足し算のテスト' do
    it '2つの数を足す' do
      # Arrange
      calculator = Calculator.new
      a = 2
      b = 3

      # Act
      result = calculator.add(a, b)

      # Assert
      expect(result).to eq(5)
    end
  end
end

3. 質の高い単体テストを書くコツ

単体テストの4つの柱

質の高い単体テストは、以下の4つの特徴で分析できます:

  1. 退行に対する保護:バグをどれだけ検出できるか
  2. リファクタリングへの耐性:いかに偽陽性を出さずにコードをリファクタリングできるか(リファクタリングしてもテストが壊れないか)
  3. 迅速なフィードバック:テストの実行がどれだけ速いか
  4. 保守のしやすさ:いかにテスト自体が理解しやすく、メンテナンスが容易か

偽陽性:嘘の警告。コードは問題ないのにテストが失敗すること。これが多くなることで、テストの信頼性が下がり意味がなくなってしまいます。テストコードと実装の詳細の結びつきが強すぎることで発生する。

また、テストケースの価値は4本の柱の掛け算で決まります。
以下の式は、それぞれの度合いが0~1としたものです。

テストケースの価値 = [0..1]*[0..1]*[0..1]*[0..1]

それぞれの柱のバランスが大事

まず、全ての要素が1(最大)のテストケースをつくることはできません。
なぜなら、4つの柱のうち「退行に対する保護」「リファクタへの耐性」「迅速なフィードバック」の3つは互いに排反で、例えばどれか2つを最大化(1)にした場合、残りのひとつが犠牲になってしまうからです。

また、テストケースの価値は4本の柱の掛け算で決まるため、どれかひとつでも欠けているとそのテストケースの価値は0になってしまいます。

テストケースの価値 = [0..1]*[0..1]*[0..1]*[0..1]

よってどの柱も欠けさせずに、全ての柱をできる限り備えるようにする必要があるのです。
以下では、3本のうち1本を犠牲にしたらどのような問題が起きるのか、いくつか例を見てみましょう。

極端な例1)E2Eテスト

E2Eテストは、UIやデータベースなど、システムを構成する要素を全て経由する検証を行うテストです。特性上多くのプロダクションコードが実行されるので、退行に対する保護を十分に備えていると言えます(バグを検出しやすい)。また、E2Eテストは機能の振る舞いのみに注目するテストなので、偽陽性が持ち込まれにくく、リファクタへの耐性がもっとも備わっていると言えます。
しかし、テストの実行にとても時間がかかってしまいます。つまり、迅速なフィードバックは全く得られないという欠点があります。

極端な例2)取るに足らないテスト

取るに足らないテストとは、以下のように、間違えることがほぼなさそうな些細なコードのテストのことです。

class Person
  attr_accessor :name

  def initialize(name)
    @name = name
  end
end
describe Person do
  it "nameの取得" do
    person = Person.new("Alice")
    expect(person.name).to eq("Alice")
  end
end

実行時間ははやく、偽陽性が持ち込まれることもほぼないので、リファクタへの耐性もあると言えます。
しかしこのテストは、プロダクションコードを別の書き方で表現しているだけで、常に成功する意味のないことを検証しているにすぎません。よって、退行に対する保護は全く備わっていないと言えます。

極端な例3)壊れやすいテスト

実行時間が短く、バグを検出しやすくても、偽陽性を多く持ち込んでしまうテストも簡単につくれます。そのようなテストを壊れやすいテストと呼びます。
壊れやすいテストは、実装の詳細に過度に依存しているテストのことです。以下に、UserRepositoryクラスのメソッドが発行するSQLを検証する例を示します。

class UserRepository
  def find_active_users
    query = "SELECT * FROM users WHERE status = 'active' ORDER BY created_at DESC"
    User.find_by_sql(query)
  end
end

describe UserRepository do
  it "アクティブユーザを返す正しいSQLを生成しているか" do
    repository = UserRepository.new
    expected_sql = "SELECT * FROM users WHERE status = 'active' ORDER BY created_at DESC"
    
    # SQLクエリを直接検証
    expect(User).to receive(:find_by_sql).with(expected_sql)
    
    repository.find_active_users
  end
end

このテストは、find_active_usersメソッドが生成するSQLクエリが期待通りであることを検証しています。確かにこのテストがバグを検出することもあるでしょう。しかし、とてもリファクタリングしにくいという欠点があります。
例えば、該当のSQLは以下のどれでも代用でき、同じ振る舞いをします。

  • SELECT id, name, email, status, created_at FROM users WHERE status = 'active' ORDER BY created_at DESC
  • SELECT * FROM (SELECT * FROM users WHERE status = 'active') AS active_users ORDER BY created_at DESC
  • SELECT * FROM users WHERE CASE WHEN status = 'active' THEN 1 ELSE 0 END = 1 ORDER BY created_at DESC

しかし、どれかに変えてしまうとテストが失敗してしまいます。それは、テストコードが機能の振る舞いではなく、実装の詳細と深く結びついてしまったからです。
このようなテストは、コードの変更に対して非常に脆弱です。実装の詳細が少し変わっただけでテストが失敗してしまい、リファクタリングの大きな障害となります。

テストケースの価値を高めるための方針

テストケースの価値を高めるには、前述したようにどの柱の要素も欠かさずに備える必要があります。また、3本の排反な柱の要素をバランスよく取る必要もあります。

ということは、3本の柱をすべてある程度取るようにするのが最善の戦略のように思えます。しかし3本の柱のひとつである「リファクタへの耐性」に関して、実は備えるか否かのどちらかしか選択できず、その中間にするようなことはできな性質を持っています。つまり、リファクタへの耐性を少しだけ持たせようとした場合、全く備わらないことになってしまうのです。

よって、リファクタリングへの耐性は十分に備えるという選択しかできないため、他の2つの柱(退行に対する保護、迅速なフィードバック)でバランスをとることが重要になります。

4. まとめ

今回は、単体テストやテスト自体についての基本的な考え方をまとめました。
次の記事では、設計に関連付けたり、結合テストの方もまとめていきたいです!

Discussion