parametrize — 1つのテストで多数のケースを検証する
コピペしたテストやテスト内のループを@pytest.mark.parametrizeに置き換える方法を学びます。テストID、ids=とpytest.param、デコレータの重ね合わせ、戻り値とエラーを1つのテーブルにまとめる方法、そしてよくあるミスについて解説します。
どうやって学びますか?
解決する問題
試験の点数を成績(グレード)に変換する小さな関数grading.pyがあります:
def grade(score):
if not 0 <= score <= 100:
raise ValueError(f"score must be between 0 and 100, got {score}")
if score >= 80:
return "A"
if score >= 60:
return "B"
if score >= 40:
return "C"
return "F"$ python3 -c "from grading import grade; print(grade(95), grade(79), grade(40), grade(0))"
A B C Fこの関数を適切にテストするには、多くの点数、特に境界となる値(80と79、60と59、40と39など)を検証する必要があります。誰もが最初に書く方法は、テストをコピーして2つの値を変更することです:
from grading import grade
def test_grade_95():
assert grade(95) == "A"
def test_grade_79():
assert grade(79) == "B"
def test_grade_40():
assert grade(40) == "C"$ pytest -q test_copy.py
... [100%]
3 passed in 0.01sこれは動作しますが、規模が大きくなると破綻します。3つの関数はどれも同じこと(「この点数ならこの成績になる」)を主張しており、異なっているのはわずか2つの値だけです。ケースが10個増えれば、同期を取り続けなければならないコピーがさらに10個増えることになります。
そこで次に思いつくのが、ケースのリストを用意して1つのテストの中でループを回す方法です。以下のコードでは、あえて2つの期待される成績を間違えています(79はB、40はCであるべきです):
from grading import grade
def test_grades():
cases = [(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")]
for score, expected in cases:
assert grade(score) == expected$ pytest -q --tb=short test_loop.py
F [100%]
=================================== FAILURES ===================================
_________________________________ test_grades __________________________________
test_loop.py:7: in test_grades
assert grade(score) == expected
E AssertionError: assert 'B' == 'C'
E
E - C
E + B
=========================== short test summary info ============================
FAILED test_loop.py::test_grades - AssertionError: assert 'B' == 'C'
1 failed in 0.01sこのレポートは、一見した印象以上に問題があります。間違っているケースは2つあったのに、報告されたのは1つだけです。最初に失敗したassertでテストが終了してしまうため、40のケースは実行すらされませんでした。また、レポートにはどの点数が失敗したのかが記載されておらず、'B' == 'C'が79の結果であることは自分で突き止めなければなりません。さらに「1 failed」という表示は、5つのケースをまとめて1つの事実としてカウントしてしまっています。1つ目を修正して再実行して初めて、2つ目の誤りに直面することになります。
私たちが本当に求めているのは、ループの簡潔さと、コピーしたテストのような詳細なレポートの両立です。つまり、関数は1つでありながら、複数の独立したテストとして実行されることです。
この章を終える頃には
@pytest.mark.parametrizeを使って1つのテスト関数で多数のケースを実行できるようになる- 1ケースに複数の値を渡し、pytestが生成するテストIDを読み解けるようになる
ids=やpytest.param(..., id=...)を使ってケースに名前を付けられるようになる- 他のケースに影響を与えることなく、単一のケースに
xfailやskipを設定できるようになる - 2つのparametrizeデコレータを組み合わせて、値のすべてのペアをテストできるようになる
- 「値を返すケース」と「エラーを送出するケース」を1つのテーブルに混在させられるようになる
- エラーメッセージからparametrizeのよくあるミスを見抜けるようになる
前提条件: `pytest.approx`による浮動小数点数の比較。
テストを書く前に
パラメータ化(parametrize)とは、テーブル(表)を書き出すための手法です。したがって、テストコードを書く前にやるべき作業は、そのテーブルを組み立て、そもそもテーブルという形式が適しているかどうかを判断することです。
1. 保証内容(契約)を明確にする。 grade(score)の仕様は次の通りです。0から100までの整数が入力され、"A"、"B"、"C"、"F"のいずれかが出力されます。0〜100の範囲外の値が渡された場合は、メッセージにその点数を含めたValueErrorを送出します。この関数には副作用(ファイルの操作や出力など)がないため、セットアップやクリーンアップを行う必要はありません。これこそがテーブル形式に最適な理由です。すべてのケースが「これらの入力に対して、この結果」という関係に収まります。
2. 環境を確認する。 仮想環境がアクティブになっており、pytestがインストールされていて、テストファイルからgrading.pyがインポート可能である必要があります(ここでの例では、テストファイルと同じフォルダに配置されています)。それ以外に必要なものはありません。parametrizeはpytestに標準で組み込まれており、contextlibは標準ライブラリに含まれています。
3. ケースを設計する。 最も重要なのは境界値です。なぜなら、>=と>を取り違えるミスは境界で起きるからです。それぞれのルールについて、境界線の両側の値をテストケースに設定します:
| ケース | 入力 | 期待値 | |---|---|---| | 範囲の上限 | 100 | "A" | | Aの最低点 | 80 | "A" | | Bの最高点 | 79 | "B" | | Bの最低点 | 60 | "B" | | Cの最高点 | 59 | "C" | | Cの最低点 | 40 | "C" | | Fの最高点 | 39 | "F" | | 範囲の下限 | 0 | "F" | | 範囲の直下 | -1 | ValueError | | 範囲の直上 | 101 | ValueError |
各行が1つのケースになります。「ケース」列は単なる飾りではありません。あとでテストIDになるため、テストが失敗したときにどのルールが破られたのかを教えてくれます。
4. テストしないものを決める。 0から100までのすべての点数をテストする必要はありません。95や85は80と同じルールをテストしているに過ぎず、新たな情報をもたらさないまま実行時間だけを増やすことになります。各境界の両側の値を1つずつ用意すれば十分です。Python自体の比較演算子をテストする必要はありません。また、単にテーブルに収まるからという理由だけで、2つの異なる振る舞いを1つのテストに詰め込んではいけません。行ごとに異なるアサーションが必要になるなら、それらは別のテストに分けるべきです。
この章の以降の部分では、1つずつツールを使いながら、このテーブルをコードに落とし込んでいきます。
1つの引数に複数の値
デコレータは、引数の名前と、その引数に渡す値のリストを受け取ります。pytestは値ごとに1つのテストを作成し、その名前で値を関数に渡します:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [80, 95, 100])
def test_top_grade(score):
assert grade(score) == "A"$ pytest -v test_one.py
collecting ... collected 3 items
test_one.py::test_top_grade[80] PASSED [ 33%]
test_one.py::test_top_grade[95] PASSED [ 66%]
test_one.py::test_top_grade[100] PASSED [100%]
============================== 3 passed in 0.01s ===============================関数は1つですが、テストは3つあり、それぞれに専用の行と名前が付きます。角括弧の部分は値から組み立てられたテストIDです。scoreはフィクスチャではなく、デフォルト値もありません。デコレータ文字列内の名前が、テスト関数のパラメータ名と一致していればよいだけです。
1ケースに複数の引数
ほとんどのケースでは、入力と期待される結果の両方が必要になります。両方の名前をカンマで区切って指定し、各ケースを同じ順序のタプルとして記述します。先ほどのループのコードを、同じ2つの間違った期待値を含めて書き直してみましょう:
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")],
)
def test_grade(score, expected):
assert grade(score) == expected$ pytest -q --tb=short test_param.py
..FF. [100%]
=================================== FAILURES ===================================
_______________________________ test_grade[79-C] _______________________________
test_param.py:11: in test_grade
assert grade(score) == expected
E AssertionError: assert 'B' == 'C'
E
E - C
E + B
_______________________________ test_grade[40-D] _______________________________
test_param.py:11: in test_grade
assert grade(score) == expected
E AssertionError: assert 'C' == 'D'
E
E - D
E + C
=========================== short test summary info ============================
FAILED test_param.py::test_grade[79-C] - AssertionError: assert 'B' == 'C'
FAILED test_param.py::test_grade[40-D] - AssertionError: assert 'C' == 'D'
2 failed, 3 passed in 0.01sループのときと比べてみてください。すべてのケースが実行されました。どちらの失敗も、test_grade[79-C]のようにケースを表すIDとともに報告されています。カウントも正確で、2 failed, 3 passedとなっています(デフォルトの長いトレースバックを使用した場合、pytestは失敗した各ケースの引数score = 79, expected = 'C'も出力します)。
引数名は、"score,expected"や"score, expected"のように1つの文字列として書くことも(カンマの後のスペースは許容されます)、文字列のリスト["score", "expected"]として書くこともできます。どちらもまったく同じテストを生成します。文字列で書くのが一般的ですが、プログラムによって引数名を動的に組み立てる場合にはリストが便利です。
テストIDが作られる仕組み
数値、文字列、真偽値、Noneの場合、pytestは値そのものをIDに埋め込み、1つのケースの各値を-で繋ぎます。それ以外のもの(リスト、辞書、オブジェクトなど)に対しては、中身を展開しようとはしません。引数名にそのケースの位置(インデックス)を付けた文字列を使用します:
import pytest
@pytest.mark.parametrize(
["value", "expected"],
[
(1.5, True),
("hello world", None),
([1, 2], {"a": 1}),
],
)
def test_show_ids(value, expected):
pass$ pytest -v test_ids_auto.py
collecting ... collected 3 items
test_ids_auto.py::test_show_ids[1.5-True] PASSED [ 33%]
test_ids_auto.py::test_show_ids[hello world-None] PASSED [ 66%]
test_ids_auto.py::test_show_ids[value2-expected2] PASSED [100%]
============================== 3 passed in 0.01s ===============================value2-expected2は、「0から数えて2番目のケースのvalueとexpected」を意味します。一意ではありますが、内容については何も教えてくれません。このようなケースが失敗したときは、リストを上から数えて該当するケースを探さなければなりません。これこそが、ケースに自分で名前を付けるべき理由です。
ケースに名前を付ける: ids=
ids=に各ケースに対応する文字列のリストを同じ順序で渡します:
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[(80, "A"), (79, "B"), (40, "C"), (39, "F")],
ids=["lowest-A", "highest-B", "lowest-C", "highest-F"],
)
def test_grade_boundaries(score, expected):
assert grade(score) == expected$ pytest -v test_ids_list.py
collecting ... collected 4 items
test_ids_list.py::test_grade_boundaries[lowest-A] PASSED [ 25%]
test_ids_list.py::test_grade_boundaries[highest-B] PASSED [ 50%]
test_ids_list.py::test_grade_boundaries[lowest-C] PASSED [ 75%]
test_ids_list.py::test_grade_boundaries[highest-F] PASSED [100%]
============================== 4 passed in 0.01s ===============================これで、出力結果を見れば各ケースがなぜ存在するのかが分かるようになりました。この方法の弱点は、名前がケース本体とは離れた別のリストにあることです。途中にケースを挿入して名前の追加を忘れると、それ以降のすべての名前が1つずつずれてしまいます。
ids=には関数を渡すこともできます。pytestは各ケースに対してではなく、それぞれの値に対してその関数を1回呼び出し、結果を-で連結します:
import pytest
from grading import grade
def describe(value):
if isinstance(value, int):
return f"score={value}"
return f"grade={value}"
@pytest.mark.parametrize("score,expected", [(80, "A"), (79, "B")], ids=describe)
def test_grade_named(score, expected):
assert grade(score) == expected$ pytest -v test_ids_func.py
collecting ... collected 2 items
test_ids_func.py::test_grade_named[score=80-grade=A] PASSED [ 50%]
test_ids_func.py::test_grade_named[score=79-grade=B] PASSED [100%]
============================== 2 passed in 0.01s ===============================関数を使う方法は、オブジェクトのように自動生成されたIDが役に立たない値に適しています。関数がある値に対してNoneを返した場合、pytestはその値に対して自動生成のIDを使用します。
ケースごとに個別設定する: pytest.param
pytest.paramは1つのケースをラップし、値のすぐ隣に配置できるid=や、そのケースにのみ適用されるmarks=などを付加できるようにします。
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[
pytest.param(100, "A", id="perfect"),
pytest.param(60, "B", id="lowest-B"),
pytest.param(59.5, "B", id="round-up", marks=pytest.mark.xfail(reason="no rounding yet")),
pytest.param(0, "F", id="zero", marks=pytest.mark.skip(reason="example of skip")),
],
)
def test_grade_cases(score, expected):
assert grade(score) == expected$ pytest -v test_param_obj.py
collecting ... collected 4 items
test_param_obj.py::test_grade_cases[perfect] PASSED [ 25%]
test_param_obj.py::test_grade_cases[lowest-B] PASSED [ 50%]
test_param_obj.py::test_grade_cases[round-up] XFAIL (no rounding yet) [ 75%]
test_param_obj.py::test_grade_cases[zero] SKIPPED (example of skip) [100%]
=================== 2 passed, 1 skipped, 1 xfailed in 0.01s ====================round-upのケースは、「59.5は繰り上げてBにすべきか?」というまだ決まっていない仕様を記録しつつ、テスト全体の実行を失敗させずに済みます。他のケースには何の影響もありません(xfailとskipについては後の章で詳しく扱いますが、現時点ではマークを特定のケースだけに適用できることだけ知っておいてください)。
IDがわかりやすい名前になっているため、特定のケースだけを単独で実行することもできます: pytest "test_param_obj.py::test_grade_cases[perfect]"。シェルにとって角括弧は特殊な意味を持つため、クォートで囲むのを忘れないでください。
2つのデコレータ: すべての組み合わせ
2つのparametrizeデコレータを重ねると、pytestは値のすべての組み合わせ(直積)に対してテストを実行します。2種類の通貨 × 3つの金額で、6つのテストになります:
import pytest
@pytest.mark.parametrize("currency", ["USD", "EUR"])
@pytest.mark.parametrize("amount", [0, 10, 999])
def test_format(amount, currency):
text = f"{amount} {currency}"
assert text.endswith(currency)$ pytest -v test_stack.py
collecting ... collected 6 items
test_stack.py::test_format[0-USD] PASSED [ 16%]
test_stack.py::test_format[0-EUR] PASSED [ 33%]
test_stack.py::test_format[10-USD] PASSED [ 50%]
test_stack.py::test_format[10-EUR] PASSED [ 66%]
test_stack.py::test_format[999-USD] PASSED [ 83%]
test_stack.py::test_format[999-EUR] PASSED [100%]
============================== 6 passed in 0.01s ===============================関数に最も近いデコレータが、テストIDの最初の部分を提供します。デコレータを重ねるのは、入力同士が真に独立している場合(すべての金額があらゆる通貨で動作するべき場合など)だけにしてください。特定のペアのみが意味を持つ場合は、1つのデコレータの中でそれらのペアをタプルとして並べます。また、掛け算によるテスト数の増加にも注意してください。10個の値を持つデコレータを3つ重ねると、1,000個のテストになります。
戻り値とエラーを1つのテーブルにまとめる
ケースが「この値を返す」と「この例外を送出する」に分かれることがあります。その場合、期待値そのものをパラメータにして、with文で処理することができます:
from contextlib import nullcontext
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expectation",
[
(50, nullcontext("C")),
(100, nullcontext("A")),
(-1, pytest.raises(ValueError)),
(101, pytest.raises(ValueError, match="got 101")),
],
)
def test_grade_or_error(score, expectation):
with expectation as expected:
assert grade(score) == expected$ pytest -v test_raises.py
collecting ... collected 4 items
test_raises.py::test_grade_or_error[50-expectation0] PASSED [ 25%]
test_raises.py::test_grade_or_error[100-expectation1] PASSED [ 50%]
test_raises.py::test_grade_or_error[-1-expectation2] PASSED [ 75%]
test_raises.py::test_grade_or_error[101-expectation3] PASSED [100%]
============================== 4 passed in 0.01s ===============================contextlib.nullcontextは何もしないコンテキストマネージャです。with nullcontext("C") as expectedは単にexpected = "C"を設定するだけなので、通常ケースでは普段通りにassertが実行されます。エラーのケースではpytest.raisesがコンテキストマネージャになります。その内部でgradeが例外を送出し、assertは最後まで実行されず、正しいエラーが発生したことでテストはパスします。もしエラーが発生しなければ、おなじみのDID NOT RAISEが表示されます。
これは短いテーブルであればすっきりとまとまります。もし2種類を区別するためにテスト本体にif文が必要になり始めたら、2つのテストに分割してください。また、テストIDにも注目してください。expectation0は先ほど説明したオブジェクトの命名ルールによるものです。このあとの完全な例では、pytest.paramを使ってこれを改善します。
クラスをパラメータ化する
テストクラスにデコレータを付けると、そのクラス内のすべてのテストメソッドがそのパラメータを受け取ります:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [80, 100])
class TestTopBand:
def test_is_a(self, score):
assert grade(score) == "A"
def test_is_a_string(self, score):
assert isinstance(grade(score), str)$ pytest -v test_class.py
collecting ... collected 4 items
test_class.py::TestTopBand::test_is_a[80] PASSED [ 25%]
test_class.py::TestTopBand::test_is_a[100] PASSED [ 50%]
test_class.py::TestTopBand::test_is_a_string[80] PASSED [ 75%]
test_class.py::TestTopBand::test_is_a_string[100] PASSED [100%]
============================== 4 passed in 0.01s ===============================2つのメソッド × 2つの値で、4つのテストになります。クラス内のすべてのメソッドがscoreを受け取る必要があり、そうでなければコレクションに失敗します。
完全な例
shipping.py:
import math
RATES = {"local": 50, "national": 120}
def shipping_cost(weight_kg, zone):
if zone not in RATES:
raise KeyError(f"unknown zone: {zone}")
if weight_kg <= 0:
raise ValueError("weight must be positive")
# The first kilogram is in the base rate; each started kilogram after it costs 20.
return RATES[zone] + (math.ceil(weight_kg) - 1) * 20$ python3 -c "from shipping import shipping_cost; print(shipping_cost(0.5, 'local'), shipping_cost(1.2, 'local'), shipping_cost(3, 'national'))"
50 70 160test_shipping.py:
from contextlib import nullcontext
import pytest
from shipping import shipping_cost
@pytest.mark.parametrize(
"weight_kg,zone,expected",
[
pytest.param(0.5, "local", 50, id="light-local"),
pytest.param(1, "local", 50, id="exactly-1kg"),
pytest.param(1.2, "local", 70, id="part-kg-rounds-up"),
pytest.param(3, "national", 160, id="heavy-national"),
],
)
def test_cost(weight_kg, zone, expected):
assert shipping_cost(weight_kg, zone) == expected
@pytest.mark.parametrize("zone", ["local", "national"])
@pytest.mark.parametrize("weight_kg", [0.1, 0.5, 1])
def test_first_kg_is_flat(weight_kg, zone):
assert shipping_cost(weight_kg, zone) == shipping_cost(1, zone)
@pytest.mark.parametrize(
"weight_kg,zone,expectation",
[
pytest.param(2, "local", nullcontext(70), id="ok"),
pytest.param(0, "local", pytest.raises(ValueError), id="zero-weight"),
pytest.param(-1, "local", pytest.raises(ValueError), id="negative"),
pytest.param(1, "moon", pytest.raises(KeyError, match="moon"), id="bad-zone"),
],
)
def test_cost_or_error(weight_kg, zone, expectation):
with expectation as expected:
assert shipping_cost(weight_kg, zone) == expected$ pytest -v test_shipping.py
collecting ... collected 14 items
test_shipping.py::test_cost[light-local] PASSED [ 7%]
test_shipping.py::test_cost[exactly-1kg] PASSED [ 14%]
test_shipping.py::test_cost[part-kg-rounds-up] PASSED [ 21%]
test_shipping.py::test_cost[heavy-national] PASSED [ 28%]
test_shipping.py::test_first_kg_is_flat[0.1-local] PASSED [ 35%]
test_shipping.py::test_first_kg_is_flat[0.1-national] PASSED [ 42%]
test_shipping.py::test_first_kg_is_flat[0.5-local] PASSED [ 50%]
test_shipping.py::test_first_kg_is_flat[0.5-national] PASSED [ 57%]
test_shipping.py::test_first_kg_is_flat[1-local] PASSED [ 64%]
test_shipping.py::test_first_kg_is_flat[1-national] PASSED [ 71%]
test_shipping.py::test_cost_or_error[ok] PASSED [ 78%]
test_shipping.py::test_cost_or_error[zero-weight] PASSED [ 85%]
test_shipping.py::test_cost_or_error[negative] PASSED [ 92%]
test_shipping.py::test_cost_or_error[bad-zone] PASSED [100%]
============================== 14 passed in 0.01s ==============================3つの関数で14個のテストがあり、どの行も何を検証したのかを明確に示しています。注目すべき点が3つあります。
第1に、test_costのIDは各ケースの理由を示しています。part-kg-rounds-upは守るべきルールそのものを表しており、1.2-local-70というIDではそれが分かりません。
第2に、重量と地域が真に独立しているため、test_first_kg_is_flatではデコレータを重ねています。また、数値をハードコードするのではなく関数自体の結果同士を比較しているため、基本料金が改定されてもテストは壊れません。
第3に、pytest.param(..., id=...)を使うことで、expectation2のような無機質なIDがnegativeに変わりました。値とエラーのテーブルが、まるで仕様書のように読めるようになっています。
問題が起きたとき
function uses no argument 'scores' デコレータ文字列に指定した名前が、関数のパラメータのいずれとも一致していません(ここではdef test_grade(score, expected)に対して"scores,expected"と指定しています)。テストが1つも実行される前のコレクション段階で失敗します:
import pytest
from grading import grade
@pytest.mark.parametrize("scores,expected", [(80, "A"), (40, "C")])
def test_grade(score, expected):
assert grade(score) == expected$ pytest -q test_mismatch.py
==================================== ERRORS ====================================
______________________ ERROR collecting test_mismatch.py _______________________
In test_mismatch.py::test_grade: function uses no argument 'scores'
=========================== short test summary info ============================
ERROR test_mismatch.py - Failed: In test_mismatch.py::test_grade: function us...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sfixture 'expected' not found その逆のパターンです。関数にデコレータで指定されていないパラメータがあるため、pytestはその名前のフィクスチャを探しに行き、見つからずに失敗します。デコレータ文字列にその名前を追加してください。
the number of names (2) ... must be equal to the number of values (1) あるケースの値の個数が間違っています(例えば"score,expected"に対してリスト内に(40,)がある場合など):
$ pytest -q test_count.py
==================================== ERRORS ====================================
________________________ ERROR collecting test_count.py ________________________
test_count.py::test_grade: in "parametrize" the number of names (2):
['score', 'expected']
must be equal to the number of values (1):
(40,)[score0]のようなIDと、タプルに関するTypeError 引数名が1つだけの場合、リスト内の各値はそのまま渡されます。習慣でタプルを書いてしまうと、関数はタプルを受け取ることになります:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"$ pytest -v --tb=no test_tuples.py
collecting ... collected 2 items
test_tuples.py::test_top_grade[score0] FAILED [ 50%]
test_tuples.py::test_top_grade[score1] FAILED [100%]
=========================== short test summary info ============================
FAILED test_tuples.py::test_top_grade[score0] - TypeError: '<=' not supported...
FAILED test_tuples.py::test_top_grade[score1] - TypeError: '<=' not supported...
============================== 2 failed in 0.01s ===============================テストIDが手がかりになります。通常の数値であれば[80]になっていたはずなので、[score0]となっているのは値が別の何かであったことを示しています。引数が1つなら、[80, 95]と書いてください。
単独で実行するとパスするのに、他のテストと一緒に実行すると失敗する パラメータの値はモジュールのコレクション時に1度だけ作成され、それを受け取るすべてのテストに同じオブジェクトが渡されます。あるテストがミュータブルな値(リストや辞書など)を変更すると、次のテストはその変更された値を受け取ることになります:
import pytest
@pytest.mark.parametrize("cart", [[]])
class TestCart:
def test_add_item(self, cart):
cart.append("pen")
assert cart == ["pen"]
def test_starts_empty(self, cart):
assert cart == []$ pytest -q --tb=short test_shared.py
.F [100%]
=================================== FAILURES ===================================
______________________ TestCart.test_starts_empty[cart0] _______________________
test_shared.py:11: in test_starts_empty
assert cart == []
E AssertionError: assert ['pen'] == []
E
E Left contains one more item: 'pen'
E Use -v to get more diff
=========================== short test summary info ============================
FAILED test_shared.py::TestCart::test_starts_empty[cart0] - AssertionError: a...
1 failed, 1 passed in 0.01sタプル()のようなイミュータブルな値を渡し、cart = list(items)のように各テストの内部で新しいオブジェクトを作成してください。そうすれば、各テストが独自の空リストから開始され、両方ともパスします。
ステップ 4 / 6 — 予想する
理解度をチェック
pytest -vを実行したとき、どの4つのテストIDがどのような順序で表示されますか?
import pytest
@pytest.mark.parametrize("b", ["x", "y"])
@pytest.mark.parametrize("a", [1, 2])
def test_pair(a, b):
pass- Atest_pair[1-x], test_pair[2-y]
- Btest_pair[1-x], test_pair[1-y], test_pair[2-x], test_pair[2-y]
- Ctest_pair[x-1], test_pair[y-1], test_pair[x-2], test_pair[y-2]
- Dtest_pair[x-1], test_pair[x-2], test_pair[y-1], test_pair[y-2]
grade(80)とgrade(95)はどちらも"A"を返すのに、両方のテストが失敗します。なぜでしょうか?
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"- AテストIDの`score0`と`score1`が関数名と衝突している
- B値はリストではなくタプルのタプルでなければならない
- C引数名が1つの場合、各値はそのまま渡されるため、`score`は`80`ではなくタプル`(80,)`になる
- D引数名が1つだけの場合、parametrizeは使用できない
ここでは2つの期待値が間違っています(79はB、40はCであるべきです)。pytestは何をレポートしますか?
def test_grades():
cases = [(95, "A"), (79, "C"), (40, "D")]
for score, expected in cases:
assert grade(score) == expected- A2 failed, 1 passed — ケースごとに1行ずつ表示される
- B1 failed — 最初の誤ったケースに対してのみ報告され、40のケースは実行されない
- C1 failed — 最後の誤りである40のケースに対してのみ報告される
- D3 failed — 1つの失敗によってすべてのケースが失敗するため
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
問題点のリストを返す関数check_password(text)を含むpassword.pyを作成してください。8文字未満なら"too short"、数字が含まれていなければ"no digit"、文字が含まれていなければ"no letter"を返します。問題のない適切なパスワードの場合は[]を返します。str以外のものが渡された場合はTypeErrorを送出します。
続いて、以下を含むtest_password.pyを作成してください:
- 各ケースの内容を表す
id=("seven-chars"や"digits-only"など)を付けてpytest.paramで記述された、少なくとも6つのケースを持つパラメータ化テストを1つ - 2つ重ねたデコレータ(3つの有効なパスワード × 2つの末尾文字
""と"!")で、6つすべてが[]を返すことを検証するテスト - 通常ケース(
nullcontext)とTypeErrorのケース(pytest.raises)を混在させた1つのテーブル(後者にはNoneや整数12345678が良い入力になります) - 「大文字が含まれていない」など、まだ実装していないルールに対して
xfailをマークした1つのケース
pytest -vを実行し、まるで他の人が書いたコードであるかのようにIDの一覧を読んでみてください。IDだけを見てパスワードのルールが分かりますか? 分からなければ、分かるようになるまでケースの名前を変更してください。
次に、期待値の1つを意図的に壊して再実行してみてください。ちょうど1行だけが失敗し、そのIDからどのケースであるかが分かり、他のすべてのケースは変わらず実行されたことを確認してください。
解答例
まずは仕様の確認から始め、その後にテーブルを組み立てます。check_passwordはstrを受け取り、見つかったすべての問題のリストを固定の順序で返します。str以外が渡された場合は、何も返さずにTypeErrorを送出します。password.py:
def check_password(text):
if not isinstance(text, str):
raise TypeError(f"password must be a str, got {type(text).__name__}")
problems = []
if len(text) < 8:
problems.append("too short")
if not any(ch.isdigit() for ch in text):
problems.append("no digit")
if not any(ch.isalpha() for ch in text):
problems.append("no letter")
return problems$ python3 -c "from password import check_password as c; print(c('abcdef12'), c('ab1'), c(''))"
[] ['too short'] ['too short', 'no digit', 'no letter']test_password.py:
from contextlib import nullcontext
import pytest
from password import check_password
@pytest.mark.parametrize(
"text,expected",
[
pytest.param("abcdef12", [], id="eight-chars-ok"),
pytest.param("abcde12", ["too short"], id="seven-chars"),
pytest.param("abcdefgh", ["no digit"], id="letters-only"),
pytest.param("12345678", ["no letter"], id="digits-only"),
pytest.param("", ["too short", "no digit", "no letter"], id="empty"),
pytest.param("ab1", ["too short"], id="short-but-mixed"),
pytest.param(
"abcdef12",
["no uppercase"],
id="needs-uppercase",
marks=pytest.mark.xfail(reason="uppercase rule not written yet"),
),
],
)
def test_problems(text, expected):
assert check_password(text) == expected
@pytest.mark.parametrize("suffix", ["", "!"])
@pytest.mark.parametrize("good", ["abcdef12", "pass1234", "9lives9lives"])
def test_good_passwords(good, suffix):
assert check_password(good + suffix) == []
@pytest.mark.parametrize(
"value,expectation",
[
pytest.param("abcdef12", nullcontext([]), id="str-ok"),
pytest.param("short", nullcontext(["too short", "no digit"]), id="str-problems"),
pytest.param(None, pytest.raises(TypeError), id="none"),
pytest.param(12345678, pytest.raises(TypeError, match="int"), id="int"),
],
)
def test_type_or_problems(value, expectation):
with expectation as expected:
assert check_password(value) == expected$ pytest -v test_password.py
collecting ... collected 17 items
test_password.py::test_problems[eight-chars-ok] PASSED [ 5%]
test_password.py::test_problems[seven-chars] PASSED [ 11%]
test_password.py::test_problems[letters-only] PASSED [ 17%]
test_password.py::test_problems[digits-only] PASSED [ 23%]
test_password.py::test_problems[empty] PASSED [ 29%]
test_password.py::test_problems[short-but-mixed] PASSED [ 35%]
test_password.py::test_problems[needs-uppercase] XFAIL (uppercase ru...) [ 41%]
test_password.py::test_good_passwords[abcdef12-] PASSED [ 47%]
test_password.py::test_good_passwords[abcdef12-!] PASSED [ 52%]
test_password.py::test_good_passwords[pass1234-] PASSED [ 58%]
test_password.py::test_good_passwords[pass1234-!] PASSED [ 64%]
test_password.py::test_good_passwords[9lives9lives-] PASSED [ 70%]
test_password.py::test_good_passwords[9lives9lives-!] PASSED [ 76%]
test_password.py::test_type_or_problems[str-ok] PASSED [ 82%]
test_password.py::test_type_or_problems[str-problems] PASSED [ 88%]
test_password.py::test_type_or_problems[none] PASSED [ 94%]
test_password.py::test_type_or_problems[int] PASSED [100%]
======================== 16 passed, 1 xfailed in 0.01s =========================このように書かれている理由:
- 境界値のペアが最初に配置されています。
eight-chars-okとseven-charsは長さルールの両側に位置しており、<と<=の取り違えが起きやすい場所です。他のすべてのルールには、そのルールだけに違反するケースが1つずつ用意されているため、失敗した際にはcheck_passwordのどの行に原因があるかが正確に分かります。 emptyはリストの順序を検証しています。 これは3つのルールすべてに違反する唯一のケースであり、問題が固定の順序で返されることを保証します(ユーザーにメッセージを表示する呼び出し側にとって重要な性質です)。- 期待値は各ケースで新しいリストになっており、テストはそれを読み取るだけです。何も変更(ミューテーション)されないため、「問題が起きたとき」で解説した共有オブジェクトの罠に陥ることはありません。
- デコレータの重ね合わせが
test_good_passwordsに適しています。 末尾文字とパスワードが独立しているためです。有効なパスワードであれば、!があってもなくても有効なままであるべきです。テストIDのabcdef12-やabcdef12-!は説明なしでも十分に読み取れるため、ids=は不要でした。 - エラーテーブルでは
pytest.paramのIDを使用しています。 そうしないと、nullcontextやpytest.raisesオブジェクトがexpectation0やexpectation1と表示されてしまうためです。match="int"によって、単に何らかのTypeErrorが発生したことだけでなく、メッセージに誤った型名が含まれていることも検証しています。 xfailケースは将来のルールを記録しています。 しかもテスト全体の実行を壊すことはありません。大文字のルールが追加されると、このケースはパスし始め、pytestはそれをXPASSとして報告します。これがマークを削除する合図になります。
IDを上から順に読んでいくだけで、8文字以上、数字が1文字以上、英字が1文字以上、文字列のみ受け付ける、大文字チェックを計画中、というルールが浮かび上がってきます。これこそが、テストIDに良い名前が付けられているかどうかの判断基準です。