nikkie-ftnextの日記

イベントレポートや読書メモを発信

pytest.mark.parametrize tips 2選 〜クラスもデコレートできる・複数積んでパラメタの組合せを自動化できる〜

はじめに

退屈なことはPythonにやらせよう1、nikkieです。

Pythonユニットテストフレームワークの1つ、pytest。
pytestはパラメタ化したテストも可能にしてくれるのですが、パラメタ化したテストについて最近知った小さいtipsをアウトプットします!

目次

前提情報

今回の参考文献

動作環境

pytestでパラメタ化したテスト

@pytest.mark.parametrizeというデコレータを使うと、パラメタ化したテスト(parametrized tests)ができます3
ドキュメントの例を見てみましょう。

import pytest


@pytest.mark.parametrize(
    "test_input,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)]
)
def test_eval(test_input, expected):
    assert eval(test_input) == expected

このファイル(test_expectation.py)のあるディレクトリでpytestコマンドを叩くと、3件のテストが実行されます。

collected 3 items

test_expectation.py ..F                                                  [100%]

@pytest.mark.parametrizeによって、test_inputexpectedの組として3パターンが渡されており、test_eval全部で3回実行されました。

  • ("3+5", 8)("2+4", 6)が渡されたときはテストはpass
  • ("6*9", 42)が渡されたときはexpectedが間違っているのでテストはfail
    • (ドキュメントの残りの説明に使うために、わざと間違ったexpectedにしているようです)

このように複数のパラメタ(の組)を渡して書けるのがパラメタ化されたテストです。
def_test_eval_足し算def_test_eval_掛け算のようにテストメソッドの記述を増やさずに済むのがありがたいな〜と感じています。
私の場合、テストのリファクタリングとしてパラメタ化されたテストに切り替えることが多いです。

では、参考文献で知ったtipsを紹介していきます!

💡1️⃣ クラスもデコレートできる

クラスのすべてのテストメソッドに同じデータセットを渡すことができます。(『テスト駆動PythonKindle の位置No.1443-1444)

ドキュメントの例です。

import pytest


@pytest.mark.parametrize("n,expected", [(1, 2), (3, 4)])
class TestClass:
    def test_simple_case(self, n, expected):
        assert n + 1 == expected

    def test_weird_simple_case(self, n, expected):
        assert (n * 1) + 1 == expected

TestClasstest_simple_caseにもtest_weird_simple_caseにも、(n, expected)の組として(1, 2)(3, 4)が渡ります。
なので、結果として合計4件のテストが実行されます。

pytestはTestで始まる名前のクラスのテストメソッドも見つけてくれます。
私は小さい関数やクラスが好きということもあり、クラスを入れ子にして書く4のを試しています。
その中で上のTestClassのようなケースがありました。
クラスを@pytest.mark.parametrizeでデコレートすることで、メソッドごとに繰り返しデコレータを書かずに済んでスッキリしました!

(ドキュメントにはクラスだけでなくモジュールにも適用する例があります)

@pytest.mark.parametrizeの不思議

出来心でやってみたのですが、引数の順番を入れ替えてクラスの中で一貫性を崩しても、何も問題なく動いたぞ...5

@pytest.mark.parametrize("n,expected", [(1, 2), (3, 4)])
class TestClass:
    def test_simple_case(self, n, expected):
        assert n + 1 == expected

-    def test_weird_simple_case(self, n, expected):
+    def test_weird_simple_case(self, expected, n):
        assert (n * 1) + 1 == expected

test_simple_caseは引数がnexpectedの順。
test_weird_simple_caseは引数がexpectednの順です。

@pytest.mark.parametrizeパラメタの名を見ていい感じに処理してるってことなんでしょうか?
仕組みが気になる〜!

💡2️⃣ デコレータを複数積める(自動で組合せてくれる!)

@pytest.mark.parametrizeは、なんと複数積めるんです!
これによりpytestがパラメタの値を全通り組合せてくれます!

ドキュメントの例です。

import pytest


@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
def test_foo(x, y):
    print(f"{x=}", f"{y=}")  # ドキュメントではpass
collected 4 items

test_expectation.py x=0 y=2
.x=1 y=2
.x=0 y=3
.x=1 y=3
.

(x,y)が(0,2),(1,2),(0,3),(1,3)と全通り組み合わさっていますよね!
人が書く必要がなくなってとっても便利!大助かりです。

これが非常にありがたかったのは、引数が多い関数のテストを書くときです。
多くの引数を取ってbool値を返す関数がありました6
説明用の例としてdef f(a, b, c) -> bool:としましょう。
振る舞いは申し分ないですが、将来の開発者のことを考えるとあまりいい構造ではありません。
そこでリファクタリングをするというシーンです。

この関数にはテストがありませんでした。
なので、リファクタリング中に誤りに気付けるようにまずテストを書きます。
引数を同値分割していくと2のN乗のパターンがありました。
ここでaがこれこれのときはbcの値にはよらずに関数fの返り値が常にFalseというのを発見しました7
これをパラメタ化されたテストで表すにあたって、上の例のxとyをbとcに置き換える形で、test_aがこれこれのときはFalseを返すを書いています。
「bとcの組を全部人が書くのか...」と怠惰な私の心が拒否反応を示したのですが、pytestparametrizeで自動化できることを知り、開発が大変はかどりました!

ノードIDについて

なお、pytest -vで確認できるノードIDは、内側の(=下に配置された)parametrizeの引数が先に表示されます。
ドキュメントの例だと[y-x]です。

collected 4 items

test_expectation.py::test_foo[2-0] PASSED                                [ 25%]
test_expectation.py::test_foo[2-1] PASSED                                [ 50%]
test_expectation.py::test_foo[3-0] PASSED                                [ 75%]
test_expectation.py::test_foo[3-1] PASSED                                [100%]

parametrizeデコレータの順を入れ替えると、ノードIDは[x-y]となります。

@pytest.mark.parametrize("y", [2, 3])  # yが上になった
@pytest.mark.parametrize("x", [0, 1])
def test_foo(x, y):
    print(f"{x=}", f"{y=}")
collected 4 items

test_expectation.py::test_foo[0-2] PASSED                                [ 25%]
test_expectation.py::test_foo[0-3] PASSED                                [ 50%]
test_expectation.py::test_foo[1-2] PASSED                                [ 75%]
test_expectation.py::test_foo[1-3] PASSED                                [100%]

ドキュメント「How to parametrize fixtures and test functions」にはまだまだtips満載!

メモとして残します(『テスト駆動Python』での学びも含みます)

  • @pytest.mark.parametrizeids引数でノードIDを指定できる
  • ノードIDで非アスキー文字はデフォルトでエスケープされる
    • ドキュメントのNoteによるとエスケープの無効化設定もあるが、望まない副作用といったリスクもあるそうで、利用は自己責任で
  • @pytest.mark.parametrizeにシーケンスで渡すパラメタの1個1個はpytest.paramにできる
    • pytest.paramにmarksやidを指定できる
      • idはノードIDの指定
    • パラメタ化されたテストの1つ1つのパラメタについてpytest.paramできめ細かく設定できるという理解(素振りしたい)
  • 例から学べるドキュメント(積ん読

終わりに

パラメタ化されたテストに使う@pytest.mark.parametrizeについて知ったtips 2つをアウトプットしました。

  1. クラスもデコレートできる
  2. 複数積んで、自動でパラメタを組合せられる

直近では2に助けられました〜。
また1を実現する仕組みが気になるところです。

ドキュメントを見ていてもpytestにはまだまだ私が知らない機能がある感じで、ちょっとずつ知っている範囲を増やしていければと思っています。

P.S. 積めるのに気づけたのはChatGPTのおかげ!

からあげさんのブログでSIGNATEさんの活用例コンペを知っていたので、「AIとのペアプロ成功例と言えるのでは?」と投稿してみました(上のツイートをリツイートして応援してくれたら嬉しいです)

からあげさん、頑張ってください!

リツイート数で争うの、ちょっと『パリピ孔明』みがありますね。EIKO-san v.s. AZALEA)


  1. そういえば第2版が出たそうです O'Reilly Japan - 退屈なことはPythonにやらせよう 第2版
  2. 監修者のやっとむさんによるトークのレポートがこちら:みんなのPython勉強会#88のやっとむさんによる「手軽なpytestでテストを活用しよう!」、テストコードに関係する知識が結び付き、刺激的でした #stapy - nikkie-ftnextの日記
  3. unittestモジュールに経験がある向きにお伝えすると、ここで紹介する@pytest.mark.parametrizeは、厳密には動きに差があるのですが、subTestのようなものです
  4. printデバッグ+pytest -sで確認してもnには1と3が渡っています
  5. 『Clean Code』3章にある「さまざまな引数の組み合わせを網羅するテストケースを書く難しさ(Kindle の位置No.1424-1425)」を体感しました。組合せが爆発してしまっていて、すべての組合せを網羅するのはマジ困難!(ここで紹介したpytestのtipsがなかったら絶望していたでしょう)
  6. テストを先に書いていたら、そもそも多くの引数を持たなかった、なので、同値分割してテストを書くのも不要だったのではないかと思われます(一度一緒になったものを引き剥がすのはこんなにも大変ということですね)