【Python】テストが突然落ちる?実行順序に依存しない「独立したテスト」の書き方

「昨日までパスしていたテストが、今日は何もコードを変えていないのになぜか失敗する…」 このような経験は、多くの開発者にとって悪夢です。この不可解な現象の最も一般的な原因の一つが、テストケースが互いに独立しておらず、特定の実行順序に依存してしまっていることです。

優れたテストスイートの黄金律は、**「すべてのテストは、他のどのテストからも完全に独立しており、どんな順序で実行されても必ず同じ結果になる」**というものです。今回は、この原則を破ってしまう一般的なアンチパターンと、各テストをクリーンで独立した状態に保つための現代的なテクニックを紹介します。

目次

問題点:インスタンス変数の共有が引き起こす「テスト間の結合」

pytestなどでクラスベースのテストを書く際、setupメソッドで初期化したインスタンス変数(self.xxx)を、複数のテストメソッドで使い回してしまうことがあります。これは、テスト間に見えない依存関係を生み出す非常に危険なパターンです。

ショッピングカートを模したクラスのテストを例に見てみましょう。

悪い例:self.cartを複数のテストで共有し、変更してしまっている

class ShoppingCart:
    def __init__(self):
        self.items = {}
        self.total = 0

    def add_item(self, item_name: str, price: int):
        self.items[item_name] = price
        self.total += price

class TestShoppingCart:
    def setup_method(self, method):
        # 注意:このcartは各テストメソッド実行前に「再生成」されるが...
        # 下記の書き方では、前のテストの副作用が残る場合がある (後述)
        # より危険なのはsetup_classなど、一度しか実行されないセットアップ
        self.cart = ShoppingCart()

    def test_add_first_item(self):
        """1つ目の商品を追加するテスト"""
        self.cart.add_item("apple", 100)
        assert self.cart.total == 100

    def test_add_second_item(self):
        """2つ目の商品を追加するテスト"""
        # このテストは、test_add_first_item が先に実行されていることを
        # 暗黙的に期待してしまっている可能性がある
        self.cart.add_item("banana", 200)
        assert self.cart.total == 300 # -> 失敗する!

pytestのようなテストランナーは、テストの実行順序を保証しません。もしtest_add_second_itemが先に実行された場合、カートは空の状態から始まるため、self.cart.total200になり、assert self.cart.total == 300は失敗します。このように、あるテストの成功が他のテストの実行に依存している状態は、非常に不安定で「 flaky( flaky:気まぐれな、不安定な)なテスト」と呼ばれます。


解決策①:各テストを完全に自己完結させる

最もシンプルで確実な解決策は、各テストメソッドが必要とするオブジェクトやデータを、そのメソッドの内部で一から準備することです。

良い例:各テストが必要なデータを自身で準備する

class TestShoppingCart:
    def test_add_one_item(self):
        # Arrange: このテストのためだけのカートを生成
        cart = ShoppingCart()
        
        # Act
        cart.add_item("apple", 100)
        
        # Assert
        assert cart.total == 100

    def test_add_two_items(self):
        # Arrange: こちらも、完全に独立したカートを生成
        cart = ShoppingCart()
        cart.add_item("apple", 100) # 1つ目の商品を追加
        
        # Act
        cart.add_item("banana", 200) # 2つ目の商品を追加
        
        # Assert
        assert cart.total == 300

このアプローチでは、各テストが完全に自己完結しているため、実行順序を気にする必要は一切ありません。テストコードは少し冗長になりますが、それ以上に得られる信頼性のメリットは絶大です。


解決策②(モダンなアプローチ):pytestのフィクスチャで状態を分離する

セットアップ処理が複雑で、各テストで繰り返したくない場合は、pytestフィクスチャが理想的な解決策を提供します。フィクスチャは、各テストに毎回新しく、クリーンな状態のオブジェクトを提供するための仕組みです。

さらに良い例:フィクスチャでクリーンなオブジェクトを各テストに提供

import pytest

@pytest.fixture
def empty_cart() -> ShoppingCart:
    """テスト用に、毎回新しい空のカートを生成して提供するフィクスチャ"""
    return ShoppingCart()

# テスト関数の引数としてフィクスチャ名を指定する
def test_add_one_item(empty_cart):
    # empty_cartは、このテスト専用の新品インスタンス
    empty_cart.add_item("apple", 100)
    assert empty_cart.total == 100

def test_total_is_zero_for_empty_cart(empty_cart):
    # こちらのempty_cartも、上記とは別の新品インスタンス
    assert empty_cart.total == 0

pytestは、empty_cartを引数に取る各テストに対して、その都度empty_cart関数を呼び出し、新しいShoppingCartインスタンスを生成して渡します。これにより、セットアップコードの共通化と、テスト間の完全な状態分離を両立できます。

まとめ

信頼性が高く、保守しやすいテストスイートを構築するための絶対的なルールは、**「テスト間でいかなる状態(State)も共有しない」**ことです。

  • 各テストは、それ自身の実行に必要なすべてのデータを、自身のスコープ内で定義またはセットアップするべきである。
  • クラスのインスタンス変数を、テストをまたいで変更・利用するような書き方は避ける。
  • セットアップロジックを共通化したい場合は、各テストに毎回新しいリソースを提供するpytestのフィクスチャを活用する。

テストの実行順序は、テストランナーの気まぐれや設定によって変わる可能性があります。その「気まぐれ」に影響されない、いつ誰が実行してもパスする堅牢なテストを書くことを常に心がけましょう。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

私が勉強したこと、実践したこと、してることを書いているブログです。
主に資産運用について書いていたのですが、
最近はプログラミングに興味があるので、今はそればっかりです。

目次