😎

優れた単体テストを書くために意識していること

2023/06/18に公開

概要

この記事では、「優れた単体テストを書くために意識すべきこと」について、一部Rubyのコードをサンプルにしながらテストコードを中心にご紹介をさせて頂きます。

なぜ本記事を書こうと思ったか

既存システムの刷新プロジェクトに携わった中で、単体テストの重要さ、及びコード設計の大切さを改めて感じた事から、本記事を書くことにしました。優れた単体テストが書ける事によって、退行(regression)によるバグが予防出来る事は勿論の事、既存システムの開発コストを抑える事や、実装担当者の心理的負荷を減らす事にも繋がります。この記事の内容が少しでも読者のお役に立つ内容となっていましたら幸いです。

本題

まず優れた単体テストの定義について

ここでは優れた単体テストについて定義をさせて頂きたいと思います。そもそも優れた単体テストとはどのような単体テストの事を指すのでしょうか?
完璧な正解を導き出す事は難しいと思いますが、ここでは以下の「単体テストの考え方/使い方」という書籍の内容を参考に、次の特徴を持っている単体テストの事を優れた単体テストであると定義させて頂きたいと思います。

https://www.amazon.co.jp/単体テストの考え方-使い方-Vladimir-Khorikov/dp/4839981728

優れた単体テストの定義

  • テストすることが開発サイクルの中に組み込まれている
    • 単体テストに価値が付くのは、その単体テストが実際に使用される場合に限るためです。
  • 重要な部分のみがテスト対象となっている
    • プロダクトの核となる部分(ドメイン・モデル)とそうでない部分をきちんと区別し、取るに足らないテストコードを排除し、重要な部分のみをテスト対象とした方が、余計な開発コストが発生しないためです。
  • 最小限の保守コストで最大限の価値を生み出すようになっている
    • 簡単にテストが書けるようにコードの設計を見直したり、プロダクション・コードのリファクタリングを行った際に、テストコードが原因でテストが失敗してしまう現象である偽陽性(false positive)を抑えることも、優れた単体テストを作成する上で重要な要素となるためです。

優れた単体テストを構成するための4つの柱について

上記のような条件を満たす優れた単体テストを構成するためには、以下の4つの柱を意識してテストコードを書くのが良いでしょう。

1. 退行(regression)に対する保護

ソフトウェアで発生する退行とは、バグのことであり、何らかの変更を加えた後に、既存の機能が意図したように動かなくなってしまうことを指します。こちらは言うまでもなく重要なポイントとなると思います。
退行が発生してしまう原因は様々なケースがあると思いますが、プロダクションコードが複雑であることやドメイン知識の理解不足などが主な原因となどが多いのではないでしょうか。

2. リファクタリングへの耐性

リファクタリングへの耐性は、テストが失敗することなく、どのくらい柔軟にプロダクション・コードの変更が行えるかを指します。リファクタリングの体制が整っていないと、プロダクション・コードは正しく動作するのに、テストが落ちてしまうという偽陽性(false positive)状態が発生します。偽陽性が大量に発生してしまうと、開発者は次第にテストのエラーを無視するようになってしまったり、リファクタリングを行う能力や意思を開発者から奪ってしまいまうことに繋がるので重要なポイントとなります。

3. 迅速なフィードバック

迅速なフィードバックは、テストの実行時間がどのくらい短くなるのかに影響する性質のことを指します。
テストが遅くなると、テストからフィードバックを得るための時間が長くなります。そうなるとバグを修正するために必要なコストが大きくなってしまったり、テストを実施する回数を減らすなど、開発が間違った方向に進んでしまいことにも繋がるため重要なポイントとなります。

4. 保守のしやすさ

保守のしやすさは、次の2つのことから評価できます

  • テストコードを理解することがどのくらい難しいか
  • テストを実行すること自体がどのくらい難しいか

保守性が低いと、退行(regression)が発生しやすくなってしまったり、時間経過に連れて、開発コストも膨れ上がる事になるので重要なポイントとなります。

では、上記の4つの柱を守る為には、具体的にどのような事を意識したら良いのでしょうか?
以下よりご紹介をさせて頂きたいと思います。

優れた単体テストを構成するための4つの柱についての対策

退行(regression)に対する保護を高めるために意識すべきこと

退行を防止するためには、テストの際に出来るだけ多くのプロダクションコードを実行させることが大切です。
上記のように解説すると、何でもかんでもテストケースを追加すべきなのでは?と思ってしまいそうですが、優れたテストコードを書くためには、重要な部分のみがテスト対象となっている事と、最小限の保守コストで最大限の価値を生み出すことが重要なポイントとなってきます。
その為、後述する「リファクタリングへの耐性を高めるために意識すべきこと」、「迅速なフィードバックを得るために意識すべきこと」などとのバランスを保ちながら、テストコードを書いていくと良いと思います。

リファクタリングへの耐性を高めるために意識すべきこと

まずリファクタリングの耐性を高める事が意味することとしては、「テストが失敗することなく、どのくらいプロダクションコードのリファクタリングを行えるか?」 という事になります。
その上でリファクタリングの耐性を高める為には、テストコードを書く際には、実装の詳細ではなく、最終的な結果に着目してテストコードを書くことが重要です。
少しイメージが湧きにくい部分があるかと思いますが、ちょうど以下の記事の中に分かりやすい例(サンプルコード)があったので、今回はそちらを引用させて頂いた上で ご紹介をさせて頂きたいと思います。
https://techracho.bpsinc.jp/hachi8833/2018_02_19/52597

■ サンプルコード

# Badなspec
# ==========

describe CloseOrder do
  describe '#call' do
    it do
      order = create(:order)
      service = CloseOrder.new(order)
      allow(service).to receive(:send_notification)

      service.call

      expect(service).to have_received(:send_notification)
    end
  end

  describe '#send_notification' do
    it '注文のクローズを顧客に通知する' do
      order = create(:order, customer_email: 'tony@stark.com')  
      service = CloseOrder.new(order)

      expect { 
        service.send(:send_notification) # We are forced to use #send to test private method
      }.to change { ActionMailer::Base.deliveries.count }.by(1)

      notification = ActionMailer::Base.deliveries.last
      expect(notification).to have_attributes(subject: 'Order closed!', recipients: ['tony@stark.com'])
    end
  end
end

# goodなspec
# ==========

describe CloseOrder do
  describe '#call' do
    it '注文のクローズを顧客に通知する' do
      order = create(:order, customer_email: 'tony@stark.com')  
      service = CloseOrder.new(order)

      expect { 
        service.call 
      }.to change { ActionMailer::Base.deliveries.count }.by(1)

      notification = ActionMailer::Base.deliveries.last
      expect(notification).to have_attributes(subject: 'Order closed!', recipients: ['tony@stark.com'])
    end
  end
end

# 実装側
# ==============

class CloseOrder
  def initialize(order)
    @order = order
  end

  def call
    send_notification
  end

  private

  def send_notification
    OrderMailer.order_closed_notification(@order).deliver_now
  end  
end

上記の「Badなspec」のコードは、テスト対象に実装の詳細部分であるsend_notificationメソッドが含まれてしまっている事で、リファクタリングへの耐性が低い状態となってしまっています。
例えば、callメソッドの方に別の新しいメソッドが追加された場合は、既存コードを真似て、責務の重複するprivateメソッドのテストが増えていってしまう事に繋がりそうです。
また、極端な話ですがcallメソッド側ではsend_notificationが呼ばれている事をテストしてしまっている事で、メソッドの命名を変更した場合や、使用するメソッドを同じ振る舞いを持つ別のメソッドに切り替えた場合などにも、不要にテストが落ちてしまう事にも繋がるでしょう。
上記の例に限らず、テストコードを書く際には、テストコードが壊れやすくなっていないか?という部分は、ぜひ注意した実装を心がけたいですね。

迅速なフィードバックを得るために意識すべきこと

迅速なフィードバックを得るために意識すべきことは、テストピラミッドという言葉もあるように、基本的にはテストの種類が以下のようなピラミッド型で構成されている事が良いと考えています。

E2E(UI)テスト・統合テスト・単体テストの違いなどについては、以下の記事に分かりやすく紹介されていると感じた為、必要に応じて参考にしてみてください。
https://techblog.xtone.co.jp/entry/2023/03/24/151555

また、テストピラミッドの構成を最適化した上で、取るに足らないテストケースを取り除いたり、以下記事で紹介されているようRspecであれば、TestProfなどのライブラリを有効活用し、にテストの実行時間を削減することも合わせて行うと良いでしょう。
https://zenn.dev/aldagram_tech/articles/5a566cb6629b00

保守のしやすさを維持するために意識すべきこと

テストコードの保守性は、 「テストコードを理解することがどのくらい難しいか」 と 「テストを実行すること自体がどのくらい難しいか」 で評価が出来ると思います。
「テストを実行すること自体がどのくらい難しいか」については、RSpecなどのテスト用のライブラリを用いれば、通常そこまで意識が必要となるケースは、個人的には少ないように感じますので、今回は「テストコードを理解することがどのくらい難しいか」に絞ってご紹介をさせて頂きます。

テストコードの理解のしやすさを維持するためには、当然ながら疎結合なクラス設計やメソッド定義を維持することがとても大切です。
テスト対象となるプロダクションコードが肥大化してしまっていたり、条件分岐の数やネストが深い場合、テストケースとして考慮漏れが発生しやすくなってしまったり、テストコードを作成・保守する為の工数自体も膨れ上がってしまいます。
その為、普段から、「このコードってテストコードが書きにくくなってきていないか?」 という事を常に自問しながら日々コーディングを行いましょう。

その為の考え方の指標として、SOLID原則やクリーン・アーキテクチャの知識が役立つのではないでしょうか。
SOLID原則やクリーン・アーキテクチャのイメージが浮かばないという場合は、以下の記事などがとても参考になると感じたため、良かったら参考にしてみてください。
https://qiita.com/baby-degu/items/d058a62f145235a0f007
https://qiita.com/nrslib/items/a5f902c4defc83bd46b8

感想

今回は「単体テストの考え方/使い方」の書籍を読み、その他の記事なども色々と参考にさせて頂きながら、本記事を執筆させて頂きましたが、良いテストコードを書くためには、保守性の高いクラス設計やコーディングがとても大切だと改めて感じました。また、退行(regression)を防ぐ為に、何でもかんでもテストを追加すれば良いという訳ではなく、最小限の保守コストで最大限の価値を生み出すために、リファクタリング面、迅速なフィードバック面、保守コストなどのバランスを保ちながら、テストコードを書いていきたいですね。

Discussion