0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【単体テストの考え方/使い方】3. 単体テストの構造的解析

Last updated at Posted at 2025-10-06

AAAパターンとは

AAAパターンは、「準備(Arrange)」「実行(Act)」「確認(Assert)」の3つの要素で構成される単体テストの構造のこと

AAAパターンを用いることで、テストスイートに用いられる全てのテストケースに対して簡潔で統一された構造を持たせられるようになる
→テストケースの可読性が向上し、テストスイート全体の保守コストを下げることに繋がる

テスト駆動開発(TDD)では、「確認(Assert)」から書き始める場合がある

TDDでは、機能を開発する前に失敗するテストケースを作成するようになっているので、その機能の振る舞いをまだ十分に把握していないことがある

→想定する振る舞いをテストケースに書き出しておき、その想定に見合ったシステムにするにはどう開発するのかを考えていく

※問題を解決しようとするときに、最初に目的から考えるという手順と同じ

準備(Arrange)

テストケースの事前条件を満たすようにテスト対象システムとその依存状態を設定する

実行(Act)

テスト対象のメソッドを呼び出し、テスト対象の振る舞いを実行させる(準備しておいた依存も呼び出される)
テスト対象メソッドから実行結果が返ってくるのであれば、その実行結果を保持しておく

確認(Assert)

実行結果(戻り値や協力者オブジェクトの状態、呼び出しなど)が想定した結果であることを確認する

コード例

プロダクションコード
calculatorクラスの中に、2つの数値を足すメソッドが定義されている

class Calculator():
    def sum(first: float, second: float):
        return first + second

単体テストコード
AAAパターンを使ったcalculatorクラスのsumメソッドを検証するテストケース

各フェーズを区別できるように書くと読みやすい

// 準備(Arrange) // 実行(Act) // 確認(Assert)などのコメントをつける
各フェーズを空白行で区切る など

class TestCalculator():
    def test_sum_of_two_numbers():
        # 準備(Arrange)
        first = 10
        second = 20
        caluclator = new Calculator()

        # 実行(Act)
        result = caluclator.sum(first,second)

        # 確認(Assert)
        assert result == 10

Given-When-Thenパターンとは

Given-When-Thenパターンは、テストケースのシナリオを「Given(前提として)」「When(〇〇したときに)」「Then(その結果)」の3つに分解して構築する単体テストの構造のこと

AAAパターンとGiven-When-Thenパターンの違い

テストケースの構造という観点から見ると、AAAパターンと同じ
唯一の違いは、Given-When-Thenパターンの方がAAAパターンよりも、英語を母国語とする非開発者にとって読みやすいことだけ

単体テストのアンチパターン構造

  • 同じフェーズを複数用意すること(以下のような状態のこと)
    1つのテストケースの中で、複数の振る舞いを検証しようとしていることになるので、単体テストの定義(1つの振る舞いのみを検証すること)から外れている
    →テストケース内に各フェーズが1つずつになるよう、テストケースを分割するべき
class TestXxxx():
    def xxxx():
        # 準備(Arrange)
        xxxx

        # 実行(Act)
        xxxx

        # 確認(Assert)
        xxxx

        # 実行(Act)
        xxxx

        # 確認(Assert)
        xxxx
  • if文を使用すること
    テストケースは分岐のない単純な流れにしなくてはならない
    if文が含まれる=1つのテストケースの中であまりにも多くのことを検証している
    →複数のテストケースに分割するべき

各フェーズのサイズはどのぐらいが適切なのか?

準備フェーズ(Arrange)

準備フェーズは各フェーズの中で最もサイズが大きくなる
最大サイズの目安は、実行フェーズと確認フェーズを合わせたサイズ

準備フェーズのサイズが大きくなりすぎる場合の対処法

実行フェーズ(Act)

実行フェーズのサイズは1行のコードであるべき
実行フェーズが複数行になる場合は、テスト対象システムで公開されているAPIがきちんと設計されていない可能性がある

※実行フェーズを常に1行のみにするというのは、ビジネスロジックのコードでは適用できるが、ユーティリティやインフラのコードでは適切でない場合もある

コード例

テストケース
顧客が商品を購入する際に、在庫があれば取引成立で、在庫が購入数分減る

ベストプラクティス(実行フェーズが1行の場合)

実行フェーズで1つのメソッドだけしか呼び出していない

# 在庫が十分にある場合、購入は成功する
def test_purchase_succeeds_when_enough_inventory():
    # 準備(Arrange)
    store = new Store
    store.AddInventory(Product.Shampoo,10)
    customer = new Customer()
    
    # 実行(Act)
    is_success = customer.Purchase(store,Product.Shampoo,5)

    # 確認(Assert)
    assert success == true
    assert store.GetInventory(product.Shampoo) == 5

アンチパターン(実行フェーズに複数行の場合)

実行フェーズが2行になっている

# 在庫が十分にある場合、購入は成功する
def test_purchase_succeeds_when_enough_inventory():
    # 準備(Arrange)
    store = new Store
    store.AddInventory(Product.Shampoo,10)
    customer = new Customer()
    
    # 実行(Act)
    is_success = customer.Purchase(store,Product.Shampoo,5)
    store.RemoveInventory(is_success,Product.Shampoo,5)

    # 確認(Assert)
    assert success == true
    assert store.GetInventory(product.Shampoo) == 5

このテストコードの問題点
1つのオペレーション(商品の購入)に対して2つのメソッドの呼び出しを必要としていること
(2つ目のメソッドの呼び出しが忘れられる危険性がある)
→2つ目のメソッドが呼び出されないとデータの整合性が損なわれる = 不変条件の侵害

どのように解決するのか?
1つの振る舞いを1つのテストケースで検証できるようにカプセル化する

※テスト自体の問題ではなく、プロダクションコードの設計に問題がある
(テストケースは1つの振る舞いを検証してるので正しいテストケースと言える)

「購入を正しく処理するためには、2つ目のメソッドを呼び出すことを知っていなくてはならない」というプロダクションコードの設計に問題がある(カプセル化できていない)
→在庫数の調整をcustomer.Purchaseメソッドの中でするようにプロダクションコードを修正する

確認フェーズ(Assert)

確認フェーズは、1単位の振る舞いを確認していれば複数行になっても問題はない
(1単位の振る舞いによって複数の結果が生じることがあるため)

ただし、確認フェーズが大きくなりすぎるのはよくない
→プロダクションコードで抽象化できていない可能性が高い

例)テスト対象システムが戻り値として返すオブジェクトの全フィールド値を確認している場合
→そのオブジェクトが同等である確認できる手段を用意できるようにする
(実際に返ってきたオブジェクトと期待値のオブジェクトを比較するだけで済むようにする)

テストの後始末フェーズを設けるべきか?
※テスト後始末とは、テスト時に作成したファイルの削除やデータベースの接続切断などを指す
単体テストではプロセス外依存とのやり取りは行わないため、破棄が必要なものは残らない→単体テストは後始末処理を必要としない

読みやすい単体テストの書き方

変数名

テスト対象システムとその依存とを区別できるようにする
→テスト対象システムを表現するものに対して、常に「sut」と名付けるようにする

class TestCalculator():
    def sum_of_two_numbers():
        # 準備(Arrange)
        first = 10
        second = 20
        sut = new Calculator()

        # 実行(Act)
        result = sut.sum(first,second)
        
        # 確認(Assert)
        assert result == 30

テストメソッド名

テストメソッド名から、何を検証し、どのように振る舞うべきなのか把握できるようにすることが大切である
→そのためには、実装の詳細よりも、システムの振る舞いがわかる簡潔な名前にする

ベストプラクティス

  • 厳格な命名規則に縛られないようにする
  • 開発領域のことに精通している非開発者に対してどのような検証をするのかが伝わるような名前をつける
  • (英語の場合は)アンダースコア _ を使って単語を区切るようにする

アンチパターン

  • テスト対象のメソッド名をテストメソッド名に含める
    単体テストはコードをテストしているのではなく、アプリケーションの振る舞いをテストしているため
    テスト対象メソッドを変えると、振る舞いは変わっていなくてもテストコードを変更しなくてはならないため
    ※ユーティリティ系のコードは例外(ビジネスロジックが含まれていないため)
  • テストメソッド名に「should be」をつける
    単体テストは、1単位の振る舞いについて1つの不可分な事実(シナリオ)を伝えるものなので希望や要望を含めるべきではない

コード例

配達サービスに対して過去の日付は指定できないことを検証する

# 1. 厳格な命名規則({テスト対象メソッド}_{事前条件}_{想定する結果})に従った場合
# テストメソッド名:isDeliveryValid_不正な日付_falseを返す
def test_isDeliveryValid_invalidDate_returnFalse():
    sut = new DeliveryService()
    pastDate = datetime.now() - timedelta(days=1)
    delivery = new Delivery(pastDate)

    isValid = sut.isDeliveryValid(delivery)

    assert isValid = false

# 2. 非開発者にも伝わる名前にする
# テストメソッド名:過去の日付が指定された配達は不正だと見做されるべきである
def test_delivery_with_past_date_should_be_considered_invalid():
    # --- テストコード(省略) ---

# 3. 冗長な部分を改善する
# テストメソッド名:過去の日付が指定された配達は不正である
def test_delivery_with_past_date_is_invalid():
    # --- テストコード(省略) ---

# 4. 文法的に正しくする
# テストメソッド名:過去の日付が指定された配達は不正である
def test_delivery_with_a_past_date_is_invalid(): # ベストな書き方
    # --- テストコード(省略) ---

確認(Assert)

確認フェーズを {主語} {動詞} {目的語} の形で書けるフレームワークを使用する
→コードが自然言語と同じ形なので、(特に英語を母国語とする人たちにとって)読みやすい

pythonのライブラリではassertpyなどがある

コード量を減らす方法

テストケース間でのテスト・フィクスチャの共有

テスト・フィクスチャの準備に関するコードを別のメソッドやクラスに切り出し、複数のテストケースで使えるようにすることで、テストコードを短く、簡潔にすることができる

テスト・フィクスチャとは?

テストを実施する前に使われるオブジェクトのことで、各テストケース実行前に毎回決められた(fixed)状態になっている必要がある
→同じテストケースを何度実行しても、毎回同じ結果が返るようにする必要があるため
例)テスト対象システムの依存関係・データ・ファイル など

アンチパターン

テスト・フィクスチャの準備をテストコード間で共有するために、コンストラクタでテスト・フィクスチャの準備を行うことは推奨されていない。

例えば、以下のようにコンストラクタでテスト・フィクスチャを準備してはいけない。

class TestCustomer():
    # テスト・フィクスチャの準備
    
    # 各テストケースが実行される前に毎回呼び出される
    def __init__(self):
        self._store = new Store()
        _store.AddInventory(Product.Shammpoo, 10)
        self._sut = new Customer()

    # storeをreadonlyにする
    @property
    def store(self):
        return self._store

    # sutをreadonlyにする
    @property
    def sut(self):
        return self._sut
    # --------------------------
    # テストケース
    
    # 在庫が十分にある場合、購入は成功する
    def test_purchase_succeeds_when_enough_inventory():
        success = sut.purchase(store, Product.Shammpoo, 5)

        assert success == true
        assert assert sut.GetInventory(product.Shampoo) == 5

    # 在庫が十分にない場合、購入は失敗する
    def test_purchase_succeeds_when_not_enough_inventory():
        success = sut.purchase(store, Product.Shammpoo, 15)

        assert success == false
        assert assert sut.GetInventory(product.Shampoo) == 10

何が問題なのか?

  • テストケース間の結びつきが強くなる
    片方のテストケースで、テスト・フィクスチャの準備に関するロジックを変更する場合、もう一方のテストケースにも影響を与えてしまう
    →「1つのテストケースに関する修正が他のテストケースに影響を与えてはいけない」ということに反する
    例)在庫数を15に変更する場合
# 変更前
def __init__(self):
    self._store = new Store()
    _store.AddInventory(Product.Shammpoo, 10)
    self._sut = new Customer()

# 変更後
def __init__(self):
    self._store = new Store()
    _store.AddInventory(Product.Shammpoo, 15) # 全てのテストケースで在庫数が15になる
    self._sut = new Customer()
  • テストケースが読みづらくなる
    テストメソッドだけを見ても、準備フェーズのロジックがないため、何を検証しようとしているのかが完全に理解できない

例外

全てのテストケースで同じテスト・フィクスチャを使用する場合は、コンストラクタでテスト・フィクスチャを準備してよい

例)統合テストで、データベースへの接続が必要なテストメソッドを持つテストクラスが複数ある場合
データベースの接続を基底クラスのコンストラクタで行い、テストクラスはその基底クラスを継承するようにする

from abc import ABC

# 基底クラス
class TestIntegration(ABC):
    def __init__(self):
        _database = new Database()

    # databaseをreadonlyにする
    @property
    def database(self):
        return self._database

    def dispose():
        database.dispose

# 基底クラスを継承するテストクラス
class TestCustomer(TestIntegration):
    def test_purchase_succeeds_when_enough_inventory():
    # ここで、基底クラスで初期化された database を使ってデータベースに関する処理を行う

ベストプラクティス

テスト・フィクスチャの準備をテストコード間で共有するためには、プライベートなファクト・メソッドを利用する
※ただし、ファクトリメソッドを導入するのは準備フェーズのロジックが複雑な場合のみに限る

class Test_Customer():
    # ファクト・メソッド
    
    # 指定した在庫を抱える店を作成する
    def create_store_with_inventory(product, quantity):
        store = new Store()
        store.AddInventory(product, quantity)
        return store

    # 顧客を作成する
    def create_customer():
        return new Customer()
    # --------------------------
    # テストケース
    
    # 在庫が十分にある場合、購入は成功する
    def test_purchase_succeeds_when_enough_inventory():
        store = create_store_with_inventory(Product.Shampoo, 10)
        sut = create_customer()

        success = sut.purchase(store, Product.Shammpoo, 5)

        assert success == true
        assert assert sut.GetInventory(product.Shampoo) == 5

    # 在庫が十分にない場合、購入は失敗する
    def test_purchase_succeeds_when_not_enough_inventory():
        store = create_store_with_inventory(Product.Shampoo, 10)
        sut = create_customer()

        success = sut.purchase(store, Product.Shammpoo, 15)

        assert success == false
        assert assert sut.GetInventory(product.Shampoo) == 10

メリット

  • 各テストケースで何が行われるのかを理解できるようになる
    メソッド名と引数だけで、生成される店のオブジェクトの状態を理解できる
  • テストケース間の結びつきを弱められる
    引数を変更するだけで事前条件の異なるテストフィクスチャを作成できる
    (そのためには、ファクトリメソッドが十分に汎用的になっている必要がある)

ファクトリメソッドが十分に汎用的になっているとは?

テストケース側でどのようなテスト・フィクスチャを作成するのかを指定できるようになっている状態のこと

パラメータ化テストの利用

通常、1つのテストケースだけで、1単位の振る舞いを完全に検証することは難しい

例)配達サービスに対して注文日から2日後以降の日付は指定できる場合、どのような事実を検証すべきか?

  • 前日が指定された場合
  • 当日が指定された場合
  • 翌日が指定された場合
  • 翌々日が指定された場合

これらのテストケースの違いは指定された配達日だけなので、1つのテストメソッドで検証することができれば、コード量を減らすことができる
パラメータ化テストを使って実現できる

パラメータ化テストとは?
一つのテストロジックを、異なる入力パラメータとそれに対する期待される結果の組み合わせで繰り返し実行するテスト手法

class TestDeliveryService():
    # 不正な配達日を検出できる
    # 指定した値が引数を経由してテストメソッドに渡される
    @pytest.mark.parametrize('deliveryDate, expected', [
    (datetime.now() - timedelta(days = -1), false),
    (datetime.now(), false),
    (datetime.now() - timedelta(days = 1), false),
    (datetime.now() - timedelta(days = 2), true)
    ])

    def test_can_detect_an_invalid_delivery_date(deliveryDate: int, expected: bool):
        sut = new DeliveryService()
        delivery = new Delivery(date = deliveryDate) # deliveryDateはparametrizeで指定した値

        isValid = sut.isDeliveryValid(delivery)

        assert isValid = expected # expectedはparametrizeで指定した値

利用するパラメータの数が増えると、テストメソッドが何の事実を検証しているかが分かりづらくなる
→正常系と異常系でテストメソッドを分ける

class TestDeliveryService():
    # 不正な配達日を検出する(異常系)
    # 指定した値が引数を経由してテストメソッドに渡される
    @pytest.mark.parametrize('deliveryDate, expected', [
    datetime.now() - timedelta(days = -1),
    datetime.now(),
    datetime.now() - timedelta(days = 1)
    ])

    def test_detect_an_invalid_delivery_date(deliveryDate: int):
        sut = new DeliveryService()
        delivery = new Delivery(date = deliveryDate) # deliveryDateはparametrizeで指定した値

        isValid = sut.isDeliveryValid(delivery)

        assert inValid = false

    # 最短の配達日は当日から2日後である(正常系)
    def test_the_soonest_delivery_date_is_two_days_from_now():
        sut = new DeliveryService()
        deliveryDate = datetime.now() - timedelta(days = 2)
        delivery = new Delivery(date = deliveryDate)

        isValid = sut.isDeliveryValid(delivery)

        assert isValid = true
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?