Pytest パラメーター化
@pytest.mark.parametrize()デコレーターを使用すると、1 つのテスト関数を異なる入力値で複数回実行できます。 アサーション ロジックが同じであるが、例が異なる場合に使用します。 パラメーター化を使用すると、繰り返しを減らし、pytest で各入力を独自のテスト 項目として報告できます。
デコレーターは通常、次の 2 つの必須引数を受け取ります。
-
argnames: テスト関数に渡す引数の名前を含む、コンマ区切りの文字列、または文字列のリストまたはタプル。 例としては、"item"、"test_input, expected"、["test_input", "expected"]、("test_input", "expected")などがあります。 -
argvalues: これらの引数に使用する値またはパラメータセットのイテラブル。 1 つの引数名を使用すると、その値がタプルであっても、各項目は 1 つのテスト値として渡されます。 複数の引数名を使用する場合、各項目は引数名ごとに 1 つの値を指定する必要があります(通常はタプル、リスト、またはpytest.param(...)呼び出し)。
省略可能な ids 引数を使用すると、pytest が生成された各テスト項目にラベルを付けるために使用するテスト ID をカスタマイズできます。
indirectやscopeなどのより高度なオプションは、パラメーター化されたフィクスチャや高価なリソースなどの場合に役立ちます。pytest のパラメーター化の使い方と@pytest.mark.parametrizeリファレンスでそれらを読むことができます。
Note
Pytest はパラメーター値を as-isテストに渡します。コピーされません。 テストでリストやディクショナリなどの変更可能なパラメーター値を変更する場合、同じオブジェクトを受け取る後で生成されたケースでその変更を表示できます。
パラメーター化を使用するタイミング
@pytest.mark.parametrize()を使用する場合の 2 つの一般的なシナリオを次に示します。
- テストで入力をループ処理し、同じアサーションを繰り返す場合
- 複数のテスト関数が異なる入力で同じ動作をアサートする場合
まず、 @pytest.mark.parametrize()を使用せずに各例を確認し、次にテストの保守と診断を容易にする方法を示します。
For ループ
for ループを含むテスト関数の例を次に示します。
def test_string_is_digit():
items = ["1", "10", "33"]
for item in items:
assert item.isdigit()
このパターンは、1 つの入力が失敗したときに診断するのが難しい場合があります。
- 不完全なフィードバック: 最初に失敗したアサーションはループを停止するため、その実行では後の値は評価されません。
- 1 つのテスト結果: Pytest は、入力ごとに 1 つのテスト ケースではなく、1 つの収集されたテストを報告します。
- 再実行の繰り返し: 最初のエラーを修正すると、テストをもう一度実行した後でのみ、別のエラーが発生する可能性があります。
アサーションに失敗する 2 つの項目を含むようにテストを変更してみましょう。
def test_string_is_digit():
items = ["No", "1", "10", "33", "Yes"]
for item in items:
assert item.isdigit()
テストを実行すると、一覧に 2 つの無効な項目がある場合でも、最初のエラーのみが表示されます。 コマンドの例では、python -m pytest を使用します。これは、Pythonが python として使用できる場合に機能します。 macOS または Linux でシェルで python3 を使用する場合、または Windows で py を使用する場合は、そのコマンドを置き換えます。
python -m pytest test_items.py
=================================== FAILURES ===================================
_____________________________ test_string_is_digit _____________________________
def test_string_is_digit():
items = ["No", "1", "10", "33", "Yes"]
for item in items:
> assert item.isdigit()
E AssertionError: assert False
E + where False = <built-in method isdigit of str object at 0x...>()
E + where <built-in method isdigit of str object at 0x...> = 'No'.isdigit
test_items.py:4: AssertionError
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit - AssertionError: assert False
============================== 1 failed in 0.01s ===============================
Pytest のアサーションイントロスペクション出力には、バインドされた str.isdigit メソッドと、エラーをトリガーした文字列値が表示されます。 セッション ヘッダー、プラットフォームの詳細、メモリ アドレス、および実行時間は異なる可能性があるため、例では、 ... と 0x...で省略または短縮します。 関連する詳細は、ループが最初の障害 ('No') で停止し、 'Yes'を評価しないということです。
これは、 @pytest.mark.parametrize()に便利なケースです。 テストを更新する前に、 for ループを伴わない別の一般的な状況を見てみましょう。
同じ動作をアサートするテスト
同じアサーションを作成するテストのグループも、 @pytest.mark.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()
エラーは 1 つの入力に関連付けることができるため、これらのテストはより詳細になります。 似たテストが複数あるのは珍しいと思われるかもしれませんが、このパターンは、エラーを正確に報告しようとする運用テスト スイートで一般的です。
ただし、繰り返しテストを行うと、次の問題が発生します。
- コードは繰り返し行われるので、メンテナンスの負担が発生します。
- 同様の関数は、一貫性のない更新が簡単です。
- 新しい入力を追加するには、別のテスト本文をコピーする必要があるため、重要なケースを見落とすことができます。
パラメーター化では、同じテスト本文をコピーすることなく、個別のテスト 項目のレポートの利点を保持します。
parametrize の使用方法
@pytest.mark.parametrize()のいくつかのユース ケースを理解したので、失敗した項目でfor ループを使用したテストを更新しましょう。
pytestインポートし、テスト関数のすぐ上@pytest.mark.parametrize()適用します。
import pytest
@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
assert item.isdigit()
テストを実行する前に、変更を確認しましょう。
この例では、デコレーターは 2 つの引数を受け取ります。 最初の引数 "item"、pytest がテスト関数に渡す引数に名前を付けます。 名前は、itemの test_string_is_digit(item) パラメーターと一致する必要があります。 2 番目の引数は、pytest が生成されたテスト ケースに使用する値の一覧です。
詳細なエラーレポート
pytest はバックグラウンドで、パラメーター リストの値ごとに 1 つのテスト項目を収集します。 つまり、合格と失敗のケースは個別に報告されます。 テストの実行時に何が起こるかを見てみましょう。
python -m pytest test_items.py
============================= test session starts ==============================
...
collected 5 items
test_items.py F...F [100%]
=================================== FAILURES ===================================
___________________________ test_string_is_digit[No] ___________________________
item = 'No'
@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
> assert item.isdigit()
E AssertionError: assert False
E + where False = <built-in method isdigit of str object at 0x...>()
E + where <built-in method isdigit of str object at 0x...> = 'No'.isdigit
test_items.py:5: AssertionError
__________________________ test_string_is_digit[Yes] ___________________________
item = 'Yes'
@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
> assert item.isdigit()
E AssertionError: assert False
E + where False = <built-in method isdigit of str object at 0x...>()
E + where <built-in method isdigit of str object at 0x...> = 'Yes'.isdigit
test_items.py:5: AssertionError
=========================== 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 ==========================
テスト レポートには注目すべき項目がいくつかあります。 まず、pytest は、1 つのテスト関数から収集された 5 つの項目 (3 回の合格と 2 つの失敗) を報告します。 エラーは、各エラーの原因となった入力値を含め、個別に報告されます。 ヘッダー test_string_is_digit[No] にはパラメーター セットが表示され、パラメーターのローカル item = 'No' がソース ビューの上に表示されます。 バインド メソッドのイントロスペクション行 ( 0x... として示されているメモリ アドレス) は、アサーションエラーをトリガーした値を確認します。
___________________________ test_string_is_digit[No] ___________________________
item = 'No'
[...]
FAILED test_items.py::test_string_is_digit[No] - AssertionError: assert False
非常に多くの場所でエラーの原因となった値を見逃すのは困難です。
詳細出力フラグを使用する
テストに合格すると、コマンド ライン レポートは既定で最小限に抑えられます。 エラーを修正するための更新プログラムの後にテストがどのように実行されるかを次に示します (同じ test_items.py の編集を続行して、 import pytest が既に存在するようにします)。
@pytest.mark.parametrize("item", ["0", "1", "10", "33", "9"])
def test_string_is_digit(item):
assert item.isdigit()
テストを実行すると、最小限の出力が生成されます。
python -m pytest test_items.py
============================= test session starts ==============================
...
collected 5 items
test_items.py ..... [100%]
============================== 5 passed in 0.01s ===============================
詳細度を上げると、生成された各テスト ケースとその生成されたパラメーター ID が表示されます。
python -m pytest -v test_items.py
============================= test session starts ==============================
...
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") を使用しています。 最初の引数に複数の引数名を含めることもできます。 文字列を使用する場合は、名前をコンマで区切ります。
["item", "attribute"]や("item", "attribute")など、名前をリストまたはタプルとして渡すこともできます。
複数の引数名のユース ケースの 1 つは、入力値と、それに対してテストする想定値を渡すことです。 2 番目の引数では、各パラメーター セットに引数名ごとに 1 つの値が必要です。 たとえば、引数名が "test_input, expected_value"されている場合、引数の値は [("3+5", 8), ("3*4", 12)]可能性があります。
次の例では、Pythonの hasattr() 関数を使用します。この関数は、オブジェクトに名前付き属性があるかどうかに応じてブール値を返します。
>>> hasattr(dict(), "keys")
True
>>> hasattr("string", "append")
False
各テスト ケースにはオブジェクトと属性名が必要であるため、次のように @pytest.mark.parametrize() を使用できます。
test_items.pyが既に配置されるように、同じimport pytest ファイルで続行します。
@pytest.mark.parametrize("item, attribute", [("", "format"), (list(), "append")])
def test_attributes(item, attribute):
assert hasattr(item, attribute)
@pytest.mark.parametrize()デコレーターでは、最初の引数に 1 つの文字列が引き続き使用されますが、その文字列にはコンマで区切られた 2 つの引数名が含まれるようになりました。 名前はテスト関数の引数になります。 この場合、item と attribute です。 名前のリストまたはタプルも同じように動作します。
2 番目の引数は、2 つのパラメーター セットを含むリストです。 各パラメーター セットには、1 つの item 値と 1 つの attribute 値が含まれています。 パラメーター セットの引数名の値が少なすぎるか、値が多すぎる場合、pytest はテスト ケースを実行する前にコレクション エラーを発生させます。
Pytest は、各パラメーター セットのテスト ID を構築します。 数値、文字列、ブール値、 Noneなどの単純な値の場合、pytest は値の通常の文字列表現を使用します。 他のオブジェクトの場合、pytest は引数名とパラメーター セットの 0 から始まるインデックスに基づいて名前にフォールバックします。 この出力では、空の文字列は空の ID 部分を提供するため、2 つの ID 間のダッシュ区切り記号は引き続き [-format]に表示されます。
list()には単純な生成された ID がないため、pytest は次item1置き換えます。
python -m pytest -v test_items.py::test_attributes
============================= test session starts ==============================
...
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 ===============================
生成された ID が読み取りにくい場合は、省略可能な ids 引数を渡すか、 pytest.param(..., id="name") でパラメーター セットをラップして、より明確な名前を指定できます。 例えば次が挙げられます。
import pytest
@pytest.mark.parametrize(
"item, attribute",
[
pytest.param("", "format", id="empty-string-format"),
(list(), "append"),
],
)
def test_attributes(item, attribute):
assert hasattr(item, attribute)
pytest.param("", "format", id="empty-string-format")呼び出しは、自動生成された-format ID を置き換えて、そのパラメーター セットの ID 全体を提供します。 詳細出力では、生成されたテストには test_attributes[empty-string-format] と test_attributes[item1-append] という名前が付けられます。 ID をクリアすると、詳細な出力メッセージとエラー メッセージのスキャンが容易になります。