マーカー、skipとxfail — どのテストをどこで実行するか
skip、skipif、importorskip、xfailで、pytestに各テストが必要なことと期待を伝えます。strict=True、raises=、XPASSが意味すること。独自のマーカーを登録して-mで選択。パラメータ化フィクスチャとファクトリーフィクスチャも扱います。
どうやって学びますか?
解決する問題
小さなプロジェクトshopがあり、1つのモジュールと2つのテストファイルがあります。money.py:
def parse_price(text: str) -> float:
return float(text.replace(",", ""))
def split_bill(total: float, people: int) -> float:
return round(total / people, 2)test_money.pyは素の値で確認します。test_export.pyはYAMLから価格を読み込みます。これはサードパーティのyamlパッケージが必要です:
import yaml
from money import parse_price
def test_prices_from_yaml():
data = yaml.safe_load("pen: '15.00'")
assert parse_price(data["pen"]) == 15.0yamlがインストールされていないマシンでは、pytest -qはこう出します:
==================================== ERRORS ====================================
_______________________ ERROR collecting test_export.py ________________________
ImportError while importing test module '/home/you/shop/test_export.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_export.py:1: in <module>
import yaml
E ModuleNotFoundError: No module named 'yaml'
=========================== short test summary info ============================
ERROR test_export.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13s最後の2行を見てください。Interrupted — 中断です。test_money.pyの2つの良いテストは一度も実行されませんでした。このマシンでは実行できなかった1つのファイルが、実行全体を持ち去ったのです。
これが、十分に長く続くあらゆるプロジェクトで現れる現実です。あるテストはオプションのライブラリを必要とします。あるテストはLinuxでのみ意味を持ちます。あるテストはCIだけが持つAPIキーを必要とします。あるテストは誰もが知っていて、まだ誰も修正していないバグを記述しています。あるテストは1分かかり、ファイルを保存するたびに1分待つのはいやです。
それらのテストを削除するのは間違いで、コメントアウトするのも同じく間違いです。あなたが欲しいのは、pytestに各テストが必要とすることと、あなたがそれに期待することを伝え、実行ごとにpytest自身にどうするかを決めさせることです。そのための道具がマーカーです。
この章を終える頃には
- 常に、条件によって、あるいはテストの内部からテストをスキップし、理由を見えるようにできるようになる
- オプションのパッケージがないとき、
pytest.importorskipでファイル全体をスキップできるようになる - 既知のバグを
xfailでマークし、strict=Trueとraises=でそれを正確にできるようになる - レポートの文字
s、x、Xを読み、-rs、-rx、-rAで詳細を要求できるようになる - 自分だけのマーカーを発明し、登録し、
--strict-markersでタイポを捕まえるようになる -m "not slow"と-m "slow and db"でテストを選択できるようになる- すべてのテストを何回も実行させるフィクスチャと、関数を返すフィクスチャを書けるようになる
前提条件: モック(Mock) — 外部サービスがどのように呼び出されたかを確認する。
テストを書く前に
マーカーは、テストが何を確認するかの話ではありません — それは前の章で扱いました。マーカーが扱うのは、テストが書かれる前に答える2つの質問であり、そして多くの人が実行が誤った理由で赤くなった後でしか答えられない2つの質問です。
1. このテストが実行されるために、何が必要ですか? 必須ではない依存関係のパッケージ、オペレティングシステム、Pythonのバージョン、秘密情報を保持する環境変数、データベース、ネットワーク、大量の時間。これらのそれぞれが前提条件です。それが欠けているとき、テストは何も言うことがありません。パスもしていないし、失敗もしていないのです。
2. 今日はそれに何を期待していますか? 通常はパスすることです。しかし、コードに既知のバグがあり、正しい振る舞いを記述するテストを書いているなら、失敗することを期待しています — そして特定の1つの方法で失敗することを。
答えがマーカーを決めるので、まず書き出しましょう。shopプロジェクトの計画はこうなります:
| ケース | 入力 / 状況 | 必要なもの | 期待 | マーカー | |---|---|---|---|---| | 素の価格 | "1,250.50" | なし | 1250.5 | なし | | YAMLエクスポート | "pen: '15.00'" | yamlパッケージ | インストール済みなら15.0 | pytest.importorskip("yaml") | | POSIXパス | "data", "prices.csv" | Windowsではないこと | "data/prices.csv" | skipif(sys.platform == "win32") | | 為替レートのライブ取得 | 実際のAPI | RATES_API_KEYが設定済み | キーがある場所でのみレート | テスト内部のpytest.skip() | | 通貨記号 | "$1,250" | なし | 1250.0 — 今日はValueError(バグ#42) | xfail(raises=ValueError, strict=True) | | 大量の価格 | 1000個の文字列 | 1秒以上 | 正しい合計 | slow(カスタム) |
この表は列ごとに読みます。必要なものの列からスキップが生まれます。期待の列からxfailが生まれます。かかる時間から、何を実行するかを選べるカスタムマーカーが生まれます。
このどれもが動く前に、用意しておくべきもの:
pytestがインストールされた仮想環境と、テストからmoney.pyをインポートできること(前の章と同様に、プロジェクトのルートで実行します);- プロジェクトのルートにある
pyproject.toml— カスタムマーカーはそこに登録するからです; - バグに番号を付けてどこかに書き出しておくこと。そうすれば
reason=でそれを指せるからです。
そして、マークしてはいけないもの:
- 失敗したからといって、理由も分からずにテストをスキップしないでください。 スキップは、その後のすべての実行から失敗を隠します。修正するか、あるいは
xfail(raises=...)を理由付きで書けるほど十分に理解してください。 - フレーキーなテストに
xfailを付けないでください。 ときどきパスするテストは、ある日はX、別の日はxと表示され、strict=Trueではランダムに失敗します。フレークさはテスト自身のバグです。それを修正してください。 - すべてに
slowラベルを付けないでください。 スイートの大部分が毎回選択から外れるなら、日常の実行は何も確認していません。実際に数秒かかる少数のテストだけにラベルを付けてください。 - pytestが正しくスキップすることをテストしないでください。 テスト対象は
money.pyであって、pytestではありません。マーカーは設定です。実行レポートが、それが動くことの十分な証拠です。
この章の残りは、この表を1行ずつ実装します。
skip — 実行しない、理由付きで
まず、誰もマーカーを知らない間の一般的な方法 — 早いreturnです:
import os
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
return
assert False, "would call the real API here". [100%]
1 passed in 0.07sパスしました。テストは何も確認しておらず、レポートは成功したと言っています。キーのないノートパソコンでは、これは毎回の実行で行われる嘘で、緑のドットは本物と区別がつきません。修正は、条件を避けることではなく、それを正直に — スキップとして — 報告することです。
最も単純なマーカーは「これを実行しない」と言い、理由を付けるデコレータです:
import os
import sys
import pytest
from money import parse_price, split_bill
@pytest.mark.skip(reason="rounding rules not agreed yet")
def test_split_rounding():
assert split_bill(100.0, 3) == 33.34
@pytest.mark.skipif(sys.platform == "win32", reason="uses a POSIX path")
def test_posix_path():
assert os.path.join("data", "prices.csv") == "data/prices.csv"
@pytest.mark.skipif(sys.version_info < (3, 13), reason="needs Python 3.13")
def test_new_feature():
assert parse_price("10") == 10.0
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
assert False, "would call the real API here"s.ss [100%]
1 passed, 3 skipped in 0.09s4つのテスト、4つの異なる判断の方法:
@pytest.mark.skip(reason=...)— 常にスキップされます。本体は一度も実行されません。@pytest.mark.skipif(condition, reason=...)— 条件が真のときにスキップされます。条件はファイルの収集時に評価されます。今回の実行はLinux上だったので、test_posix_pathは実行されてパスしました。Pythonは3.12だったので、test_new_featureはスキップされました。- テストの内部で呼ばれる
pytest.skip("...")— 判断はテストの実行中に行われます。半分まで進んでからでないと分からない場合に使います。たとえば、環境やフィクスチャの戻り値を見てからの場合です。
進捗行の各sは、スキップされたテストです。スキップは失敗ではありません。実行はグリーンです。
しかし理由がどこにも見えません。デフォルトでは、pytestは失敗とエラーだけを要約します。-rsでスキップを要求しましょう:
s.ss [100%]
=========================== short test summary info ============================
SKIPPED [1] test_skips.py:9: rounding rules not agreed yet
SKIPPED [1] test_skips.py:19: needs Python 3.13
SKIPPED [1] test_skips.py:26: RATES_API_KEY is not set
1 passed, 3 skipped in 0.10sだからreasonが重要なのです。3ヶ月後、「スキップ」ただそれだけでは、そのテストをもう一度有効にできるのか、誰にも分かりません。
importorskip — ファイル全体が必要とするパッケージがあるとき
test_export.pyに戻ります。import yamlをこれに置き換えます:
import pytest
from money import parse_price
yaml = pytest.importorskip("yaml")
def test_prices_from_yaml():
data = yaml.safe_load("pen: '15.00'")
assert parse_price(data["pen"]) == 15.0.. [100%]
=========================== short test summary info ============================
SKIPPED [1] test_export.py:5: could not import 'yaml': No module named 'yaml'
2 passed, 1 skipped in 0.08spytest.importorskip("yaml")はインポートを試みます。成功すればモジュールを返すので、それをyamlに代入します。失敗すれば、ファイル全体がスキップされ、インポートエラーがその理由になります。他の2つのテストは今や実行されます。yamlをインストールすれば、変更なしで同じファイルが完全に実行されます。
xfail — あなたが知っているバグ
スキップは「このテストはここで実行できない」と言います。次に、異なる宣言があります。「このテストは実行されるし、失敗することが予想される。なぜなら、コードに既知のバグがあるから」です。
parse_priceにはそのようなバグがあります。通貨記号を処理しません:
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped")
def test_parse_with_symbol():
assert parse_price("$1,250") == 1250.0
@pytest.mark.xfail(reason="spaces as thousands separators")
def test_parse_with_spaces():
assert parse_price("1 250") == 1250.0pytest -q -rx:
xx [100%]
=========================== short test summary info ============================
XFAIL test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
XFAIL test_xfail.py::test_parse_with_spaces - spaces as thousands separators
2 xfailed in 0.08sスキップされたテストと違い、xfailテストは実際に実行されます。予想どおり失敗したので、小文字のxと表示され、実行はグリーンのままです。テストは今や、誰かの頭の中のメモではなく、スイートの中に置かれたバグの書き残された記録になりました。
なぜ単にskipにしないのでしょうか? スキップされたテストは一度も実行されないからです。だから何も教えてくれません。バグが直されたときも、コードが変わって新しい方法で失敗するときも。xfailテストは毎回実行され、その結果はあなたの予想と比較されます。skipは「ここで実行できない」ためのもので、xfailは「実行されるし、何と言うか知っている」ためのものです。
誰かがバグ#42を修正し、money.pyが$を除去するように変化したとします:
Xx [100%]
=================================== XPASSES ====================================
=========================== short test summary info ============================
XPASS test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
1 xfailed, 1 xpassed in 0.12s大文字のX、XPASS — 「失敗することが予想されていたのに、パスした」。そして実行はまだグリーンです — 終了コードは0です。誰もグリーンの実行を見ないので、マーカーは残り、テストはもはや存在しないバグを主張し続けます。
strict=True — いい知らせを大きく報せる
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped", strict=True)
def test_parse_with_symbol():
assert parse_price("$1,250") == 1250.0F [100%]
=================================== FAILURES ===================================
____________________________ test_parse_with_symbol ____________________________
[XPASS(strict)] bug #42: currency symbol is not stripped
=========================== short test summary info ============================
FAILED test_xfail.py::test_parse_with_symbol - [XPASS(strict)] bug #42: curre...
1 failed in 0.10sstrict=Trueでは、予想外のパスは失敗です。一見すると逆のように聞こえますが、まさにあなたが欲しいものです。バグを修正した人にマーカーを外すよう伝え、テストは以降、その修正を守る普通のテストに変わります。プロジェクトのすべてのxfailにこれを適用したい場合は、pytestの設定でxfail_strict = trueを設定します。
raises= — 正しい理由で失敗する
引数のないxfailはあらゆる失敗を受け付けます。意図していないものも含みます:
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42")
def test_loose():
assert parse_prise("$1,250") == 1250.0
@pytest.mark.xfail(raises=ValueError, reason="bug #42")
def test_precise():
assert parse_prise("$1,250") == 1250.0xF [100%]
=================================== FAILURES ===================================
_________________________________ test_precise _________________________________
@pytest.mark.xfail(raises=ValueError, reason="bug #42")
def test_precise():
> assert parse_prise("$1,250") == 1250.0
^^^^^^^^^^^
E NameError: name 'parse_prise' is not defined
test_raises.py:13: NameError
=========================== short test summary info ============================
XFAIL test_raises.py::test_loose - bug #42
1 failed, 1 xfailed in 0.09s2つのテストには同じタイポ、parse_priseがあります。test_looseはNameErrorを飲み込み、落ち着いたxを報告しました — parse_priceを一度もテストせずに、「失敗することが予想される」まま永遠に終わります。test_preciseは、どの失敗を期待しているかを宣言しています。float("$1250")が送出するValueErrorです。ですから、その他の例外はすべて本物の失敗として報告されます。タイポを直すと、両方ともxになります。
レポートの文字
進捗行はテストごとに1文字を使い、-rは同じ文字を受け取って、要約に何を載せるかを選びます:
| 文字 | 意味 | 要約に表示させるには | |---|---|---| | . | パス | -rp | | F | 失敗 | -rf(デフォルトでオン) | | E | セットアップまたはティアダウンでのエラー | -rE(デフォルトでオン) | | s | スキップ | -rs | | x | xfailed — 予想どおり失敗 | -rx | | X | xpassed — 予想外にパス | -rX |
文字は組み合わせられます。-rsxはスキップとxfailを一緒に表示します。-rAはすべてを表示します。パスも含めて:
..xs [100%]
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED test_params.py::test_parse_price[15-15.0]
PASSED test_params.py::test_parse_price[1,250.50-1250.5]
SKIPPED [1] test_params.py:6: space separators not decided
XFAIL test_params.py::test_parse_price[$1,250-1250.0] - bug #42
2 passed, 1 skipped, 1 xfailed in 0.08sあなた自身のマーカー
skipとxfailは、pytestがテストに対してどうするかを変えます。マーカーは単なるラベルにもなります。あなたが発明した名前を持ち、テストの選択に使うまで何も変えません。あるテストが遅く、あるテストがデータベースを必要とするとしましょう:
import time
import pytest
from money import parse_price, split_bill
def test_parse_plain():
assert parse_price("1,250.50") == 1250.5
@pytest.mark.slow
def test_parse_many():
time.sleep(1)
assert sum(parse_price("1.5") for _ in range(1000)) == 1500.0
@pytest.mark.slow
@pytest.mark.db
def test_bill_from_database():
time.sleep(1)
assert split_bill(300.0, 3) == 100.0
@pytest.mark.db
def test_bill_lookup():
assert split_bill(90.0, 3) == 30.0.... [100%]
=============================== warnings summary ===============================
test_marks.py:12
/home/you/shop/test_marks.py:12: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
test_marks.py:18
/home/you/shop/test_marks.py:18: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
test_marks.py:19
/home/you/shop/test_marks.py:19: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.db
test_marks.py:25
/home/you/shop/test_marks.py:25: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.db
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
4 passed, 4 warnings in 2.09s4つすべてパスしましたが、pytestはすべての使用に文句を言います。pytest.mark.<anything>は受け付けられます — 属性はその場で作られます — ですから、pytestはslowを別の何かの綴り間違いと区別できません。あなたに名前を宣言するよう求めます。
マーカーの登録
リストはpyproject.tomlに入ります。マーカーごとに1つの文字列で、コロンの前が名前、後が説明です:
[tool.pytest.ini_options]
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"db: needs the database",
].... [100%]
4 passed in 2.11s警告は消え、pytest --markersは今やそれを、プロジェクトに新しく加わる次の人のために一覧表示します:
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'
@pytest.mark.db: needs the database-mでの選択
ラベルが役に立つのはここからです。-mはマーカー名に対する式を取り、and、or、not、そして括弧が使えます:
$ pytest -q -m "not slow"
.. [100%]
2 passed, 2 deselected in 0.07s
$ pytest -q -m "slow and db"
. [100%]
1 passed, 3 deselected in 1.08s
$ pytest -q -m "slow or db"
... [100%]
3 passed, 1 deselected in 2.07s選択から外れた(deselected)はスキップ(skipped)ではありません。スキップされたテストは選択された後で実行を辞退しました。選択から外れたテストは選ばれもしませんでした。-m "not slow"はファイルを保存するたびに行う実行で、完全な実行はCIとプッシュ前のためのものです。
--strict-markers — タイポがエラーになる
警告は見落としやすいです。後から誰かが追加した、マーカーの綴りが間違ったテストがあります:
@pytest.mark.slwo
def test_split_large_group():
time.sleep(1)
assert split_bill(1000.0, 8) == 125.0... [100%]
=============================== warnings summary ===============================
test_marks.py:30
/home/you/shop/test_marks.py:30: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slwo
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
3 passed, 2 deselected, 1 warning in 1.07s-m "not slow"を指定したのに、実行はまだ1秒かかりました。綴りの間違ったテストはslowではないので、実行されたのです。--strict-markersでは、登録されていないマーカーが代わりに収集を止めます:
==================================== ERRORS ====================================
________________________ ERROR collecting test_marks.py ________________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR test_marks.py - Failed: 'slwo' not found in `markers` configuration option
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13s毎回このフラグを打ちたくないでしょうから、addoptsに入れます:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"db: needs the database",
]pytestmark — ファイル全体にマークを付ける
ファイル内のすべてのテストがデータベースを必要とするなら、1つずつデコレータを付けるのは同じことの繰り返しです。pytestmarkという名前のモジュールレベルの変数は、ファイル内のすべてのテストに適用されます:
import pytest
from money import split_bill
pytestmark = pytest.mark.db
def test_bill_for_two():
assert split_bill(50.0, 2) == 25.0
def test_bill_for_four():
assert split_bill(80.0, 4) == 20.0$ pytest -q -m db
.. [100%]
2 passed, 4 deselected in 0.07s名前は正確にpytestmarkでなければなりません。リストにもできます。pytestmark = [pytest.mark.db, pytest.mark.slow]。
parametrizeの1つのケースにマークを付ける
パラメータ化されたテストへのマーカーは、すべてのケースに適用されます。1つのケースだけにマークを付けるには、pytest.param(..., marks=...)で包みます:
import pytest
from money import parse_price
@pytest.mark.parametrize(
"text, expected",
[
("15", 15.0),
("1,250.50", 1250.5),
pytest.param(
"$1,250", 1250.0,
marks=pytest.mark.xfail(raises=ValueError, reason="bug #42"),
),
pytest.param(
"1 250", 1250.0,
marks=pytest.mark.skip(reason="space separators not decided"),
),
],
)
def test_parse_price(text, expected):
assert parse_price(text) == expectedtest_params.py::test_parse_price[15-15.0] PASSED [ 25%]
test_params.py::test_parse_price[1,250.50-1250.5] PASSED [ 50%]
test_params.py::test_parse_price[$1,250-1250.0] XFAIL (bug #42) [ 75%]
test_params.py::test_parse_price[1 250-1250.0] SKIPPED (space separa...) [100%]
=================== 2 passed, 1 skipped, 1 xfailed in 0.11s ====================既知のバグは、動くケースと同じ表の中に隣り合わせで存在し、修正された日にはpytest.paramのラッパーを1つ削除するだけです。
さらに先へ: 変化するフィクスチャと、組み立てるフィクスチャ
2つのフィクスチャパターンはフィクスチャの章には収まりませんでした。マーカーはそれらを取り上げるのに良い機会です。どちらも、あなたが学んだばかりのテストの選択を増やしたり、形付けたりするからです。
パラメータ化されたフィクスチャ
parametrizeは1つのテストの入力を変化させます。ときどきは、フィクスチャを使うすべてのテストを、そのフィクスチャの値ごとに1回ずつ実行したいことがあります。@pytest.fixtureにparamsリストを与え、現在の値をrequest.paramから読みます:
import pytest
from money import parse_price
@pytest.fixture(params=["1250", "1,250", "1,250.00"], ids=["plain", "comma", "decimals"])
def price_text(request):
return request.param
def test_parses_to_1250(price_text):
assert parse_price(price_text) == 1250.0
def test_is_positive(price_text):
assert parse_price(price_text) > 0test_fixtures.py::test_parses_to_1250[plain] PASSED [ 16%]
test_fixtures.py::test_parses_to_1250[comma] PASSED [ 33%]
test_fixtures.py::test_parses_to_1250[decimals] PASSED [ 50%]
test_fixtures.py::test_is_positive[plain] PASSED [ 66%]
test_fixtures.py::test_is_positive[comma] PASSED [ 83%]
test_fixtures.py::test_is_positive[decimals] PASSED [100%]
============================== 6 passed in 0.08s ===============================2つのテスト、3つの値、6回の実行。requestはセットアップ中のテストを記述する組み込みフィクスチャです。request.paramはフィクスチャにparamsがあるときだけ存在します。idsは各値にレポート上の名前を付けます — それがなければ、pytestは値そのものからIDを組み立てるので、長い値や表示できない値では読みにくくなります。リストにはparametrizeとまったく同じように、marks=付きのpytest.param(...)エントリも入れられます。
これが力を発揮するのは: 同じテストのセットを複数のバックエンド、複数のファイル形式、複数の通貨に対して行う場合です。paramsに値を1つ追加すれば、フィクスチャを使うすべてのテストがそれをカバーします。
ファクトリーフィクスチャ
フィクスチャはテストから引数を受け取れません。テストはそれを名前で呼ぶだけです。では、テストが2つのオブジェクトを、異なる方法で組み立てる必要があるときはどうするのでしょうか? 関数を返します:
import pytest
from money import parse_price
@pytest.fixture
def make_receipt(tmp_path):
def _make(name, lines):
path = tmp_path / f"{name}.txt"
path.write_text("\n".join(lines))
return path
return _make
def receipt_total(path):
return sum(parse_price(line) for line in path.read_text().splitlines())
def test_two_receipts(make_receipt):
lunch = make_receipt("lunch", ["15", "1,250.50"])
taxi = make_receipt("taxi", ["300"])
assert receipt_total(lunch) + receipt_total(taxi) == 1565.5
def test_empty_receipt(make_receipt):
empty = make_receipt("empty", [])
assert receipt_total(empty) == 0.. [100%]
2 passed in 0.08sフィクスチャはそれでもテストごとに1回実行され、自分のフィクスチャ(ここではtmp_path。ファイルの削除も面倒を見ます)を得ます。渡すのは_makeで、テストは好きなだけ、好きな引数で呼びます。make_...という名前は通常の慣習で、読む人に一目で、このフィクスチャは呼び出すための関数であって、準備済みの値ではないと伝えます。
完全な例
すべてを1つのプロジェクトにまとめます。pyproject.toml:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"network: talks to an outside service",
]test_checkout.py — 同じmoney.pyを使います。バグ#42はまだ残っています:
import os
import time
import pytest
from money import parse_price, split_bill
# Every test that asks for amount_text runs once per format.
@pytest.fixture(params=["300", "300.00", "1,250"], ids=["whole", "decimals", "comma"])
def amount_text(request):
return request.param
# A factory: the fixture hands back a function the test can call many times.
@pytest.fixture
def make_share():
def _make(text, people):
return split_bill(parse_price(text), people)
return _make
def test_share_is_positive(amount_text, make_share):
assert make_share(amount_text, 3) > 0
@pytest.mark.parametrize(
"text, people, share",
[
("300", 3, 100.0),
("90", 4, 22.5),
pytest.param(
"$300", 3, 100.0,
marks=pytest.mark.xfail(raises=ValueError, strict=True, reason="bug #42"),
),
],
)
def test_share(make_share, text, people, share):
assert make_share(text, people) == share
@pytest.mark.slow
def test_many_group_sizes(make_share):
time.sleep(1) # stands in for real, slow work
assert all(make_share("300", n) > 0 for n in range(1, 1000))
@pytest.mark.network
def test_live_exchange_rate():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
raise AssertionError("would call the real service here")日常の実行、理由付きで:
$ pytest -q -rsx -m "not slow"
.....xs [100%]
=========================== short test summary info ============================
SKIPPED [1] test_checkout.py:52: RATES_API_KEY is not set
XFAIL test_checkout.py::test_share[$300-3-100.0] - bug #42
5 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09s遅いものだけの実行と、遅いテストとネットワークの両方を除外したオフラインの実行:
$ pytest -q -m slow
. [100%]
1 passed, 7 deselected in 1.07s
$ pytest -q -m "not slow and not network"
.....x [100%]
5 passed, 2 deselected, 1 xfailed in 0.08s数えてみてください。3つの値を持つフィクスチャ1つが、test_share_is_positiveの3回の実行、test_shareの3つのケース、遅いテスト1つ、ネットワークテスト1つを生みます — 合計8つで、これが1 passed, 7 deselectedの合計です。
そしてすべての結果が正直に語ります。ネットワークテストは、キーなしでパスしたふりをしません。スキップされたこととその理由を言います。バグ#42はスイートの中にあり、strictとraises=ValueErrorのおかげで、修正された日には実行が赤くなり、マーカーを外すよう求めます。
問題が起きたとき
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? マーカーが登録されていません。pyproject.tomlのmarkersに追加してください。名前が本当にタイポなら直してください — そして--strict-markersをaddoptsに追加すれば、次のは警告ではなくエラーになります。
`'slwo' not found in markers configuration option` これが--strict-markersの役目です。ファイルがリストにないマーカーを使っています。綴りを直すか、新しい名前を登録してください。
8 deselectedとno tests ran、終了コード5 -mが何も一致しませんでした。-mの式の中の綴り間違いは、--strict-markersがあってもエラーにはなりません — -m slowwは単にどのテストも選択しません。pytest --markersで名前を確認してください。
ERROR: Wrong expression passed to '-m': not slow and: at column 13: expected not OR left parenthesis OR identifier; got end of input -mの式が不完全です。式全体を引用符で囲み、すべてのand/orの両側に何かがあることを確認してください。
Error evaluating 'skipif': you need to specify reason=STRING when using booleans as conditions. reason=のないskipif(sys.platform == "linux")です。真偽値の条件ではなぜなのかを何も語らないので、pytestは理由を要求します。
`Using pytest.skip outside of a test will skip the entire module. If that's your intention, pass allow_module_level=True.` pytest.skip("...")がファイルのトップレベルで呼ばれたからです。パッケージがないときはpytest.importorskipを使ってください。それ以外はpytest.skip("...", allow_module_level=True)と書くか、pytestmarkの中のskipifを使ってください。
[XPASS(strict)] bug #42 失敗として報告されたいい知らせです。バグは修正されました。xfailマーカーを外して、テストを残してください。
バグが修正されてもxのままのxfailテスト 別の理由で失敗しています — たいていはテスト自身のタイポです。実際に期待している例外をraises=に加えてください。それ以外はすべて本物の失敗として表示されます。
AttributeError: 'SubRequest' object has no attribute 'param' フィクスチャがrequest.paramを読んでいますが、params=リストがなく、他の方法でもパラメータ化されていません。@pytest.fixtureにparams=[...]を追加してください。
fixture 'name' not found — フィクスチャを指している フィクスチャがdef make_user(name):と書かれ、テストがnameを渡してくれると期待しています。フィクスチャの引数は他のフィクスチャなので、pytestはnameというフィクスチャを探しました。ファクトリに変えてください。nameを受け取る内側の関数を、フィクスチャが返すようにします。
ステップ 4 / 6 — 予想する
理解度をチェック
pytest -qの進捗行と最後の行は何ですか?(round(2.675, 2)が2.67になることを思い出してください。)
import pytest
@pytest.mark.skip(reason="not ready")
def test_export():
assert False
def test_total():
assert 2 + 2 == 4
@pytest.mark.xfail(reason="bug #7")
def test_rounding():
assert round(2.675, 2) == 2.68- A..x 2 passed, 1 xfailed
- Bs.X 1 passed, 1 skipped, 1 xpassed
- Cs.F 1 passed, 1 skipped, 1 failed
- Ds.x 1 passed, 1 skipped, 1 xfailed
slowはpyproject.tomlに登録されており、--strict-markersはオフです。pytest -m "not slow"を実行しますが、実行にはまだ5秒かかります。なぜですか?
import time
import pytest
@pytest.mark.slwo
def test_big_report():
time.sleep(5)
assert True- A`-m "not slow"`は遅いテストだけを実行する
- Bマーカーが`slwo`と綴り間違いになっているので、テストは`slow`ではなく選択され、pytestは警告するだけ
- C`time.sleep`がpytestにマーカーを無視させる
- D`-m`は`skip`のような組み込みマーカーとしか一緒に働かない
バグ#12は実際にはもう修正されており、アサートは真です。pytest -qは何を報告しますか?
import pytest
@pytest.mark.xfail(strict=True, reason="bug #12")
def test_upper():
assert "taka".upper() == "TAKA"- A`X` — 1 xpassed、終了コード0
- B`F` — FAILED、`[XPASS(strict)]`、終了コード1
- C`s` — マーカーが付いているので、テストはスキップされる
- D`x` — 1 xfailed、終了コード0
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
すでに持っているテストスイート — 前の章のものでも、自分の仕事のものでも — を取り、正直な一組のマーカーを与えてください:
pyproject.tomlに自分だけのマーカーを2つ登録し(例:slowとnetwork)、addoptsで--strict-markersをオンにします。- 各マーカーを少なくとも1つのテストに付けます。遅いテストがなければ、
time.sleep(1)で偽ります。 - 環境変数を必要とするテストを追加し、その変数がないときは内部から
pytest.skipでスキップします。 - コードの中のバグを1つ見つけるか発明します。パスすべきテストを書き、
xfailをstrict=Trueと正確なraises=でマークします。 paramsとidsを持つフィクスチャを1つ追加し、少なくとも2つのテストに使わせます。
次に実行し、それぞれの出力を保存します:
pytest -q -rsx -m "not slow"pytest -q -m slowpytest -q -rA
最後に、2つの実験です。マーカーの1つの綴りを間違え、--strict-markersなしで1回、ありで1回実行します。次に、手順4のバグを修正してもう一度実行します。pytestがそれぞれで何と言うか読んでください — その2つのメッセージが、マークの付いたスイートを正直に保つもので、自分で一度それらを起こしてみれば、2回目にはもっとよく分かります。
解答例
1つの解答例です。shopプロジェクトのmoney.pyを使います。手順4のバグは新しいもの、バグ#51です。スプレッドシートの空のセルが""として届き、0.0と数えるべきところ、float("")はValueErrorを送出します。
pyproject.toml:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"network: needs a real outside service and its API key",
]test_solution.py:
import os
import time
import pytest
from money import parse_price, split_bill
# Step 5: one fixture, three formats, used by two tests.
@pytest.fixture(params=["90", "90.00", "1,090"], ids=["whole", "decimals", "comma"])
def amount_text(request):
return request.param
def test_parse_is_positive(amount_text):
assert parse_price(amount_text) > 0
def test_split_for_one_is_whole_amount(amount_text):
assert split_bill(parse_price(amount_text), 1) == parse_price(amount_text)
# Step 2: a slow test, labelled so it can be left out.
@pytest.mark.slow
def test_many_prices():
time.sleep(1) # stands in for real, slow work
assert sum(parse_price("2.5") for _ in range(1000)) == 2500.0
# Step 3: decided inside the test, because only the environment knows.
@pytest.mark.network
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
raise AssertionError("would call the real service here")
# Step 4: bug #51 - an empty cell should count as 0, today it raises ValueError.
@pytest.mark.xfail(raises=ValueError, strict=True, reason="bug #51: empty cell")
def test_empty_cell_is_zero():
assert parse_price("") == 0.03つの実行:
$ pytest -q -rsx -m "not slow"
......sx [100%]
=========================== short test summary info ============================
SKIPPED [1] test_solution.py:34: RATES_API_KEY is not set
XFAIL test_solution.py::test_empty_cell_is_zero - bug #51: empty cell
6 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09s
$ pytest -q -m slow
. [100%]
1 passed, 8 deselected in 1.07s
$ pytest -q -rA
.......sx [100%]
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED test_solution.py::test_parse_is_positive[whole]
PASSED test_solution.py::test_parse_is_positive[decimals]
PASSED test_solution.py::test_parse_is_positive[comma]
PASSED test_solution.py::test_split_for_one_is_whole_amount[whole]
PASSED test_solution.py::test_split_for_one_is_whole_amount[decimals]
PASSED test_solution.py::test_split_for_one_is_whole_amount[comma]
PASSED test_solution.py::test_many_prices
SKIPPED [1] test_solution.py:34: RATES_API_KEY is not set
XFAIL test_solution.py::test_empty_cell_is_zero - bug #51: empty cell
7 passed, 1 skipped, 1 xfailed in 1.08s最初の実験: @pytest.mark.slowを@pytest.mark.slwoと綴り間違います。--strict-markersなしでは(ここでは-o addopts=""でオフにしています)、実行はまだ1秒かかります。「slow」のテストが選択から外れなかったからです:
$ pytest -q -m "not slow" -o addopts=""
.......sx [100%]
=============================== warnings summary ===============================
test_solution.py:24
/home/you/shop/test_solution.py:24: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slwo
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
7 passed, 1 skipped, 1 xfailed, 1 warning in 1.09sありでは、実行は始まるのを拒否します:
$ pytest -q -m "not slow"
==================================== ERRORS ====================================
______________________ ERROR collecting test_solution.py _______________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR test_solution.py - Failed: 'slwo' not found in `markers` configuration ...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13s2番目の実験: money.pyのバグ#51を修正します:
def parse_price(text: str) -> float:
if not text.strip():
return 0.0
return float(text.replace(",", ""))
def split_bill(total: float, people: int) -> float:
return round(total / people, 2)$ pytest -q -m "not slow"
......sF [100%]
=================================== FAILURES ===================================
___________________________ test_empty_cell_is_zero ____________________________
[XPASS(strict)] bug #51: empty cell
=========================== short test summary info ============================
FAILED test_solution.py::test_empty_cell_is_zero - [XPASS(strict)] bug #51: e...
1 failed, 6 passed, 1 skipped, 1 deselected in 0.08sxfailの行を外せば、そのテストは以降、普通のテストとしてパスします。
各判断の理由:
--strict-markersはコマンドラインではなくaddoptsに。 有効にすることを覚えておく必要のあるセーフネットは、セーフネットではありません。設定に入れば、CIを含むすべての人に適用されます。slowはスキップではなくラベルに。 遅いテストは良いテストです。実行されるべきで、ただ毎回の保存のときでなくてよいだけです。ラベルなら選べます。スキップなら、すべての実行から外れます。- APIキーには
skipifではなくテスト内部のpytest.skip()を。 どちらもここで動きますが、そのチェックはキーを必要とするコードと同じ場所にあるべきです。テストが半分まで進んでからでないと続行できないと分からなければ、内部の形式しか機能しません。 raises=ValueErrorとstrict=Trueを一緒に。raises=は、テストがタイポではなくバグ#51のために失敗することを保証します。strict=Trueは、バグが修正された日が気づかれないまま終わらないことを保証します。それぞれが、マーカーが嘘をつき始める別の入り口を塞ぎます。- 形式には
parametrizeではなくパラメータ化されたフィクスチャを。 2つのテストが同じ3つの入力を共有します。フィクスチャならリストは1か所にあります。4つ目の形式を追加すれば、両方のテストに一度に追加されます。 - IDは
whole、decimals、comma。 レポートでは、[comma]がそのケースが何についてかを語ります。[1,090]でもここでは動きますが、IDは値が長いときや表示できないときでも機能し続けます。