Pytest パラメータ化

完了

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

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

parametrizeの英語のpytest APIリファレンスについては、こちらのpytest.Metafunc.parametrizeをご覧ください。

パラメーター化を使用するタイミング

パラメーター化を使用する一般的な 2 つのシナリオを次に示します。

  • forループのテスト時
  • 複数のテストが同じ動作をアサートする場合

最初にパラメーター化を使用せずに各例を確認し、次にテストを改善する方法を示します。

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 ===============================

これは パラメーター化の優れたユース ケースです。 テストを更新する方法を確認する前に、 for ループを伴わない別の一般的な状況を見てみましょう。

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

同じアサーションを行うテストのグループは、パラメーター化の良い候補でもあります。 前のテストが項目ごとに 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 の使用方法

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

パラメーター化を使用するには、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 ===============================