🌏

pytestのparametrizeを使ったらテスト実装が楽しくなった話

に公開

概要

  • pytest でテストケースが増えてきた際に、可読性や実装コストに課題感が出てきた
    • 同じようなテストを値を変えて量産していた
  • parametrize を使用することで可読性や実装がシンプルになりテスト実装が楽しくなった

環境

  • Python 3.11.9
  • pytest 8.4.1

想定読者

  • Python での実装経験がある(ここでは Python の基本な記述方法や用語については触れません)
  • pytest でテスト実装をしている

課題に感じていたこと

ある値を受け取るクラスを作成しており、そのクラスでバリデーション機能を実装していました。
テストとして確認したい観点はシンプルに以下の 2 点でした。

  1. 正常な値でインスタンスが生成されるか
  2. 異常な値はエラーとなるか

以下にソースコードの例を載せますが、値のパターンが増えてくるとどうしてもテストの関数が増えてきて可読性が落ちていました。また、新しくパターンを追加する際にも基本コピペでいいとはいえ、いたずらに関数を増やしているだけになっており、修正が入った場合の影響範囲も大きくなります。

from pydantic import BaseModel, Field

# 例:1〜5桁の半角英数字のみ許容するクラス
class AlphanumericStr(BaseModel):
    """特定の桁数の半角英数字を受け取るクラス."""
    value: str = Field(default="",pattern=r'^[a-zA-Z0-9]{1,5}$')
import pytest
from pydantic import ValidationError

from program.demo import AlphanumericStr

class TestAlphanumericStr:
    """AlphanumericStrクラスのテスト."""

    # 正常系のテスト
    def test_valid_only_half_case_max(self):
        """半角文字だけのテスト."""
        result = AlphanumericStr(value='abcde')
        assert result.value == 'abcde'

    def test_valid_only_number_max(self):
        """数字だけのテスト."""
        result = AlphanumericStr(value='12345')
        assert result.value == '12345'

    def test_valid_half_case_and_number_max(self):
        """半角英数字のテスト."""
        result = AlphanumericStr(value='abc12')
        assert result.value == 'abc12'

    def test_valid_half_case_and_number(self):
        """半角英数字のテスト."""
        result = AlphanumericStr(value='a12')
        assert result.value == 'a12'

    # エラー系のテスト
    def test_invalid_full_case_and_number(self):
        """全角文字を含むテストケース."""
        with pytest.raises(ValidationError):
            AlphanumericStr(value='あいう123')

    def test_invalid_empty_string(self):
        """空文字列のテストケース."""
        with pytest.raises(ValidationError):
            AlphanumericStr(value='')

    def test_invalid_special_chars(self):
        """特殊文字を含むテストケース."""
        with pytest.raises(ValidationError):
            AlphanumericStr(value='abc#123')

    def test_invalid_too_long(self):
        """最大長を超えるテストケース."""
        with pytest.raises(ValidationError):
            AlphanumericStr(value='abcdef123456')

    def test_invalid_whitespace(self):
        """空白文字を含むテストケース."""
        with pytest.raises(ValidationError):
            AlphanumericStr(value='abc 123')

parametrize とは

pytest のツールに parametrize というものがあり、これを使用することで複数のケースを一括でテストできるようになります。
テストデータのセットをリストやタプルとして渡すことができ、各パラメータに対してテストを繰り返し実行できます。
今回の例だと、正常系で4つ、以上系で5つのテスト関数を作成していますが、いずれも入力値のパターンが異なるだけで確認したい挙動はそれぞれ同様になります。
これを parametrize を使用して書くと以下のようになります。

import pytest
from pydantic import ValidationError

from program.demo import AlphanumericStr

class TestAlphanumericStr:
    """AlphanumericStrクラスのテスト."""

    @pytest.mark.parametrize("value", [
        'abcde',  # 半角文字だけ
        '12345',  # 数字だけ
        'abc12',  # 半角英数字(最大長)
        'a12',    # 半角英数字
    ])
    def test_valid_cases(self, value):
        """正常系テスト."""
        result = AlphanumericStr(value=value)
        assert result.value == value

    @pytest.mark.parametrize("value", [
        'あいう123',      # 全角文字を含む
        '',              # 空文字列
        'abc#123',       # 特殊文字を含む
        'abcdef123456',  # 最大長を超える
        'abc 123',       # 空白文字を含む
    ])
    def test_invalid_cases(self, value):
        """異常系テスト."""
        with pytest.raises(ValidationError):
            AlphanumericStr(value=value)

実行結果

test_python_code/tests/test_parametrize.py::test_valid_cases[abcde] PASSED
test_python_code/tests/test_parametrize.py::test_valid_cases[12345] PASSED
test_python_code/tests/test_parametrize.py::test_valid_cases[abc12] PASSED
test_python_code/tests/test_parametrize.py::test_valid_cases[a12] PASSED
test_python_code/tests/test_parametrize.py::test_invalid_cases[\u3042\u3044\u3046123] PASSED
test_python_code/tests/test_parametrize.py::test_invalid_cases[] PASSED
test_python_code/tests/test_parametrize.py::test_invalid_cases[abc#123] PASSED
test_python_code/tests/test_parametrize.py::test_invalid_cases[abcdef123456] PASSED
test_python_code/tests/test_parametrize.py::test_invalid_cases[abc 123] PASSED

テストロジックとテストデータが分離され、可読性が向上しています!
さらに、テストパターンを増やしたい場合は、parametrize の引数のデータセットに追加するだけで良いので、メンテナンス性も改善されています。

今回はシンプルな入力値のテストでしたが、入力値に対して変換等の処理が発生する場合、入力値に対して期待値が異なるケースがあると思います。
その場合は、タプルで引数を渡すことで解決できます。

import pytest

@pytest.mark.parametrize("input_a, input_b, expected", [
    (1, 2, 3),
    (5, 5, 10),
    (-1, -1, -2),
    (0, 0, 0),
    (-1, 1, 0)
])
def test_add(input_a, input_b, expected):
    assert add(input_a, input_b) == expected

まとめ

pytest の parametrize の紹介でした。
可読性やメンテナンス性の課題感をまるっと解決できる便利なツールですので、ぜひ使ってみてください!
特に、タプルで引数を複数設定できるので、この辺りはうまく活用でしていきたいですね!

Discussion