見出し画像

単体テストの考え方/使い方のメモ・単体テストについて学んだこと

単体テストとは

テスト・コードを含めたすべてのコードは負債
そのため、テスト・ケースがもたらす価値の基準を高く設定し、その基準を満たしたテスト・ケースのみをテスト・スイートに含めなくてはなりません。
価値のないテスト・ケースをいくつも用意するよりも、価値のあるテスト・ケースを必要な分だけ揃えるほうがプロジェクトの継続的な成長に効果があるのです。

単体テストでバグを発見すると、かなりお得!!

🐤 単体テストの性質3点

  • 1単位の振る舞い(a unit of behavior)を検証すること。

  • 実行時間が短いこと。

  • 他のテスト・ケースから隔離された状態で実行されること。

    • (1 つのテスト・ケースに関する修正が他のテスト・ケースに影響を与えてはいけない)

※ Googleでは80%がユニットテスト。
※ テストの保守性に重きを置き、テストが失敗するまではそのテストに考える必要がない程度の品質が必要。

※ network疎通があったり、I/Oがある場合は単体テストとは言えないことに注意。(非推奨)
※ Googleが推奨している単体テストのルール・定義 ↓
Small = Unit テスト(コンポーネントテスト) / Mediun = 統合テスト / Large = E2Eテスト

🐤 脆いテスト

以下のようなシナリオを想像してみよう。
Maryは単純な新機能を製品に追加したいと望んでおり、おそらくコードが20行もあればその機能を素早く実装できる。
しかしMaryが自分の変更をチェックインしに行って直面するのは、自動テストシステムから返された画面いっぱいのエラーである。
Maryは 1 日の残りを、それらの失敗の一つ一つを見ていくのに費やす。
各失敗事例で、Maryの変更は、実際のバグは全く持ち込んでいなかった。
だがコードの内部構造についてテストが設けた前提条件をいくつか破っており、テストの更新が必要となっていた。
そもそもテストが何をしようとしているのかMaryにとって理解が難しい場合が多く、テストの修正のためにMaryが追加したハックによって、将来それらのテストを理解することが輪をかけて難しくなってしまう。
ついには、すぐ片づく仕事だったはずが何時間、あるいは何日もの間、実のない仕事に忙殺される羽目になり、Maryの生産性は削がれ、士気が害されるのだ。

🐤テストコードはDRYではなく、DAMP

良いテストとは変化しないように設計されるものであり、そして実際、テスト対象システムが変化する際にはテストが破綻することが通常は望ましい。
したがってテストコードに関しては、本番環境向けコードの場合ほど DRY の恩恵はない。

テストが正しく動作していることを担保するためにテスト自体にテストが必要と感じられるほどテストが複雑になり始めたら、何かが間違っている。

🐤 単体テストの構造的解析

AAA パターン

  • Arrange(準備) ← 通常最も大きくなりがちな部分。

  • Act(実行) ← Actフェーズが1行を超える場合は注意。API設計が不十分な可能性があります。(例えば、購入メソッドと、購入後在庫減少メソッドが分離している場合などは、不変条件の侵害と呼ばれカプセル化すべきである。)

  • Assert(確認)

AAAパターンを用いることで、テスト・スイート(test suite)に含まれるすべてのテスト・ケースに対して簡潔で統一された構造を持たせられるようになります。
この構造に慣れてしまえば、どのようなテスト・ケースであっても読みやすさが向上し、そのテスト・ケースが何をしているのかを簡単に理解できるようになるため、非常に大きな利点となります。

 public class Calculator
  {
    public double Sum(double first, double second)
    {
      return first + second;
    }
}
public class CalculatorTests
{
  public void Sum_of_two_numbers()
  {
    // Arrange
    double first = 10;
    double second = 20;
    var calculator = new Calculator();
    
    // Act
    double result = calculator.Sum(first, second);
    
    // Assert
    Assert.Equal(30, result)
  }
}

Given-When-Thenパターン

  • Given (前提として) : AAAパターン Arrangeと相当

  • When(hogehogeした時): AAAパターン Actと相当

  • Then(その結果): AAAパターン Assertと相当

※ Rubyですみません...!

describe "商品をカートに入れ、購入画面に遷移する" do ## Given
  context "1つの商品を購入した時" do ## When
    it "商品が購入され、購入履歴に1つ商品が残る" do ## Then
      assert hoge
    end
  end
  
  context "2つの商品を購入した時" do
    it "商品が購入され、購入履歴に2つ商品が残る" do
      assert fuga
    end
  end
end

🐤 単体テストでの名前の付け方

単体テストにおいて、何を検証するのかを明確に説明する名前をテスト・メソッドに付け
ることは重要なことです。なぜなら、そのテスト・メソッドが何を検証し、テスト対象のコ
ードがどのように振る舞うべきなのかをテスト・メソッド名から把握できるようになるからです。

`{ テスト対象メソッド } _ { 事前条件 } _ { 想定する結果 }`

  • { テスト対象メソッド } に検証するメソッド名を記述する。

  • { 事前条件 } にどのような条件でそのメソッドをテストするのかを記述する。

  • {想定する結果} に、この条件のもとでそのメソッドを実行した際の想定する結果を記述する。

🐤 優れた単体テストとは?

  • テストすることが、開発サイクルの中に組み込まれている。

  • コードベースの特に重要な部分のみがテスト対象となっている。

    • プロダクションコードの全てに対して単体テストに関する意識を等しく向ける必要はない。

    • 重要なのは、ドメイン・モデル(プロダクトの核)かどうかである。

  • 最小限の保守コストで最大限の価値を生み出すようになっている。

    • 価値あるテストケースの認識

    • 価値あるテストケースの作成

🐤 Tips

‼️同じフェーズが複数あるテストは分割しよう!

Arrange -> Act -> Assert -> Act -> Assert -> Act -> Assert ......
という状況の時は、「単一の振る舞いに対するテスト」 ではなくなっている可能性があります。
これは、単体テストではなく、「統合テスト」に属するものになります。

‼️ if文の使用は控えよう!

単体テストなのか統合(integration)テストなのかにかかわらず、分岐のない 単純な流れにしなくてはなりません。
テスト・ケースの中に含まれる if 文もまた、1つのテスト・ケースの中であまりにも多くのことを検証している、ということを示唆するものとなります。
もし、テスト・ケースに分岐を持ち込んでしまうと、テスト・ケースは読みづらくなり、そして、理解しづらいものになってしまいます。その結果、テストに対して余分な保守コストがかかることになります。

‼️ Arrangeフェーズが長くなってしまう時は!

Object Mother / Test Data Builderパターンを活用してみましょう。

単体テストと、その価値

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

  • リグレッションに対する保護

  • リファクタリングへの耐性

  • 迅速なフィードバック

  • 保守のしやすさ

この 4 本の柱は良い単体テストの基盤を成すものであり、各柱の観点を用いることで単体テスト、統合(integration)テスト、E2E(End-to-End)テストなどのあらゆる自動化されたテストの分析を行えるようになります。

リグレッションに対する保護

悲しいことに、プログラミングにおいて、コードは資産ではなく、負債なのです。そのため、コードベースが大きくなると、より多くの潜在的なバグを抱えることになります。

リグレッションに対する保護がテストにどのくらい備わっているか確認するには、以下に目を向けます。

  • テスト時に実行されるプロダクションコードの量

  • コードの複雑さ

  • コードが扱っているドメインの重要性

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

リファクタリングの耐性が意味することは、テストが失敗することなく、どのくらいのプロダクションコードのリファクタリングを行えるのか、という意味です。

通常、プロダクションコードをリファクタリングした際に、テストが失敗することがあります。
その際に、テストコードに誤りがあることがあります。それを「偽陽性 false positive」といいます。
この偽陽性を生まれづらいテストコードのことを、リファクタリングへの耐性がある、と定義します。

偽陽性が頻出する場合、テストコードに対する信頼性が薄れ、リファクタリングをしにくい状況になります。

テストが生み出す多くの偽陽性はそのテスト・ケースが何を見て作られているのかに直接関係します。
つまり、テストコードとプロダクションコードとより密接に結びついてしまう場合です。
偽陽性を生み出す可能性を減らす唯一の方法は、テストコードをテスト対象の内部的なコードから切り離すことです。
つまり、検証する対象を観察可能な振る舞いとし、その結果を得るための細かい手順である実装の詳細には目を向けないようにします。

テストケースを作成する際に最も重要なことは、問題領域に関するStoryを伝えているのか、ということです。
例えば、あるクラスのhogeメソッドが1度呼ばれたかどうか、というテストがあるとしますが、
このクラスをリファクタリングした際に、hogeメソッドとfugaメソッドを統合した場合、テストは落ちますが、これはリファクタリングによるバグではありません。

リグレッションに対する保護と、リファクタリングへの耐性の関係

  • リグレッションに対する保護は偽陰性(第二種過誤)からプロダクション・コードを守るものである。

  • リファクタリングへの耐性は偽陽性(第一種過誤)の数を最小限に抑えるものである

インフルエンザの検査にて、陽性・陰性・偽陽性・偽陰性が存在し、
インフルエンザの検査の正確性を測る指標は、偽陽性・偽陰性の可能性の低さ。

良い単体テストに求められていることは、インフルエンザの検査の正確性と同様に、偽陽性・偽陰性を低く保つこと。

迅速なフィードバックと保守のしやすさ

  • テストケースを理解することがどのくらい難しいのか?

    • テスト・コードの品質はプロダクション・コードの品質と同じくらい重要であるため、テスト・ケースの作成に手を抜いてはならない。

  • テストを行うことがどのくらい難しいのか?

    • たとえば、テストを行うのにプロセス外依存を必要とするのであれば、その依存が機能するように様々なことをしなくてはならず(たとえば、データベース・サーバを再起動することやネットワークの問題を解決すること)、より多くの時間を費やすことになる。

※ 上記3本の柱は、同時に重要視することは難しいです。
※そのため、それぞれ3本の柱の折衷をすることになります。 ≒ CAP(Consistency, Availability, Partition-tolerance)定理
※ 柱間のトレード・オフは部分的、かつ、戦略的に行わなくてはならない

単体テスト3つの手法

🐤 出力値ベース・テスト

この出力値ベース・テストとは、テスト対象のコードに入力値を渡したあと、そこから返される結果を検証する、というものです

🐤 状態ベース・テスト

状態ベース・ テストでは、検証する処理の実行が終わったあとにテスト対象の状態を検証します。
ここで言う「状態」とは、テスト対象システムの状態、その協力者オブジェクトの状態、データベースやファイル・システムなどのプロセス外依存の状態のことを指します。

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

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

※ 最も偽陽性を発生させやすく、脆い / 保守がしにくい

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

🐤 リファクタリングが必要なコードの識別

4種のプロダクションコード

  • コードの複雑さ、もしくは、ドメインにおける重要性

    • 複雑さ=分岐の数 / 循環的複雑度

  • 協力者オブジェクトの数

    • 可変、もしくは、プロセス外の依存のこと。(外部APIとか

単体テストを行う価値がもっとも高いプロダクション・コードは
複雑なコード、もしくは、ドメインにおける重要性が高いコードです。
なぜなら、そのようなコードをテストすることで、リグレッションに対する保護が備わるようになるからです。

過度に複雑なコードはテストをすることが非常に難しいコードで、テストをしないでおくことがあまりにも危険なコードです。
このようなコードは、ドメインモデルとコントローラ二分割することがよくあります。

また、Humble Object(質素なオブジェクト)パターンとして、過度に複雑なコードから、ロジックを抽出し、テストを行うのを困難にする要素から剥離させることができます。(Wrapperクラスを作り、ロジックから依存関係を切り離す)

🐤 プライベートメソッドに対する単体テスト

単体テストを行えるようにすることだけを目的に、本来であればプライベートであったメソッドを公開することは、観察可能な振る舞いのみを検証する、という基本原則に反することになります。

もし、そのようなことをすると、テストが実装の詳細と結び付いてしまい、結果として、良い単体テストを構成する 4 本の柱の中でもっとも重要なリグレッションに対する保護を失うことになります。

プライベートメソッドがあまりにも複雑である場合、以下である可能性が高いです。

  • デッドコード

  • 抽象化の欠落

以下は抽象化の欠落をリファクタリングするケースです。(pythonで挑戦

class Order:
    def __init__(self, customer, products):
        self._customer = customer
        self._products = products

    def generate_description(self):
        return f"Customer name: {self._customer.name}, " \
               f"total number of products: {len(self._products)}, " \
               f"total price: {self._get_price()}"

    def _get_price(self): # ← private メソッド
        base_price = 0.0
        discounts = 0.0
        taxes = 0.0

        return base_price - discounts + taxes

↑ _get_priceメソッドに対してテストしたいが、privateメソッドとして扱いたいためテストは難しい。

class Order:
    def __init__(self, customer, products):
        self._customer = customer
        self._products = products

    def generate_description(self):
        calc = PriceCalculator()
        return f"Customer name: {self._customer.name}, " \
               f"total number of products: {len(self._products)}, " \
               f"total price: {calc.calculate(self._customer, self._products)}"


class PriceCalculator:
    def calculate(self, customer, products):
        base_price = 0.0 
        discounts = 0.0
        taxes = 0.0

        return base_price - discounts + taxes

これで、先ほどの隠れたビジネス・ロジックは、Orderクラスから独立して、PriceCalculator クラスとしてテストできるようになりました。

🐤 テストへのドメイン知識の漏洩

このアンチ・パターンは複雑なアルゴリズムを検証する際に持ち込まれることが多いです。

class Calculator:
    @staticmethod
    def add(value1, value2):
        return value1 + value2
@pytest.fixture
def calculator():
    return Calculator()

def test_adding_two_numbers(calculator):
    value1 = 1
    value2 = 3
    expected = value1 + value2 # ←ドメイン知識の漏洩
    actual = calculator.add(value1, value2)
    assert expected == actual

プロダクション・コードのアルゴリズム(「value1 + value2」)をテスト・コードにそのまま持ち込むことは問題であり、このことはアンチ・パターンとなります。(ブラック・ボックス・テストの観点でプロダクション・コードをテストする!

@pytest.fixture
def calculator():
    return Calculator()

@pytest.mark.parametrize("value1, value2, expected", [
    (1, 3, 4),
    (11, 33, 44),
    (100, 500, 600)
])
def test_adding_two_numbers(calculator, value1, value2, expected):
    actual = calculator.add(value1, value2)
    assert expected == actual

単体テストにおいて、期待値を直接書くことは実践すべきプラクティスです。
なぜなら、期待値を直接書き込むことは、プロダクション・コードを使わずに別の方法でその結果を算出することを意味するからです。

つまり、テスト対象のコードとは異なる方法で取得した期待値と実行結果を比較することが単体テストにおいて意味のある確認となるのです。

理想なのは、その期待値をドメイン・エキスパートと共に算出することです。

🐤 プロダクションコードへの汚染

プロダクション・コードへの汚染の例としてよくあるのがテストとして実行されている場合にだけ振る舞いを変える様々な種類の切り替えです

class Logger:
    def __init__(self, is_test_environment):
        self._is_test_environment = is_test_environment

    def log(self, text):
        if self._is_test_environment:
            return

class Controller:
    def some_method(self, logger):
        logger.log("SomeMethod is called")

この場合は、Logger インターフェイスを導入して、本番環境で使われる実装クラスとテスト環境で使われる実装クラスの 2 つの実装クラスを用意します。
そして、コントローラに対してログ出力オブジェクトの具象クラスではなくインターフェイスを受け取らせるようにリファクタリングします。

最高なのでぜひ買って読んでみて欲しいです・・・!

↑こちらも面白いので単体テストの章だけでも価値あります。

この記事が気に入ったらサポートをしてみませんか?