Asyncテスト、フック、プラグイン作成
pytest-asyncioでasyncコードをテストし、conftest.pyのフックでpytestを拡張 — `--runslow`フラグ、マーカー、レポートヘッダー — そしてそれを自分のプラグインにし、pytesterでテストする。すべての例を実際に実行。
どうやって学びますか?
解決する問題
ここに、非同期コードの一例があります — ネットワークとやり取りし、待っている間はアイドル状態を避けたい種類のコードです。
prices.py:
import asyncio
PRICES = {"pen": 15.0, "notebook": 60.0}
async def fetch_price(item):
await asyncio.sleep(0.01) # stands in for a network call
if item not in PRICES:
raise KeyError(item)
return PRICES[item]これまでと同じようにテストします:test_で始まる関数として書きます。awaitはその中でしか使えないため、async関数にします。そしてテストが本当に何かをチェックしていることを確実にするため、期待値を意図的に間違った値にしておきます:
test_prices.py:
from prices import fetch_price
async def test_pen_price():
assert await fetch_price("pen") == 999.0pytest -q:
F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
async def functions are not natively supported.
You need to install a suitable plugin for your async framework, for example:
- anyio
- pytest-asyncio
- pytest-tornasync
- pytest-trio
- pytest-twisted
=========================== short test summary info ============================
FAILED test_prices.py::test_pen_price - Failed: async def functions are not n...
1 failed in 0.11sこの失敗メッセージをよく読んでください。15.0や999.0については何も言っていません。アサーションは一度も実行されていません。async def関数を呼んでもその本体は実行されません — コルーチンが作られるだけで、それを実行するには何かがイベントループを駆動する必要があります。素のpytestはそれをしません。(古いバージョンのpytestでは、そのようなテストを警告付きでスキップしていたため、何もテストせずにグリーンに見えていました。pytest 9では失敗させるため、はるかに良くなりました。)
これがこの章の前半です:pytestにasyncコードを実行させること。後半は、その修正の背後にあるより深い考え方です — pytest自体がフックの集合であり、プラグインはそれらのいくつかを埋めるコードに過ぎません。それが分かれば、自分で書けるようになります:--runslowフラグ、マーカー、レポートヘッダーの一行、そしてプラグイン自身のテストスイート。
この章を終える頃には
- なぜ素の
async def test_...が実行されないのかを説明し、その失敗を認識できる pytest-asyncioを使ってasyncテストを実行できる:@pytest.mark.asyncioまたはasyncio_mode = "auto"を使用yield後のティアダウンを含むasyncフィクスチャを書けるpytest_addoptionでコマンドラインオプションを追加し、request.config.getoptionで読めるpytest_collection_modifyitemsを使って、--runslowが与えられない限りslowテストをスキップできるpytest_configureでマーカーを登録し、pytest_report_headerで一行追加できる- pytestがフックを見つける場所を言える:
conftest.py、-p、pytest11エントリーポイント pytesterフィクスチャでプラグインをテストできる
前提条件: カバレッジとCI。
テストを書く前に
この章では2種類の異なるものをテストします — async関数とpytestプラグイン — どちらも最初のテストコードの前に少し考える必要があります。
約束されていること。 まず契約を平易な言葉で書き出します。テストはその契約を翻訳したものだからです。
fetch_price(item)はコルーチン関数です。既知のアイテムでawaitすると、そのアイテムの価格を返します。未知のアイテムでawaitすると、KeyErrorを発生させます。多くの呼び出しを同時にawaitすると、キューイングされずにオーバーラップします。- slowテストプラグインの約束:
--runslowなしでは、slowマーカーが付いたすべてのテストが理由付きでスキップされる;--runslowありではすべて実行される;ヘッダーにどちらかが表示される;そしてslowマーカーが登録されているので警告は出ない。
用意が必要なもの。 asyncテストにはランナーが必要ですが、pytestにはありません:pytest-asyncioをインストールし、strictモードかautoモードかをテストを書く前に決めます。なぜなら書くデコレータがそれに依存するからです。テスト対象のコードがインポート可能であること — ここではプロジェクトルートのprices.py、後でプラグインモジュールも(pythonpath = ["."])。プラグインのテストにはpytesterフィクスチャをオンにする必要があります。そして実際のネットワークは不要です:asyncio.sleepが代わりを務めます。
計画。 1つの振る舞いにつき1行、それぞれに入力と期待する結果を:
| ケース | 入力 | 期待 | | --- | --- | --- | | 既知のアイテム | await fetch_price("pen") | 15.0 | | 未知のアイテム | await fetch_price("stapler") | KeyError | | awaitを忘れた | プレーンなテストでfetch_price("pen") | コルーチンオブジェクト、価格ではない | | 多数を一度に | asyncio.gatherで400回呼び出し | すべて400回返ってくる、1回分の時間で | | デフォルト実行 | 1つのslowテストでpytest | 1 passed, 1 skipped | | フラグあり | pytest --runslow | 2 passed | | ヘッダー | pytest | slow tests: skipped (use --runslow)という行 | | マーカー | pytest --markers | slowがリストされ、PytestUnknownMarkWarningなし |
テストしてはいけないもの。 asyncio.sleepやイベントループではない — Pythonがテストします。pytest自身のスキップ機構ではない:あなたのフックがスキップを付けることを、pytestがsをどう表示するかではなく、結果を数えることでテストします。正確なタイミングもテストしません:「400回の呼び出しが1秒以内に終わる」はどのマシンでも安定した主張ですが、「0.013秒で終わる」はそうではありません。
この章の残りはその表を、行ごとに進めていきます。
pytest-asyncioでasyncテストを実行
失敗メッセージはいくつかのプラグインを列挙しています。asyncioベースのコードなら、通常はpytest-asyncioを選びます:
pip install pytest-asyncioインストールするだけでは不十分です。同じテストをもう一度実行すると、出力は文字通り同じです:async def functions are not natively supported。デフォルトではpytest-asyncioはstrictモードで動作します — 明示的に手渡したテストだけを引き受けます。手渡すにはマーカーを使います:
import pytest
from prices import fetch_price
@pytest.mark.asyncio
async def test_pen_price():
assert await fetch_price("pen") == 999.0F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
@pytest.mark.asyncio
async def test_pen_price():
> assert await fetch_price("pen") == 999.0
E assert 15.0 == 999.0
test_prices.py:8: AssertionError
=========================== short test summary info ============================
FAILED test_prices.py::test_pen_price - assert 15.0 == 999.0
1 failed in 0.10s今度は正しい理由で失敗します。assert 15.0 == 999.0 — コルーチンが実行され、awaitが実際の数値を生成し、アサーションがそれを比較しました。間違った値でテストが失敗するのを見ることは、それが本当に実行されていることの証拠です。数値を修正し、遂にエラーパスのテストも追加します:
import pytest
from prices import fetch_price
@pytest.mark.asyncio
async def test_pen_price():
assert await fetch_price("pen") == 15.0
@pytest.mark.asyncio
async def test_unknown_item():
with pytest.raises(KeyError):
await fetch_price("stapler").. [100%]
2 passed in 0.10spytest.raisesはasyncテストの中でも他とまったく同じように動作します;awaitが単にwithブロックの中に入るだけです。
もう一つ、よくある間違いとして見逃せないものがあります:通常のdefテストを書き、async関数をawaitなしで呼ぶことで、問題全体を回避しようとするものです。
from prices import fetch_price
def test_pen_price():
assert fetch_price("pen") == 15.0F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
def test_pen_price():
> assert fetch_price("pen") == 15.0
E AssertionError: assert <coroutine object fetch_price at 0x70659d650e80> == 15.0
E + where <coroutine object fetch_price at 0x70659d650e80> = fetch_price('pen')
test_noawait.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_noawait.py::test_pen_price - AssertionError: assert <coroutine ob...
1 failed in 0.08s
sys:1: RuntimeWarning: coroutine 'fetch_price' was never awaitedこのレポートは、何が起きたかを正確に伝えています:fetch_price('pen')がコルーチンオブジェクトを返し、コルーチンは15.0と等しくありません。最後の行はPythonが追加したもので、コルーチンが実行されずに捨てられたことを示しています。これは少なくとも大きく失敗します。危険な変種は比較なしのassert fetch_price("pen")です — コルーチンオブジェクトは真値なので、そのテストは1 passed, 1 warningと報告しながら何もチェックしません。asyncコードのテストは必ずasync defで、必ずawaitしなければなりません。
asyncio_mode = "auto"とasyncフィクスチャ
すべてのテストの上に@pytest.mark.asyncioを書くのはすぐに面倒になります。asyncが通常のプロジェクトでは、モードをpyproject.tomlで切り替えます:
[tool.pytest.ini_options]
asyncio_mode = "auto"autoモードでは、pytest-asyncioはすべてのasync defテストとすべてのasync defフィクスチャを、マーカーなしで引き受けます。完全なpytest実行のヘッダーがどのモードが有効かを教えてくれます:
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=functionフィクスチャもasyncにできます。ここでカートをセットアップし、テストに渡し、その後でクローズします — yieldの両側でawaitします:
import pytest
from prices import fetch_price
class Cart:
def __init__(self):
self.items = []
self.closed = False
async def add(self, item):
self.items.append((item, await fetch_price(item)))
async def close(self):
self.closed = True
@pytest.fixture
async def cart():
c = Cart()
yield c
await c.close()
async def test_add(cart):
await cart.add("pen")
assert cart.items == [("pen", 15.0)]
async def test_two_items(cart):
await cart.add("pen")
await cart.add("notebook")
assert [price for _, price in cart.items] == [15.0, 60.0].. [100%]
2 passed in 0.11syieldは前章のフィクスチャと同じように動作します:その前がセットアップ、後がティアダウン、そしてテストが失敗してもティアダウンは実行されます。
strictモードでは、同じフィクスチャに独自のデコレータ、@pytest_asyncio.fixtureが必要です(import pytest_asyncioから)。そこにプレーンな@pytest.fixtureがあると、pytestはそれをどう実行するか分からず、セットアップで止まります:
E [100%]
==================================== ERRORS ====================================
__________________________ ERROR at setup of test_add __________________________
'test_add' requested an async fixture 'cart', with no plugin or hook that handled it. This is an error, as pytest does not natively support it.
See: https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture
=========================== short test summary info ============================
ERROR test_cart.py::test_add - Failed: 'test_add' requested an async fixture ...
1 error in 0.09sなので選択はシンプルです。strictモード:asyncテストに@pytest.mark.asyncio、asyncフィクスチャに@pytest_asyncio.fixtureを付ける。autoモード:どこでもプレーンなasync def。プロジェクトごとに1つ選び、設定に書いておけば誰も推測する必要がありません。
pytest-asyncioはどうやってそれをやったの? pytestを変えたわけではありません。pytestのフックのいくつかを実装しただけです — 「テストが今から実行される」といった決まった瞬間にpytestが呼ぶ、決まった名前の関数です。それがプラグインのすべてです。この章の残りで、独自のプラグインを書きます。conftest.pyでのフック
フックとは、pytest_<何らか>という名前の関数で、pytestが実行の特定の瞬間に呼ぶものです。登録は不要です;pytestが見る場所に定義すれば、名前が残りをやります。最初のそういう場所は、共有フィクスチャで既に使っているconftest.pyファイルです。
コマンドラインオプション:pytest_addoption
同じテストを自分のノートパソコンとステージングサーバーの両方で実行したいとします。アドレスはコードではなくコマンドラインに置くべきです:
conftest.py:
import pytest
def pytest_addoption(parser):
parser.addoption(
"--shop-url", default="http://localhost:8000", help="where the shop under test runs"
)
@pytest.fixture
def shop_url(request):
return request.config.getoption("--shop-url")test_shop.py:
def test_shop_url(shop_url):
print("testing against", shop_url)
assert shop_url.startswith("http")pytest -q -s、次にpytest -q -s --shop-url https://staging.example.com:
testing against http://localhost:8000
.
1 passed in 0.07s
testing against https://staging.example.com
.
1 passed in 0.07spytest_addoptionはコマンドラインがパースされる前に1回実行され、parser.addoptionはPythonのargparseと同じ引数を取ります。そしてrequest.configはこの実行の設定オブジェクトで、getoptionが値を読み戻します。そのオプションはpytest --helpにも現れます:
--shop-url=SHOP_URL where the shop under test runsオプションをフィクスチャで包むのが定石のパターンです:テストはshop_urlを要求し、設定に直接触れません。
slowテストをスキップ:pytest_collection_modifyitems
よくあるニーズ:あるテストは数秒かかり、毎回の保存では実行したくない — プッシュ前やCIでだけ実行したい。それらにslowマーカーを付け、--runslowフラグが与えられない限りスキップします。
まず思い付くのは、各slowテストの中にチェックを入れることです:トップでオプションを読み、なければpytest.skip()を呼ぶ。これは動きますが、間違ったやり方です。ルールがすべてのslowテストに分散して1か所にまとまらない;誰かが新しいslowテストを書いてその2行を忘れたら、毎回の保存で実行され、なぜスイートが遅くなったか誰も気づきません。そしてテスト本体が実行を開始してからでないと実行しない判断ができないため、そのフィクスチャが既にセットアップされています — データベースかもしれないし、サーバーかもしれません。
正しい場所は、すべてのテストが始まる前です。pytestがすべてのテストを収集した後、どれも実行する前に、pytest_collection_modifyitems(config, items)を呼びます。itemsは収集されたテストのリストで、フックはそれを変更できます — 1つのルール、1か所で、そのマーカーを持つすべてのテストに適用されます。
conftest.py:
import pytest
def pytest_addoption(parser):
parser.addoption(
"--runslow", action="store_true", default=False, help="also run tests marked slow"
)
def pytest_configure(config):
config.addinivalue_line("markers", "slow: a test that takes seconds, not milliseconds")
def pytest_report_header(config):
if config.getoption("--runslow"):
return "slow tests: included"
return "slow tests: skipped (use --runslow)"
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
return
skip_slow = pytest.mark.skip(reason="needs --runslow")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)test_reports.py:
import time
import pytest
def test_quick_total():
assert sum([15.0, 60.0]) == 75.0
@pytest.mark.slow
def test_full_year_report():
time.sleep(1) # pretend this crunches a year of sales
assert Truepytest -q、次にpytest -q --runslow:
.s [100%]
1 passed, 1 skipped in 0.10s
.. [100%]
2 passed in 1.09sフラグなしでは、slowテストはsになり、実行は0.1秒で終わります。フラグありでは両方実行され、2つ目は丸々1秒かかります。action="store_true"は--runslowをスイッチにします:なければFalse、あればTrue。フックの中にはrequestがないので、フックはconfigから直接値を読みます。
item.keywordsにはテストのマーカー名(とその他)が入っているので、"slow" in item.keywordsは「このテストは@pytest.mark.slowを持っているか?」を尋ねます。item.add_marker(skip_slow)はそれにスキップを付けます — まさにあなたがテストの上に@pytest.mark.skip(reason="needs --runslow")と書いたのと同じです。
マーカーを登録し、ヘッダー行を追加
あのファイルの2つの小さなフックにも一言。
pytest_configure(config)はコマンドラインがパースされた後に1回実行されます。config.addinivalue_line("markers", ...)はslowマーカーを登録します — pyproject.tomlのmarkers = [...]行と同じですが、プラグイン自身が持つ形です。これがないと、すべての@pytest.mark.slowがPytestUnknownMarkWarningを出し、--strict-markersではエラーになります。これでpytest --markersがそれを先頭に列挙します:
@pytest.mark.slow: a test that takes seconds, not millisecondspytest_report_header(config)は文字列(または文字列のリスト)を返し、pytestが実行のトップに表示します。CIログを読む人に知らせたいことは何でも、ここが適切な場所です。pytest -rs:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
slow tests: skipped (use --runslow)
rootdir: /home/you/shop
collected 2 items
test_reports.py .s [100%]
=========================== short test summary info ============================
SKIPPED [1] test_reports.py:10: needs --runslow
========================= 1 passed, 1 skipped in 0.07s =========================明確な理由付きでスキップされたテストは適切です:サマリーが何が実行されなかったか、なぜかを伝えます。
pytestがフックを見つける方法
これらのフックのすべてはモジュールに住んでいます。pytestはそういうモジュール — プラグイン — をいくつかの場所から読み込みます:
- 組み込みプラグイン。 pytestの大半はプラグインとして書かれています:
-k、マーカー、tmp_path、ターミナルレポート。 pytest11エントリーポイントを宣言しているインストール済みパッケージ。 これがpytest-asyncio、pytest-covなどがインストールされると同時に自分を読み込む仕組みです。ヘッダーのplugins:行にそれらが列挙されます。- コマンドラインの
-p、またはaddoptsで指定されたモジュール。 conftest.pyファイル、テストのルートと任意のサブディレクトリに(サブディレクトリのconftest.pyはその下のテストにだけ適用されます)。pytest_addoptionのような一部の早期フックは、テストのルートにあるconftest.pyでのみ有効です。
なので、上のconftest.pyをそのまま使えます。pytest_slowtests.pyとリネームすれば、それはプラグインです。-pとモジュール名で読み込みます:
pytest -p pytest_slowtestsそのモジュールはインポート可能でなければなりません。プロジェクトルートに置くなら、pythonpathでpytestに教え、毎回-pを打たなくて済むようにaddoptsに入れます:
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "-p pytest_slowtests"プロジェクト間で共有するには、パッケージにしてエントリーポイントを宣言します。グループ名はpytest11でなければなりません;左側の名前がプラグイン名、右側の値がモジュールです:
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "pytest-slowtests"
version = "0.1.0"
dependencies = ["pytest>=8"]
[project.entry-points.pytest11]
slowtests = "pytest_slowtests"これを新しい環境にインストールし(pip install ./pytest-slowtests)、conftest.pyのないプロジェクトで:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
slow tests: skipped (use --runslow)
rootdir: /home/you/other-project
plugins: slowtests-0.1.0
collected 2 items
test_reports.py .s [100%]
========================= 1 passed, 1 skipped in 0.01s =========================plugins: slowtests-0.1.0 — pytestがエントリーポイント経由で見つけました。逆も機能します:-p no:slowtestsでインストール済みプラグインを1回の実行でオフにできます。プラグインがトラブルの原因だと疑うときに便利です。
pytesterでプラグインをテスト
プラグインはpytestの振る舞いを変えるので、そのテストはpytestを実行し、何が起きたかを見る必要があります。pytestにはまさにそのためのフィクスチャ、pytesterが同梱されています。デフォルトではオフ;テストモジュール(またはルートのconftest.py)でオンにします:
pytest_plugins = ["pytester"]pytesterは各テストに空の一時ディレクトリ、小さな新しいプロジェクトを与えます。pytester.makepyfile(...)でテストファイルを書き、pytester.runpytest(...)で与えた引数でpytestを実行し、結果にはassert_outcomes(passed=..., skipped=..., failed=...)で件数をチェックし、result.stdout.fnmatch_lines([...])で出力行をチェックできます(*はワイルドカードとして使えます)。完全版は下の完全な例にあります。
完全な例
この章のすべてを抱える小さな1プロジェクト:asyncコード、autoモードのasyncテスト、slowテストプラグイン、そしてプラグインのテスト。
shop/
├── pyproject.toml
├── prices.py
├── pytest_slowtests.py
└── tests/
├── test_plugin.py
└── test_prices.pyprices.pyは章の冒頭のもの、pytest_slowtests.pyは--runslow節のconftest.pyをリネームしたものです。pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
addopts = "-p pytest_slowtests"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"tests/test_prices.py:
import asyncio
import pytest
from prices import PRICES, fetch_price
async def test_pen_price():
assert await fetch_price("pen") == 15.0
async def test_unknown_item():
with pytest.raises(KeyError):
await fetch_price("stapler")
@pytest.mark.slow
async def test_every_price_at_once():
names = list(PRICES) * 200
prices = await asyncio.gather(*(fetch_price(n) for n in names))
assert len(prices) == 400tests/test_plugin.py:
pytest_plugins = ["pytester"]
SAMPLE = """
import pytest
def test_quick():
assert True
@pytest.mark.slow
def test_slow():
assert True
"""
def run(pytester, *args):
# The inner run is a brand-new project in a temporary directory.
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function\n")
pytester.makepyfile(SAMPLE)
return pytester.runpytest("-p", "pytest_slowtests", *args)
def test_slow_is_skipped_by_default(pytester):
result = run(pytester)
result.assert_outcomes(passed=1, skipped=1)
def test_runslow_runs_everything(pytester):
result = run(pytester, "--runslow")
result.assert_outcomes(passed=2)
def test_header_mentions_the_flag(pytester):
result = run(pytester)
result.stdout.fnmatch_lines(["slow tests: skipped (use --runslow)"])pytest -v(ヘッダーは短縮):
slow tests: skipped (use --runslow)
configfile: pyproject.toml
testpaths: tests
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function
collecting ... collected 6 items
tests/test_plugin.py::test_slow_is_skipped_by_default PASSED [ 16%]
tests/test_plugin.py::test_runslow_runs_everything PASSED [ 33%]
tests/test_plugin.py::test_header_mentions_the_flag PASSED [ 50%]
tests/test_prices.py::test_pen_price PASSED [ 66%]
tests/test_prices.py::test_unknown_item PASSED [ 83%]
tests/test_prices.py::test_every_price_at_once SKIPPED (needs --runslow) [100%]
========================= 5 passed, 1 skipped in 0.17s =========================そしてpytest -q --runslow:
...... [100%]
6 passed in 0.40s気づく価値のあることが3つあります。
まず、プラグインはasyncテストでも動きます。test_every_price_at_onceはslowマーカー付きのasync defで、スキップは他のテストと同じように適用されます。フックは収集されたアイテムを見ます;その裏の関数がasyncかどうかはpytest-asyncioの仕事で、フックの仕事ではありません。
2つ目に、asyncio.gatherがfetch_priceを400回呼び、それぞれが0.01秒スリープし、全体の実行が1秒もかからずに終わりました — 4秒ではありません。それらは並行して待機しました。それがasyncコードが存在する理由で、テストがその振る舞いを証明しています。
3つ目に、runの中のmakeini行。pytester内部の実行は独自の設定を持つ別のプロジェクトで、インストール済みのすべてのプラグイン — pytest-asyncioを含む — を読み込みます。asyncio_default_fixture_loop_scopeが未設定だと警告が出ます。内側のプロジェクトにその1設定を与えると、外側のレポートがきれいになります。一般的な教訓:pytester実行はあなたのインストール済みプラグインを見ますが、あなたのpyproject.tomlは見ません。
プラグインが壊れたとき、これらのテストが正確にそれを伝えます。ここでスキップチェックが"slw"とタイポされていたため、何もスキップされませんでした:
> result.assert_outcomes(passed=1, skipped=1)
E AssertionError: assert {'passed': 2,...rors': 0, ...} == {'passed': 1,...rors': 0, ...}
E
E Omitting 4 identical items, use -vv to show
E Differing items:
E {'passed': 2} != {'passed': 1}
E {'skipped': 0} != {'skipped': 1}
E Use -v to get more diffその下のCaptured stdout callでは、pytestが内側の実行の出力全体を表示するので、それが実際に何をしたかが正確に分かります。
困ったとき
async def functions are not natively supported. テストがasync defで、何もそれを実行していません。pytest-asyncioがインストールされていないか、strictモードでインストールされていてテストに@pytest.mark.asyncioがありません。マーカーを付けるか、asyncio_mode = "auto"を設定してください。
requested an async fixture 'cart', with no plugin or hook that handled it async defフィクスチャがstrictモードでプレーンな@pytest.fixtureでデコレートされています。@pytest_asyncio.fixtureを使うか、autoモードに切り替えてください。
AssertionError: assert <coroutine object fetch_price at 0x...> == 15.0、続いてRuntimeWarning: coroutine 'fetch_price' was never awaited** 通常のdefテストがasync関数をawaitなしで呼び、コルーチンオブジェクトそのものを比較しました。テストをasync defにし、await fetch_price("pen")と書いてください。
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? マーカーが使われているが登録されていません。pytest_configureでconfig.addinivalue_line("markers", ...)で登録するか、markers設定で登録してください。
PluginValidationError: unknown hook 'pytest_addoptions' in plugin <module 'conftest' ...> 関数がpytest_で始まるが、pytestが知らないフックです — ここでは余分なs。pytestはプラグイン内のすべてのpytest_名を既知フックと照合するため、まさにこのタイポを捕捉します。フックリファレンスでスペルを確認してください。
ValueError: no option named '--runslow' config.getoptionが、どのpytest_addoptionも追加していないオプションを要求しました。名前が違うか、 pytest_addoptionフックがpytestが十分早く読み込まないconftest.pyにあります — テストのルートに置いてください。
pytest: error: unrecognized arguments: --run-slow コマンドラインのオプションが登録されたものと一致しません。pytest --helpはあなたのプラグインが追加したすべてのオプションを「custom options」の下に列挙します。
ImportError: Error importing plugin "pytest_slowtests": No module named 'pytest_slowtests' -pはモジュール名を取りますが、そのモジュールをインポートできませんでした。設定にpythonpath = ["."]を追加するか、プラグインをパッケージとしてインストールしてください。
ステップ 4 / 6 — 予想する
理解度をチェック
fetch_price("pen")は実際には15.0を返します。pytest -qは何と報告しますか?
# pytest-asyncio is installed, in its default (strict) mode
from prices import fetch_price
async def test_pen_price():
assert await fetch_price("pen") == 999.0- A1 skipped, 1 warning
- B1 failed — async def functions are not natively supported.
- C1 failed — assert 15.0 == 999.0
- D1 passed
strictモードではこのテストがセットアップでERRORになります。修正は何ですか?
# pyproject.toml has no asyncio_mode, so pytest-asyncio is in strict mode
import pytest
from prices import fetch_price
class Cart:
def __init__(self):
self.items = []
async def add(self, item):
self.items.append((item, await fetch_price(item)))
@pytest.fixture
async def cart():
yield Cart()
@pytest.mark.asyncio
async def test_add(cart):
await cart.add("pen")
assert cart.items == [("pen", 15.0)]- A`yield`を`return`に置き換える
- Bフィクスチャを`@pytest_asyncio.fixture`でデコレートする(または`asyncio_mode = "auto"`を設定する)
- C`@pytest.mark.asyncio`をテストから外す
- D`test_add`をプレーンな`def`テストにする
両ファイルが1つのフォルダにあります。フラグなしでpytest -qを実行すると何が表示されますか?
# conftest.py
import pytest
def pytest_addoption(parser):
parser.addoption("--runslow", action="store_true", default=False)
def pytest_configure(config):
config.addinivalue_line("markers", "slow: a slow test")
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
return
skip_slow = pytest.mark.skip(reason="needs --runslow")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)
# test_reports.py
def test_quick_total():
assert sum([15.0, 60.0]) == 75.0
@pytest.mark.slow
def test_full_year_report():
assert True- As 1 skipped
- B.F 1 passed, 1 failed
- C.. 2 passed
- D.s 1 passed, 1 skipped
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
プラグインpytest_budget.pyを書き、各テストに時間予算を与えます。
- デフォルト
0.5(秒)のオプション--budgetを追加;parser.addoptionでtype=floatを使う。 request.config.getoption("--budget")で値を返すフィクスチャbudgetを提供。pytest_configureでマーカーnetworkを登録し、pytest_report_headerでbudget: 0.5s(または与えられた値)を表示。pytest_collection_modifyitemsで、2つ目のオプション--offlineが与えられたとき、networkマーカーが付いたすべてのテストをスキップ。-p pytest_budgetで読み込み、autoモードでawait asyncio.sleep(...)がbudget未満で終わり、時間内に完了したことをアサートするasyncテストを書く。
それからpytesterでプラグインをテスト:各オプションにつき1テスト、assert_outcomesで件数を、fnmatch_linesでヘッダーをチェック。最後に故意にプラグインを壊す — pytest_report_headerをpytest_report_headersとリネーム — して、pytestが何と言うか読んでください。そのエラーを1度見れば、ずっと認識できるようになります。
解答を読む前に自分で試してください。
解答例
プロジェクトは完全な例と同じ形です:
budget/
├── pyproject.toml
├── pytest_budget.py
└── tests/
├── test_budget_plugin.py
└── test_timing.pypyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
addopts = "-p pytest_budget"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"pytest_budget.py:
"""Give every test a time budget, and let network tests be switched off."""
import pytest
def pytest_addoption(parser):
parser.addoption("--budget", type=float, default=0.5, help="seconds a test may take")
parser.addoption(
"--offline", action="store_true", default=False, help="skip tests marked network"
)
def pytest_configure(config):
config.addinivalue_line("markers", "network: the test needs a network connection")
def pytest_report_header(config):
return f"budget: {config.getoption('--budget')}s"
def pytest_collection_modifyitems(config, items):
if not config.getoption("--offline"):
return
skip_network = pytest.mark.skip(reason="--offline was given")
for item in items:
if "network" in item.keywords:
item.add_marker(skip_network)
@pytest.fixture
def budget(request):
return request.config.getoption("--budget")tests/test_timing.py:
import asyncio
import time
import pytest
async def test_finishes_within_budget(budget):
start = time.perf_counter()
await asyncio.sleep(budget / 5)
assert time.perf_counter() - start < budget
@pytest.mark.network
async def test_price_service_answers():
await asyncio.sleep(0.01) # stands in for a real request
assert Truetests/test_budget_plugin.py:
pytest_plugins = ["pytester"]
SAMPLE = """
import pytest
def test_reads_budget(budget):
assert budget == EXPECTED
@pytest.mark.network
def test_network():
assert True
"""
def run(pytester, *args, expected=0.5):
# A fresh inner project: its own config, our sample tests, our plugin.
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function\n")
pytester.makepyfile(SAMPLE.replace("EXPECTED", repr(expected)))
return pytester.runpytest("-p", "pytest_budget", *args)
def test_default_budget(pytester):
result = run(pytester)
result.assert_outcomes(passed=2)
def test_budget_option_is_a_float(pytester):
result = run(pytester, "--budget", "2", expected=2.0)
result.assert_outcomes(passed=2)
def test_offline_skips_network_tests(pytester):
result = run(pytester, "--offline")
result.assert_outcomes(passed=1, skipped=1)
def test_header_shows_budget(pytester):
result = run(pytester, "--budget", "1.5", expected=1.5)
result.stdout.fnmatch_lines(["budget: 1.5s"])
def test_marker_is_registered(pytester):
result = run(pytester, "--markers")
result.stdout.fnmatch_lines(["@pytest.mark.network: *"])pytest -v(ヘッダーは短縮):
budget: 0.5s
collecting ... collected 7 items
tests/test_budget_plugin.py::test_default_budget PASSED [ 14%]
tests/test_budget_plugin.py::test_budget_option_is_a_float PASSED [ 28%]
tests/test_budget_plugin.py::test_offline_skips_network_tests PASSED [ 42%]
tests/test_budget_plugin.py::test_header_shows_budget PASSED [ 57%]
tests/test_budget_plugin.py::test_marker_is_registered PASSED [ 71%]
tests/test_timing.py::test_finishes_within_budget PASSED [ 85%]
tests/test_timing.py::test_price_service_answers PASSED [100%]
============================== 7 passed in 0.31s ===============================pytest -q --offline -rs:
......s [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_timing.py:13: --offline was given
6 passed, 1 skipped in 0.57sそしてフックをpytest_report_headersとリネームすると、何も実行されません:
INTERNALERROR> pluggy._manager.PluginValidationError: unknown hook 'pytest_report_headers' in plugin <module 'pytest_budget' from '/home/you/budget/pytest_budget.py'>
no tests ran in 0.00s各判断の理由:
type=float。 これがないと--budget 2が文字列"2"として届き、budget / 5でTypeErrorになります。test_budget_option_is_a_floatはこれを押さえるためにあります:内側のテストがbudget == 2.0をアサートし、文字列"2"なら等しくなりません。- フィクスチャはプラグインに住む。 プラグインモジュールは
conftest.pyと同じようにフィクスチャを定義できるので、-p pytest_budgetを読み込む誰もがbudgetを得られます。テストはbudgetを要求し、その裏にオプションがあることを知りません。 - スキップは収集時に決める、テストの中でではない。
test_price_service_answersにはifがありません。1つのフックがルールを持ち、新しいnetworkテストが書かれた瞬間にカバーされます。 - タイミングテストは数値でなく余裕を主張する。 予算の5分の1だけスリープし、予算全体以内に完了したことをアサートします。これは遅いCIマシンでも成り立ちます;「0.1秒かかった」では成り立ちません。
- 1つの
runヘルパー。 すべてのpytesterテストが同じ内側プロジェクトを構築するので、セットアップ — 内側の実行でpytest-asyncioを静かにするmakeini行を含む — が1回で書けます。この解答の最初のドラフトはtest_marker_is_registeredでヘルパーを忘れ、外側の実行がその1回の内側実行から即座に1 warningを報告しました。 - 振る舞いには件数、出力には行。
assert_outcomesはプラグインがテストに何をするかをチェック;fnmatch_linesはプラグインが何を表示するかをチェック。"@pytest.mark.network: *"の*は、マーカーの説明が言い換えられてもテストが壊れないようにします。 - 壊れたフック。 pytestは
pytest_で始まるすべての関数を既知のフックと照合し、マッチしないものがあれば開始を拒否します。フック名のタイポが黙って何もしないことはあり得ません — そしてpytesterテストもそれを捕捉していたはずです。test_header_shows_budgetが決して印刷されない行を探しているからです。