Python の 単体テストで 大量の入力パターンを効率よくテストする方法

  • 22
    いいね
  • 0
    コメント

対象の読者

  • 普段Pythonをお使いの方。
    • 特に単体テストコードを書く機会がある方。
    • Python標準のUnitTestフレームワークを使う機会のある方。

概要

  • 単体テストをする際、1つのテストケースの中で、パラメータを変えて複数回対象をAssertすることがあります。
  • 普通に実装すると、1パターン失敗した時点で、残りのパターンがテストされず、テストは終了してしまいます。
  • Subtestを用いると、途中で失敗するパターンがあっても、テストを最後まで実行し、成否をパターン毎に個別に出力してくれるようになります。

前提条件

この記事の内容はPython 3.5系で検証したものです。
それ以前のバージョンだと想定通り動かないかもしれません。

テスト対象例

あるメソッドやクラスのテストをする場合、大抵の場合、テスト対象が取りうる入力、状態ないし結果を網羅するように、パラメータを変えてテストをするかと思います。

例えば、以下のように、2人の手の形を与えると、じゃんけんの勝者の文字列を返すメソッドがあるとします。

test_target.py
def rock_paper_scissors(p1_shape, p2_shape):
    """ Evaluate both player's hand shape and return winner.
    :param p1_shape: Player 1's hand shape, must be either of "Rock", "Paper", "Scissor".
    :type p1_shape: str
    :param p2_shape: Player 2's hand shape, must be either of "Rock", "Paper", "Scissor".
    :type p2_shape: str
    :return: winner
    Either of "Player1" or "Player2" "Even" will be returned.
    :rtype : str
    :raises ValueError when invalid hand shape has given.
    """

    # Define valid shape constants.
    _valid_shapes = ["Rock", "Paper", "Scissor"]

    # Define result constants.
    _EVEN = "Even"
    _PLAYER_1 = "Player1"
    _PLAYER_2 = "Player2"

    # Initialize judgement table.
    _win_loss_judgement_table = {
        ("Rock", "Rock"): _EVEN,
        ("Rock", "Paper"): _PLAYER_2,
        ("Rock", "Scissor"): _PLAYER_2,  # Bug! Correct result is _PLAYER1
        ("Paper", "Rock"): _PLAYER_1,
        ("Paper", "Paper"): _EVEN,
        ("Paper", "Scissor"): _PLAYER_1,  # Bug! Correct result is _PLAYER2
        ("Scissor", "Rock"): _PLAYER_2,
        ("Scissor", "Paper"): _PLAYER_1,
        ("Scissor", "Scissor"): _EVEN,
    }

    # Main logic starts from here.
    # Validation
    if p1_shape not in _valid_shapes or p2_shape not in _valid_shapes:
        raise ValueError("Shape must be either of {0}".format(_valid_shapes))

    # Judgement
    winner = _win_loss_judgement_table[(p1_shape, p2_shape)]
    return winner

ソースを見ても分かるとおり、この関数が取りうる入力のパターンは9通りあります。
したがって、可能な限り、9通り(注: 異常系を考慮しない場合)のパラメータでテストをする必要があります。

テスト方法

上記のモジュールをテストする場合に、どのような方法があるか、いくつか考えてみます。

手法①:やりたくない方法

じゃんけんモジュールに対応するテストケースを書き、入力のパターン1つに対し、テストメソッドを1つ定義します。

test_rps_poor.py
from unittest import TestCase, main
from src.unit_test.parameterized_test.test_target import rock_paper_scissors

class TestRockPaperScissors(TestCase):
    """ This test case tests rock_paper_scissors method """

  def test_with_Rock_Rock(self):
    self.assertEqual(rock_paper_scissors(p1_shape="Rock", p2_shape="Rock"), "Even")

  def test_with_Rock_Paper(self):
    self.assertEqual(rock_paper_scissors(p1_shape="Rock", p2_shape="Paper"), "Player2")
   ...
利点
  • 組み合わせごとに成否が判定され、テスト結果にも与えた引数が表示されるため、テストが失敗したときにどのパターンで失敗したかわかりやすい。
欠点
  • コピペ。DRYの原則に反しテストコードが肥大化し、入力の組み合わせが多いコードにはとても適用できない。
結論

この方法は使いたくないです。例に載せるか迷ったくらいです。
しかし、ごくまれにこのようなコードも見ることがあるので載せておきました。
このようなコードが発生した理由は、次に紹介する例で明らかになります。

手法②:よくある方法

for文という素晴らしい構文があるのですから、パラメータの組み合わせを列挙して、ループでまわすテストコードを書きます。

test_rps_without_subtest.py
from unittest import TestCase, main
from src.unit_test.parameterized_test.test_target import rock_paper_scissors


class TestRockPaperScissors(TestCase):
    """ This test case tests rock_paper_scissors method """

    def test_with_valid_params(self):
        """
        Without SubTest, Test will exit on first failure.
        Consequently, we can not test all patterns.
        """

        test_patterns = [
            ("Rock", "Rock", "Even"),  # Will pass.
            ("Rock", "Paper", "Player2"),  # Will pass.
            ("Rock", "Scissor", "Player1"),  # The Unit test will fail with this parameter. Even worse, we can't know that failed test parameters from the test result.
            ("Paper", "Rock", "Player1"),  # Following parameters are not tested because the test exits on the first failure.
            ("Paper", "Paper", "Even"),
            ("Paper", "Scissor", "Player2"),  # This pattern should fail by bugs, but won't be detected thanks to above failure.
            ("Scissor", "Rock", "Player2"),
            ("Scissor", "Paper", "Player1"),
            ("Scissor", "Scissor", "Even"),
        ]

        for p1_shape, p2_shape, expected_result in test_patterns:
            self.assertEqual(rock_paper_scissors(p1_shape=p1_shape, p2_shape=p2_shape), expected_result)
利点
  • パラメータが列挙されていて、比較的見やすい
  • テストコードの重複がない(DRY)
欠点
  • あるパラメータで失敗した場合、その時点でテストが終了してしまう。
    • 失敗時に、実行されないパターンが生じてしまう。
  • さらに悪いことに、どのパラメータで失敗したのか、パラメータの情報が出力されない。
    • 一応、printデバッグで対処はできる。
結論

あるパラメータの組みで失敗した場合、その時点でテストが終了してしまうのは不便です。
テストが通るようにバグを直しても、直したことで後続のパラメータの組みがテストされるようになり、別のパラメータでテストが失敗するようになることがあります。
これは、開発者にとってリグレッションと区別がつきにくく、混乱の元となるため、手法①が使われてしまうことがあるようです。

さらに、以下のように、どのパラメータの組で失敗したのか、一見結果からはわからないので、これも面倒です。

手法②の出力例
python -m unittest ./test_rps_without_subtest.py

======================================================================
FAIL: test_with_valid_params (test_without_subtest.TestRockPaperScissors)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "...\test_without_subtest.py", line 27, in test_with_valid_params
    self.assertEqual(rock_paper_scissors(p1_shape=p1_shape, p2_shape=p2_shape), expected_result)
AssertionError: 'Player2' != 'Player1'
- Player2
?       ^
+ Player1
?       ^
----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (failures=1)

手法③:Subtestを用いる方法

今回紹介する方法です。
実は、unittestモジュールのTestCaseクラスには、subTestというメソッドが容易されています。
これをコンテキストマネージャとして、forループの下にsubTestコンテキストとして定義すると、コンテキスト内のブロックを独立したサブテストとして扱ってくれ、テスト実行時にサブテスト毎に結果を表示してくれるようになります。

test_rps_with_subtest.py
from unittest import TestCase, main
from src.unit_test.parameterized_test.test_target import rock_paper_scissors


class TestRockPaperScissors(TestCase):
    """ This test case tests rock_paper_scissors method """

    def test_with_valid_params(self):
        """
        With SubTest, All patterns will be tested even if there are some failures.
        Consequently, we can test all patterns every time.
        """

        test_patterns = [
            ("Rock", "Rock", "Even"),  # Will pass.
            ("Rock", "Paper", "Player2"),  # Will pass.
            ("Rock", "Scissor", "Player1"),  # Will fail by bugs, and the test result shows test parameters as well.
            ("Paper", "Rock", "Player1"),  # Will pass.
            ("Paper", "Paper", "Even"),  # Will pass.
            ("Paper", "Scissor", "Player2"),  # Will fail by bugs, and the test result shows test parameters as well.
            ("Scissor", "Rock", "Player2"),  # Will pass.
            ("Scissor", "Paper", "Player1"),  # Will pass.
            ("Scissor", "Scissor", "Even"),  # Will pass.
        ]

        for p1_shape, p2_shape, expected_result in test_patterns:
            with self.subTest(p1_shape=p1_shape, p2_shape=p2_shape):
                self.assertEqual(rock_paper_scissors(p1_shape=p1_shape, p2_shape=p2_shape), expected_result)

違いは、forループの下に1行追加するだけです。
subTestの引数は、サブテスト結果に表示されるキーと値の組に対応するため、この例では、パラメータとなるp1_shape, p2_shapeを引数に与えています。(失敗時に、入っている値を見たいため)

これにより、手法②の欠点が解消されます。すなわち、あるパラメータの組みで失敗してもすべての組みが評価されるようになり、かつ、テスト結果に失敗時のパラメータが表示されるようになります。

手法③の出力例
python -m unittest ./test_rps_with_subtest.py

======================================================================
FAIL: test_with_valid_params (test_rps_with_subtest.TestRockPaperScissors) (p1_shape='Rock', p2_shape='Scissor')
----------------------------------------------------------------------
Traceback (most recent call last):
  File "...test_rps_with_subtest.py", line 28, in test_with_valid_params
    self.assertEqual(rock_paper_scissors(p1_shape=p1_shape, p2_shape=p2_shape), expected_result)
AssertionError: 'Player2' != 'Player1'
- Player2
?       ^
+ Player1
?       ^


======================================================================
FAIL: test_with_valid_params (test_rps_with_subtest.TestRockPaperScissors) (p1_shape='Paper', p2_shape='Scissor')
----------------------------------------------------------------------
Traceback (most recent call last):
  File "...test_rps_with_subtest.py", line 28, in test_with_valid_params
    self.assertEqual(rock_paper_scissors(p1_shape=p1_shape, p2_shape=p2_shape), expected_result)
AssertionError: 'Player1' != 'Player2'
- Player1
?       ^
+ Player2
?       ^


----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (failures=2)

まとめ

Python標準のUnitTestフレームワークのTestCaseでは、SubTestというメソッドを用いることで、大量の入力パターンを効率よくテストすることができます。
これは、Parameterized Testと呼ばれる手法の1つで、Python Parameterized testなどで検索すると、他のテストランナーでの手法も見つかります。
是非合わせてお試しください。

最後まで読んでいただき、ありがとうございました。