Pytest parametrize

完了

pytest の parametrize 機能は、最初は複雑に見えるかもしれませんが、対処する問題を理解すると、その目的は簡単です。 基本的に、parametrize を使用すると、異なる入力で同じテスト関数を効率的に実行できるため、少ないコードでも、詳細で多様なアサーションを簡単に実行できます。

parametrize を呼び出すとき、最初の引数は 1 つ以上の引数名を含む文字列です (例: "test\_input_")。 2 番目の引数には、引数の値の一覧が含まれています (例: ["27", "6+9", "0", "O"])。 最後の 4 つの引数には既定値があり、省略可能です。

parametrize の英語の pytest API リファレンスについては、pytest.Metafunc.parametrize を参照してください。

parametrize を使用するタイミング

parametrize を使用する 2 つの一般的なシナリオを次に示します。

  • for ループをテストするとき
  • 複数のテストで同じ動作をアサートするとき

まず、parametrize を使用せずに各例を確認し、次にテストを改善する方法を示します。

for ループ

for ループを含むテスト関数の例を次に示します。

def test_string_is_digit():
    items = ["1", "10", "33"]
    for item in items:
        assert item.isdigit()

このテストは、失敗した場合、次のようないくつかの問題につながる可能性があるため、問題があります。

  • あいまいなテスト レポート:テスト レポートでは、1 つの項目だけが失敗したか、複数の失敗が発生したかは明確に示されません。
  • 1 つのテスト ビュー:すべての項目は 1 つのテストと見なされ、個々の項目のパフォーマンスがあいまいになります。
  • 不確実な修正:失敗が修正された場合、テスト全体を再実行せずにすべての問題が解決されたかどうかを知る方法はありません。

テストを変更して、失敗が予想される 2 つの項目を具体的に含めましょう。

def test_string_is_digit():
    items = ["No", "1", "10", "33", "Yes"]
    for item in items:
        assert item.isdigit()

テストを実行すると、その一覧に無効な項目が 2 つある場合でも、1 つのエラーのみが表示されます。

$ pytest test_items.py
=================================== FAILURES ===================================
_____________________________ test_string_is_digit _____________________________
test_items.py:4: in test_string_is_digit
    assert item.isdigit()
E   AssertionError: assert False
E    +  where False = <built-in method isdigit of str object at 0x103fa1df0>()
E    +    where <built-in method isdigit of str object at 0x103fa1df0> = 'No'.isdigit
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit - AssertionError: assert False
============================== 1 failed in 0.01s ===============================

これは、parametrize の優れたユース ケースです。 テストを更新する方法を確認する前に、for ループを含まないもう 1 つの一般的な状況を見てみましょう。

同じ動作をアサートするテスト

同じアサーションを作成するテストのグループも、parametrize の候補として適しています。 前のテストが項目ごとに 1 つのテストで書き換えられた場合は、エラー報告を改善できますが、繰り返し実行されます。

def test_is_digit_1():
    assert "1".isdigit()

def test_is_digit_10():
    assert "10".isdigit()

def test_is_digit_33():
    assert "33".isdigit()

これらのテストは、単一の入力に失敗を簡単に関連付けることができるという点で優れています。 同様のテストが複数あるのは珍しいように思えるかもしれませんが、運用テスト スイートでは粒度を細かくしようとするのが一般的です。

テストは、失敗 (または成功) を正確に報告できるため、より優れていますが、次の問題も発生します。

  • コードは反復的であり、メンテナンスの負担を生み出します
  • テストを更新するときに入力ミスやミスが発生する可能性があります
  • 反復的であるため、エンジニアはすべてのユース ケースと入力を含めないことがあります

parametrize を使用する方法

parametrize のユース ケースの一部を理解したので、失敗する項目を含む for ループを使用したテストを更新しましょう。

parametrize を使用するには、ライブラリとして pytest をインポートし、関数でデコレーターとして使用する必要があります。 更新されたテストは次のようになります。

import pytest

@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
    assert item.isdigit()

テストを実行する前に、変更を確認しましょう。

pytest.mark.parametrize() デコレーターでは 2 つの引数を定義します。 最初の引数は "item" という文字列です。 この文字列は、テスト関数定義の次の行に表示されるテスト関数の "名前付き引数" として使用されます。 2 番目の引数は、テスト値の一覧です。

豊富なエラー報告

バックグラウンドでは、pytest ではその一覧内の各項目を "個別のテストと" 見なします。 つまり、合格したテストと失敗したものは個別に報告されます。 pytest を使用してテストを実行するとどうなるかを見てみましょう。

$ pytest test_items.py
============================= test session starts ==============================
Python 3.9.6, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /private
collected 5 items

test_items.py F...F                                                      [100%]

=================================== FAILURES ===================================
___________________________ test_string_is_digit[No] ___________________________
test_items.py:5: in test_string_is_digit
    assert item.isdigit()
E   AssertionError: assert False
E    +  where False = <built-in method isdigit of str object at 0x102d45e30>()
E    +    where <built-in method isdigit of str object at 0x102d45e30> = 'No'.isdigit
__________________________ test_string_is_digit[Yes] ___________________________
test_items.py:5: in test_string_is_digit
    assert item.isdigit()
E   AssertionError: assert False
E    +  where False = <built-in method isdigit of str object at 0x102d45df0>()
E    +    where <built-in method isdigit of str object at 0x102d45df0> = 'Yes'.isdigit
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit[No] - AssertionError: assert False
FAILED test_items.py::test_string_is_digit[Yes] - AssertionError: assert False
========================= 2 failed, 3 passed in 0.07s ==========================

テスト レポートには、いくつかの注目すべき項目があります。 まず、1 つのテストから、pytest が合計 5 つのテスト (3 回の合格と 2 回の失敗) を報告していることがわかります。 失敗した入力を含め、エラーは個別に報告されます。

$ pytest test_items.py
___________________________ test_string_is_digit[No] ___________________________
[...]
E    +    where <built-in method isdigit of str object at 0x102d45e30> = 'No'.isdigit
[...]
FAILED test_items.py::test_string_is_digit[No] - AssertionError: assert False

報告される場所が非常に多く、エラーの原因となった値を見逃すのは困難です。

詳細の出力フラグを使用する

コマンド ラインでテストを実行する場合、テストが合格したときのテスト レポートは最小限です。 失敗を修正するための更新の後のテストは次のようになります。

@pytest.mark.parametrize("item", ["0", "1", "10", "33", "9"])
def test_string_is_digit(item):
    assert item.isdigit()

テストを実行すると、最小限の出力が生成されます。

$ pytest test_items.py 
============================= test session starts ==============================
Python 3.9.6, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /private
collected 5 items

test_items.py .....                                                      [100%]

============================== 5 passed in 0.01s ===============================

詳細度を上げると、parametrize の使用時に pytest によって各テストで実行される値が示されます。

$ pytest -v test_items.py
============================= test session starts ==============================
Python 3.9.6, pytest-6.2.5, py-1.11.0, pluggy-1.0.0 
rootdir: /private
collected 5 items

test_items.py::test_string_is_digit[0] PASSED                            [ 20%]
test_items.py::test_string_is_digit[1] PASSED                            [ 40%]
test_items.py::test_string_is_digit[10] PASSED                           [ 60%]
test_items.py::test_string_is_digit[33] PASSED                           [ 80%]
test_items.py::test_string_is_digit[9] PASSED                            [100%]

============================== 5 passed in 0.01s ===============================

複数の引数名を使用する方法

これまでに見てきた例では、最初の引数に引数名が 1 つだけ含まれています。 "item" を使用してきましたが、最初の引数をコンマで区切って指定する複数の引数名を文字列に含めることができます。

複数の引数名を使用する場合のユース ケースの 1 つは、入力値に対してテストするために一連の予期される値を渡す場合です。 2 番目の引数では、セット内の各項目に、入力名の数と等しい数の値が必要です。 たとえば、入力名が "test\_input, expected\_value" の場合、2 番目の引数は [("3+5", 8), ("3*4", 12)] のようになります

このテストでは、Python hasattr() 関数を使用して、オブジェクトに属性があるかどうかを確認します。 オブジェクトが関連付けられた属性を持っているかどうかに応じてブール値を返します。

>>> hasattr(dict(), "keys")
True
>>> hasattr("string", "append")
False

hasattr() には 2 つの引数が必要なため、次のように parametrize を使用できます。

@pytest.mark.parametrize("item, attribute", [("", "format"), (list(), "append")])
def test_attributes(item, attribute):
    assert hasattr(item, attribute)

parametrize デコレーターでは、引き続き最初の引数には 1 つの文字列が使用されますが、2 つの引数名はコンマで区切られ、テスト関数の引数になります。 この例では itemattribute です。

次に、2 組の項目の一覧を示します。 これらの各ペアは、テスト対象の itemattribute を表します。

pytest では、渡されたオブジェクトの文字列表現をビルドできない場合は、作成します。 テストを実行すると、これを確認できます。

$ pytest -v test_items.py
============================= test session starts ==============================
Python 3.9.6, pytest-6.2.5, py-1.11.0, pluggy-1.0.0 
rootdir: /private
collected 2 items

test_items.py::test_attributes[-format] PASSED                           [ 50%]
test_items.py::test_attributes[item1-append] PASSED                      [100%]

============================== 2 passed in 0.01s ===============================