💡

【pytest初心者向け】テストコードレビューを頼まれたら最初に読む記事

に公開

はじめに

「テストコードのレビューをお願い!」と頼まれたのですが、「pytestってどんなことができるの?」「いやそもそもテストコードのレビューって、何を見れば…?」と、これは学習案件だと思い、改めて調べた内容を残します。

  • テストの基本的な種類(単体テスト、結合テストなど)
  • 単体テスト(ユニットテスト)の重要性
  • Pythonのテストフレームワークpytestの基本的な使い方と便利な機能
  • pytestのテストコードのレビューでチェックすべき観点

1. テストって色々あるの?

ソフトウェア開発における「テスト」には、その目的や対象範囲によって様々な種類があります。まずは代表的なものをいくつか見ていきましょう。

  • 単体テスト (Unit Test)

    • 対象: プログラムの最小単位(関数、メソッド、クラスなど)。部品単体のテストです。
    • 目的: 個々の部品が設計通りに正しく動作するかを検証します。
    • 特徴:
      • 実行速度が速い。
      • 問題箇所の特定が容易。
      • 開発の初期段階から頻繁に実行できる。
    • 例: 「2つの数値を足し合わせる関数」が、正しく足し算の結果を返すかを確認する。
  • 結合テスト (Integration Test)

    • 対象: 複数の部品(ユニット)を組み合わせたもの。
    • 目的: 部品間のデータの受け渡しや連携(インターフェース)がうまく機能するかを検証します。
    • 例: 「ユーザー登録API」が、受け取った情報を正しく「データベース保存モジュール」に渡し、保存が成功するかを確認する。
  • システムテスト / E2Eテスト (End-to-End Test)

    • 対象: システム全体。
    • 目的: システム全体が要件を満たしているか、実際のユーザー操作を模倣して一連の流れ(シナリオ)が正しく動作するかを検証します。
    • 例: ユーザーがWebサイトにログインし、商品を検索し、カートに入れて決済するまでの一連の流れを確認する。
  • 受け入れテスト (Acceptance Test)

    • 対象: 完成したシステム。
    • 目的: 開発されたシステムが、ユーザーや顧客の要求(ビジネス要件)を満たしているかを最終確認します。

2. なぜ単体テスト(ユニットテスト)が重要なのか?

単体テストには多くのメリットがあります。

  • バグの早期発見: 問題を小さな部品レベルで発見できるため、修正コストが低く済みます。
  • リファクタリングの安心感: コードの内部構造を改善(リファクタリング)する際に、テストがあれば意図しない動作変更(デグレード)が起きていないかをすぐに確認できます。
  • 仕様の明確化: テストコードは「その部品がどのように動作すべきか」を示す生きたドキュメントになります。
  • 開発サイクルの高速化: 手動での動作確認の手間を減らし、自動テストによって迅速なフィードバックループを実現します。

3. pytest入門:Pythonでテストを書くならコレ!

pytestは、Pythonで単体テスト(ユニットテスト)を書くためのフレームワークです。
https://docs.pytest.org/en/stable/

なぜpytestが良いのかについてのメリットは以下が挙げられます。

  • シンプル: Python標準のunittestモジュールよりも少ないコード量で、直感的にテストを書けます。
  • 強力なアサーション: assert文だけで様々な検証ができ、失敗時には詳細な情報が表示されます。
  • 便利なフィクスチャ: テストの前準備(例: データベース接続、テストデータの作成)や後片付けを簡単かつ柔軟に管理できます。
  • 豊富なプラグイン: カバレッジ計測、並列実行、Webフレームワーク連携など、多くの拡張機能を利用できます。

<基本的な使い方>

  1. インストール:

    pip install pytest
    
  2. テストファイルの作成:

    • ファイル名は test_*.py または *_test.py とします。(例: test_calculation.py
    • テスト関数名は test_ で始めます。(例: def test_add_positive():
  3. 簡単なテストコードの例:
    add関数をテストするコードを書いてみましょう。

    # calculation.py (テスト対象のコード)
    def add(a, b):
        """二つの数値を足し合わせる関数"""
        if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
            raise TypeError("Inputs must be numeric")
        return a + b
    
    # test_calculation.py (テストコード)
    import pytest
    from calculation import add # テスト対象の関数をインポート
    
    def test_add_positive_integers():
        """正の整数の足し算をテスト"""
        assert add(2, 3) == 5
    
    def test_add_negative_integers():
        """負の整数の足し算をテスト"""
        assert add(-1, -5) == -6
    
    def test_add_positive_and_negative():
        """正の数と負の数の足し算をテスト"""
        assert add(5, -3) == 2
    
    def test_add_floats():
        """浮動小数点数の足し算をテスト"""
        # 浮動小数点数の比較は誤差に注意が必要な場合があるが、今回は単純比較
        assert add(1.5, 2.5) == 4.0
    
    def test_add_type_error():
        """数値以外が入力された場合にTypeErrorが発生するかテスト"""
        with pytest.raises(TypeError, match="Inputs must be numeric"):
            add("a", 1) # 文字列と数値を渡してみる
    
  4. テストの実行:
    ターミナルで、テストファイルがあるディレクトリ(またはその親ディレクトリ)で以下のコマンドを実行します。

    pytest
    

    pytestが自動的にテストファイルとテスト関数を見つけて実行し、結果を表示してくれます。

  5. 出力結果の例:
    ✔️ 出力結果: 成功例のサンプル

    ============================= test session starts ==============================  
    platform linux -- Python 3.x.x, pytest-x.x.x, py-x.x.x  
    rootdir: /path/to/your/project  
    collected 5 items
    
    test_calculation.py .....                                                [100%]
    
    ============================== 5 passed in 0.02s ===============================  
    

    ❌ 出力結果: 失敗例のサンプル

    ============================= test session starts ==============================  
    platform linux -- Python 3.x.x, pytest-x.x.x, py-x.x.x  
    rootdir: /path/to/your/project  
    collected 5 items
    
    test_calculation.py ..F..                                                [100%]
    
    =================================== FAILURES ===================================  
    ___________________________ test_add_positive_integers __________________________
    
        def test_add_positive_integers():  
            """正の整数の足し算をテスト"""  
    >       assert add(2, 3) == 5  
    E       AssertionError: assert 4 == 5  
    E        +  where 4 = add(2, 3)
    
    test_calculation.py:6: AssertionError  
    =========================== short test summary info ============================  
    FAILED test_calculation.py::test_add_positive_integers - AssertionError: assert 4 == 5  
    ============================== 1 failed, 4 passed in 0.02s ===============================  
    

pytestの便利な機能紹介

  • アサーション (Assertion):
    assert の後に評価したい式を書くだけです。式が False になるとテストは失敗し、pytestが詳細な情報を表示してくれます。

    assert x == y       # xとyが等しいか
    assert x != y       # xとyが等しくないか
    assert x < y        # xがyより小さいか
    assert item in collection # itemがcollectionに含まれるか
    assert "error" in error_message # 文字列が含まれるか
    assert obj is None  # オブジェクトがNoneか
    
  • フィクスチャ (Fixtures): @pytest.fixture デコレータ
    テスト関数を実行する前の準備(セットアップ)や後の後始末(ティアダウン)を行うための仕組みです。テスト間で共通のデータやオブジェクトを使いたい場合に非常に便利です。

    import pytest
    
    # このフィクスチャは、これを使用する各テスト関数の前に実行される
    @pytest.fixture
    def sample_user_data():
        """テスト用のユーザーデータを作成するフィクスチャ"""
        print("\n--- Setup sample_user_data ---") # デモ用にprint
        yield {"id": 1, "name": "Test User", "email": "test@example.com"}
        print("\n--- Teardown sample_user_data ---") # デモ用にprint
    
    # テスト関数は引数にフィクスチャ名を書くだけで利用できる
    def test_user_name(sample_user_data):
        """ユーザーデータの名前をテスト"""
        assert sample_user_data["name"] == "Test User"
    
    def test_user_email(sample_user_data):
        """ユーザーデータのEmailをテスト"""
        assert sample_user_data["email"] == "test@example.com"
    

    フィクスチャにはスコープ(function, class, module, session)があり、どの範囲でセットアップ・ティアダウンを実行するか制御できます。

  • パラメータ化 (Parametrization): @pytest.mark.parametrize デコレータ
    同じテストロジックを、異なる入力値と期待値の組み合わせで繰り返し実行したい場合に便利です。テストコードの重複を減らせます。

    import pytest
    from calculation import add
    
    # "a, b, expected" という引数名で、リスト内のタプルを順番に渡してテスト実行
    @pytest.mark.parametrize("a, b, expected", [
        (1, 2, 3),          # test case 1
        (-1, -2, -3),       # test case 2
        (5, 0, 5),          # test case 3
        (0, 0, 0),          # test case 4
        (1.5, 2.5, 4.0),    # test case 5
    ])
    def test_add_parametrized(a, b, expected):
        """パラメータ化されたadd関数のテスト"""
        assert add(a, b) == expected
    
  • 例外テスト: pytest.raises
    特定の例外が発生することを期待するテストを書く際に使います。

    import pytest
    from calculation import add # 上記のTypeErrorを発生させるadd関数
    
    def test_add_invalid_type():
        """非数値入力時にTypeErrorが発生することをテスト"""
        with pytest.raises(TypeError): # TypeErrorが発生すればテスト成功
            add("a", "b")
    
    def test_add_invalid_type_with_message():
        """特定のメッセージを持つTypeErrorが発生することをテスト"""
        with pytest.raises(TypeError, match="Inputs must be numeric"):
            add(1, "b")
    
  • マーカー (Markers): @pytest.mark.<marker_name> デコレータ
    テスト関数に印(マーカー)を付けて分類できます。例えば、実行に時間のかかるテストに slow マーカーを付け、普段はスキップするなどの使い方ができます。

    import pytest
    import time
    
    @pytest.mark.slow # slowマーカーを付ける
    def test_slow_operation():
        time.sleep(1) # 時間のかかる処理を模倣
        assert True
    
    def test_quick_operation():
        assert True
    
    # 実行方法:
    # pytest            -> 全てのテストを実行
    # pytest -m slow    -> slowマーカーが付いたテストのみ実行
    # pytest -m "not slow" -> slowマーカーが付いていないテストを実行
    

    マーカーは自分で自由に名前を付けられますが、skip(無条件スキップ)やxfail(失敗を許容)など、pytest組み込みのマーカーもあります。

4. pytestのテストコードレビューの観点

さて、いよいよ本題のテストコードのレビューです。チェックリストに整理しました。

pytest テストコードレビュー
├── 🎯 1. 目的の明確性
│   ├── [ ] 関数名は具体的か? (`test_add_positive_numbers` vs `test_func1`)
│   └── [ ] 1テスト1検証か?
├── ✅ 2. テストケースの十分性
│   ├── [ ] 正常系
│   ├── [ ] 異常系 (エラー、例外)
│   ├── [ ] 境界値 (0, -1, '', None, max)
│   └── [ ] パラメータ化 (`@pytest.mark.parametrize`)
├── 🔍 3. アサーションの適切性
│   ├── [ ] `assert` の意図は明確か?
│   ├── [ ] 期待値は明確か? (Noマジックナンバー)
│   ├── [ ] 失敗時メッセージは分かりやすいか?
│   └── [ ] `assert` の数は適切か? (1つ or 少数)
├── 🔗 4. 独立性
│   ├── [ ] 他テストに依存しないか?
│   ├── [ ] 共有状態の影響はないか?
│   └── [ ] `fixture` で準備/後片付けは適切か?
├── 👀 5. 可読性
│   ├── [ ] 命名は分かりやすいか?
│   ├── [ ] コード構造は整理されているか?
│   └── [ ] コメントは適切か? (読みやすいコード優先)
├── ⚡ 6. 効率性
│   ├── [ ] 冗長なコードはないか? (共通化検討)
│   ├── [ ] 実行時間は適切か? (単体テストは高速に)
│   └── [ ] 不要な `print` はないか?
├── 🔬 7. テスト対象の適切性
│   ├── [ ] 本当に「単体」か? (外部依存なし?)
│   ├── [ ] 外部依存は意図したものか?
│   └── [ ] 外部依存はモックされているか?
└── 🛠️ 8. pytest機能の活用
    └── [ ] `fixture`, `parametrize`, `mark`, `raises` など活用できているか?
  1. テストの目的は明確か?

    • テスト関数名 (test_ の後) は、何をテストしているか具体的に示していますか? (例: test_add_positive_numbers は良い、 test_func1 は悪い)
    • 一つのテスト関数で、一つのことだけを検証しようとしていますか?(複数のことを詰め込みすぎていないか?)
  2. テストケースは十分か?

    • 正常系: 期待通りに動作する基本的なケースはテストされていますか?
    • 異常系: エラー処理や例外処理は正しく動作しますか?(不正な入力、予期せぬ状態など)
    • 境界値: 仕様の境界となる値(0, -1, 空文字, None, 最大値など)はテストされていますか?
    • パラメータ化 (@pytest.mark.parametrize) を使って、効率的に多くのパターンを網羅できていますか?
  3. アサーションは適切か?

    • assert 文で何を検証しているか、一読して分かりますか?
    • 期待値は明確ですか?(マジックナンバーではなく、意味のある変数名や定数が使われていますか?)
    • 失敗時のメッセージは分かりやすいですか?(pytestが自動で良いメッセージを出すことが多いですが、複雑な場合は assert x == y, "エラーメッセージ" のように説明を加えることも検討)
    • (理想的には)1つのテスト関数内の assert は1つ、または関連性の強い数個に留まっていますか?
  4. 独立性は保たれているか?

    • 各テストは、他のテストの実行結果や実行順序に依存していませんか?
    • テスト間で共有される状態(グローバル変数、クラス変数など)が、予期せぬ影響を与えていませんか?
    • テストに必要なデータや状態の準備・後片付けは、フィクスチャ (@pytest.fixture) を使って適切に行われていますか?
  5. 可読性は高いか?

    • 変数名、関数名、フィクスチャ名は分かりやすいですか?
    • コードの構造は整理されていますか?(インデント、空行など)
    • 複雑なロジックにはコメントが付いていますか?(ただし、テストコード自体が分かりやすければコメントは不要なことも多い)
  6. 効率性は良いか?

    • 冗長なコードはありませんか?(コピペのようなコードがあれば、ヘルパー関数やフィクスチャ、パラメータ化で共通化できないか検討)
    • テストの実行時間は適切ですか?(単体テストは高速であるべき。遅い場合は原因調査)
    • 不要な print 文などが残っていませんか?
  7. テスト対象は適切か?

    • 本当に「単体」テストになっていますか? データベース接続や外部API呼び出しなど、外部システムへの依存はありませんか?
    • もし外部依存がある場合、それは意図したものでしょうか?(結合テストの意図かもしれません)
    • 単体テストでは、外部依存はモック (Mock) を使って切り離すのが一般的です。(unittest.mockpytest-mock プラグインを使います。これは少し応用的な内容です)
  8. pytestの機能を活用できているか?

    • フィクスチャ、パラメータ化、マーカー、pytest.raises など、状況に応じてpytestの便利な機能が効果的に使われていますか?

おわりに

今回は、単体テスト(ユニットテスト)の基本から、pytestの使い方、そしてレビューの観点までを整理しました。

実際のレビュー時には、以下の観点を元に確認を進めました!

  • テストの目的と関数名が具体的でわかりやすいか
  • テストケースの漏れがなさそうか
  • アサーションと可読性

チェックリストがあると抜け漏れがないか安心できたので、心の拠り所になりました。笑

Discussion