設定と警告 — ルールを1つのファイルに
毎回同じフラグを打つのをやめて、プロジェクトのルールをpyproject.tomlまたはpytest.iniに置きます — testpaths、addopts、pythonpath、markers、xfail_strict、log_cli — そしてpytest.warns、deprecated_call、filterwarningsで警告をテストし制御します。
どうやって学びますか?
解決する問題
shopプロジェクトは、ほとんどの実プロジェクトと同じ形になってきました。コードはsrc/に、テストはtests/にあります。
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ └── pricing.py
└── tests/
└── test_pricing.py正しく実行する方法を学びましたが、「正しく」は長い1行になりました:
PYTHONPATH=src pytest -ra --strict-markers testsチームメイトがリポジトリをクローンして、誰でも打つであろうものを打ちます:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 0 items / 1 error
==================================== ERRORS ====================================
____________________ ERROR collecting tests/test_pricing.py ____________________
ImportError while importing test module '/home/you/shop/tests/test_pricing.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)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_pricing.py:1: in <module>
from shop.pricing import line_total
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.01s ===============================コードに問題はありません。問題は、テストの実行方法があなたの頭の中とシェルの履歴にあって、プロジェクトにはないことです。CIは3つ目のバリエーションを実行し、1つのコミットに対して3人の人が3つの結果を見ることになります。
そのフラグはあなたの好みではありません。プロジェクトのルールです。この章では、それらをpytestが毎回読むファイルに移します。そして同じファイルで、2つ目の、静かな問題 — スクロールしていって誰も読まれない警告 — も解決します。
この章を終える頃には
- pytestの設定を
pyproject.toml(またはpytest.ini)に置き、ヘッダーからどのファイルが使われたかを確認できるようになる testpaths、addopts、pythonpath、minversion、markers、xfail_strictを意図を選んで使い、理由を言えるようになる- 実行を見たいときは
log_cliでライブログをオンにする pytest.warnsとpytest.deprecated_callで、コードが警告することをアサートする。matchと記録されたリストを使う- 設定の
filterwarnings、マーク、-Wで警告を制御し、CIではエラーにする
前提条件: マーカー、skipとxfail。
テストを書く前に
この章では通常よりテストを少なく書き、テストの実行の仕方についてのルールを多く書きます。先に立つ思考は同じです。何が約束されるかを決めてから、その約束を確認するケースを計画します。
何が約束されるか。 2種類の約束があります。
- スイートの実行の仕方。 素の
pytestを打つ人は誰でも、CIと同じテスト、同じインポートパス、同じ厳しさを得ます。綴りの間違ったマーカーは実行を止めます。パスし始めたxfailは隠さずに報告されます。 - 戻り値のほかにコードが語ること。
apply_discount(total, percent)は数値を返し、percent > 50のときは、割引が「unusually large(異常に大きい)」だと言うUserWarningも出します。total_with_vat()はまだ動きますが、line_total()を指すDeprecationWarningを出します。警告は出力です。契約の一部であるなら、出力と同じようにテストされます。
用意が必要なもの。 pytest 9が入った仮想環境(pytest --version)、テストからインポートできるコード(後述のpythonpathです)、プロジェクトのルートにある設定ファイル1つ。追加のプラグインは不要です。
警告の振る舞いの計画:
| ケース | 入力 | 期待 | | --- | --- | --- | | 正常系 | apply_discount(200.0, 10) | 180.0、警告なし | | 境界、まだ静か | apply_discount(200.0, 50) | 100.0、警告なし | | 境界をわずかに超える | apply_discount(200.0, 60) | 80.0と、"unusually large"に言及するUserWarningが1つ | | 非推奨の名前 | total_with_vat(100.0, 1) | 115.0と、line_totalに言及するDeprecationWarning | | ライブラリ自身の非推奨化 | それを引き起こす任意のもの | 私たちのスイートを失敗させない |
設定自体の「テスト」は実行です。ルートからもtests/の中からも素のpytestはtests/だけを収集してパスしなければならず、綴りの間違ったマーカーは実行を止めなければなりません。
テストしてはいけないもの。 Pythonのwarningsモジュールが動くことでも、pytestが設定を読むことでもありません — それは他の人がテストしたコードです。警告の全文でもありません: 読み手が行動するフレーズ("unusually large")にマッチさせれば、残りの文言を変えても何も壊れません。サードパーティライブラリの非推奨化でもありません — それらはフィルターで除外します。これは設定の判断であって、テストではありません。
境界の行は、警告が欠けていても音を立てないので、みんなが忘れます。この章の終わりには、設定の1行がそれを無料で手に入るチェックに変えます。
1つのファイル — 何よりも先に読まれる
pytestはテストを1つも収集する前に設定を読みます。通常の置き場所は、プロジェクトがすでに持っているpyproject.tomlの、[tool.pytest.ini_options]という名前のテーブルです:
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
pythonpath = ["src"]pythonpathは、プロジェクトルートからの相対パスで、pytestがテストをインポートする前にsys.pathに追加するディレクトリを列挙します。テスト対象のコードは普通です:
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)$ PYTHONPATH=src python -c "from shop.pricing import line_total; print(line_total(15.0, 3))"
51.75
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 items
tests/test_pricing.py .. [100%]
============================== 2 passed in 0.01s ===============================ヘッダーを読んでください。configfile: pyproject.tomlは、pytestがどのファイルを使ったかを教えてくれています。設定が無視されているように見えたら、まずこの行を確認してください。
なぜテストファイルの中でインポートを直さないのですか? それは明白な間違った方法で、ときどきは動きます:
import sys
sys.path.insert(0, "src") # relative to wherever pytest was started
from shop.pricing import line_total
def test_three_items():
assert line_total(15.0, 3) == 51.75$ pytest -q
. [100%]
1 passed in 0.01s
$ cd tests && pytest -q
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sあそこの"src"はカレントディレクトリからの相対なので、テストは立っている場所によってパスしたり壊れたりします。設定の中のpythonpathは動かないプロジェクトルートからの相対です。インポートの準備はプロジェクトの仕事なので、テストモジュールごとの先頭ではなく、プロジェクトのファイルに1回だけ書きます。
どのファイルが優先されるのか、ルートはどこか
pytestは複数のファイルを受け付けます。各ディレクトリで、この順番にチェックします: pytest.toml、.pytest.toml、pytest.ini、.pytest.ini、pyproject.toml、tox.ini、setup.cfg。コマンドラインのパス(またはカレントディレクトリ)から始めて上方向にたどります。実際にpytestのセクションを持つ最初のファイルが勝ちます。pytestのテーブルを持たないpyproject.tomlは数えられません。
そのディレクトリがrootdirになります — ヘッダーのrootdir:行です — 設定の中の相対パスはそれを基準にします。ですからtests/の中にいてもルートが見つかります:
$ cd tests && pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 items同じ設定をpytest.iniに置くと、INI構文、[pytest]セクション、引用符も括弧もなし、になります:
[pytest]
pythonpath = src
testpaths = tests
addopts = -ra --strict-markers
markers =
slow: takes more than a second; deselect with -m 'not slow'1つのファイルを選んでください。両方があれば、pytest.iniが完全に勝ちます — 何もマージされません — ヘッダーにもそう表示されます。ここで、2行のpytest.ini(minversion = 9.0)がpyproject.tomlの隣に加えられました:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)
collected 0 items / 1 error…そしてModuleNotFoundErrorが戻りました。pythonpathが、今や無視されるファイルの中にいたからです。
testpaths — 何も言わないとこで探す場所
誰かが丸めを試すスクラッチファイルnotes/test_scratch.pyを置いていきました。素のpytestはルートの下のすべてを収集するので、それも実行されます:
$ pytest -q --tb=no
F.. [100%]
=========================== short test summary info ============================
FAILED notes/test_scratch.py::test_try_rounding - assert 2.67 == 2.68
1 failed, 2 passed in 0.01stestpathsは本物のスイートがどこにあるかを言います:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]$ pytest -q
.. [100%]
2 passed in 0.01s完全なヘッダーには今やtestpaths: testsも表示されます。これはデフォルトであって、囲いではありません。パスを名指しせずにルートから実行したときだけ適用されるので、pytest notesはまだスクラッチファイルを実行します。大きなリポジトリでは収集も速くなります。pytestがdocs/、build/、仮想環境のフォルダーを歩くのを止めるからです。
addoptsとmarkers — 誰にでも適用されるルール
addoptsは、あなたが打ったものの前にpytestが置くテキストです。冒頭のプロジェクトの2つのルールはここに入ります:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"-raは、素直にパスしなかったすべてのテスト — スキップ、xfailed、xpassed、失敗 — について短い要約行をprintします。スキップが見落とされることは二度とありません。--strict-markersは、登録されていないマーカーをエラーにします。新しいファイルtests/test_discounts.pyには@pytest.mark.slowでマークされたテスト(と、Windowsではスキップされるテスト)があり、マーカーはまだ登録されていません:
$ pytest -q
==================================== ERRORS ====================================
___________________ ERROR collecting tests/test_discounts.py ___________________
'slow' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR tests/test_discounts.py - Failed: 'slow' not found in `markers` configu...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sそれがこのフラグのポイントです。なければ、@pytest.mark.slwoのようなタイポは要約にPytestUnknownMarkWarningを1つ生むだけです。実行はグリーンのままで、-m "not slow"は除外するはずだったテストをまだ実行し続けます。マーカーを1行ずつ、pytest --markersが表示する説明付きで登録します:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01s
$ pytest --markers
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'SKIPPEDの行は-raが働いた結果です。スキップは今や毎回の実行で自分自身を説明します。
addoptsに入れるべきでないもの。 間違った方法はaddopts = "-v -x --pdb"です。それらは今日のデバッグのためのあなたの癖です。共有ファイルでは誰にでも、CIも含めて強制されます — CIでは--pdbが誰もいないキーボードを永遠に待ちます。addoptsにはプロジェクトのルールだけを入れてください。あなたが打ったものはその後に加算されるので、pytest -xは自分だけのためにまだ機能し、-oは1回の実行のためならどの設定も上書きします:
$ pytest -q -o addopts=""
.s.. [100%]
3 passed, 1 skipped in 0.01s今回はスキップ行がありません。その実行では-raが落とされたからです。
minversionとxfail_strict
minversionは、誰かのpytestがそのファイルには古すぎるときに、実行を直ちに止めます。存在しないバージョンを要求すると、次のメッセージが表示されます:
$ pytest
ERROR: /home/you/mv/pytest.ini: 'minversion' requires pytest-10.0, actual pytest-9.1.1'起動時の明確なエラーは、設定を半分しか理解しない古いpytestよりましです。
xfail_strictは前章の残りを処理します:パスし始めたxfailです。ここでバグが失敗予定としてマークされ、その後誰かがそれを修正しました:
import pytest
def refund(amount):
return round(amount * 0.9, 2)
@pytest.mark.xfail(reason="bug #41: refunds lose a cent")
def test_refund_keeps_cents():
assert refund(10.0) == 9.0$ pytest -q
X [100%]
1 xpassed in 0.01sグリーンの実行、誰も読まないX、そして今や嘘をつくマーカー。設定にxfail_strict = trueを置くと:
$ pytest -q
F [100%]
=================================== FAILURES ===================================
___________________________ test_refund_keeps_cents ____________________________
[XPASS(strict)] bug #41: refunds lose a cent
=========================== short test summary info ============================
FAILED test_refunds.py::test_refund_keeps_cents - [XPASS(strict)] bug #41: re...
1 failed in 0.01sいい知らせは今や、マーカーを外すよう伝える失敗として届きます。テストごとではなくプロジェクト全体に設定する価値があるのはそのためです。pytest 9.1は新しい名前strict_xfail、そしてすべての厳格化オプション(マーカーとxfailを含む)を一度にオンにする1つのstrict = trueも受け付けます。xfail_strictはそのまま動き続けます。
ライブログ: log_cli
デフォルトではpytestはログレコードを捕捉し、失敗したテストのときだけ表示します。パスする実行がその物語を語るのを見るには、ライブログをオンにします:
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"すると各テストのINFO以上のレコードが、実行中にlive log call見出しの下にprintされます。完全な例は後述します。ライブログはうるさいので、多くのプロジェクトはファイルにlog_cli_levelだけを置き、必要なときにpytest -o log_cli=trueで出力をオンにします。
pytest 9のネイティブなテーブル: [tool.pytest]
[tool.pytest.ini_options]には理由があって変な名前がついています。TOMLファイルの中に座っているINI設定だからです。すべての値がテキストに変えられます。pytest 9はネイティブなテーブル[tool.pytest]を加え、そこで値はTOMLの型のまま — 本物のリスト、本物の真偽値 — になります:
[tool.pytest]
minversion = "9.0"
pythonpath = ["src"]
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers"]
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
strict_xfail = true$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01saddoptsは今やリストです。ルールは2つ。どちらか一方のテーブルを使い、1つのファイルに両方は使わないこと(pytestは起動を拒否します)。そして9より前のpytestは[tool.pytest]をまったく読まないことを覚えておいてください。この章は[tool.pytest.ini_options]を使い続けます。出会うかもしれないすべてのpytestで動くからです。pytest 9だけを動かすプロジェクトでは、ネイティブなテーブルが推奨される形式です。pytest 9は、同じネイティブなスタイルの[pytest]テーブルを持つスタンドアロンのpytest.tomlも読みます。
キャッシュについて、簡単に
実行のあと、ルートに.pytest_cache/フォルダーがあります。前回どのテストが失敗したかを覚えています。それが--lf(last failed)と--ff(failed first)を動かす仕組みです。知っておく価値のあるフラグが2つあります。pytest --cache-clearは実行の前にそれを空にします — --lfがもう存在しないテストを選び続けるときに便利です。pytest -p no:cacheproviderはキャッシュプラグインを完全にオフにし、何も書き込ませません。読み取り専用のチェックアウトに適しています。代償は、プラグインのオプションも一緒に消えることです:
$ pytest -q -p no:cacheprovider --lf
ERROR: usage: pytest [options] [file_or_dir] [file_or_dir] [...]
pytest: error: unrecognized arguments: --lf
inifile: /home/you/mv/pytest.ini
rootdir: /home/you/mv警告 — 実行の最後に出るセクション
警告は、何かがまだ間違っていないというメッセージです。Pythonはそれをprintして続けます:
import warnings
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
return round(total * (100 - percent) / 100, 2)$ python -c "from pricing import apply_discount; print(apply_discount(200.0, 60))"
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
80.0stacklevel=2は、非推奨の指標を呼び出し側の行 — 誰かが変える必要のある行 — に向けさせます。pytestはテスト中に送出された警告を捕まえ、最後に1つのセクションにまとめます:
from pricing import total_with_vat
def test_old_name_still_works():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
. [100%]
=============================== warnings summary ===============================
test_old_api.py::test_old_name_still_works
/home/you/wn/test_old_api.py:5: DeprecationWarning: total_with_vat() is deprecated; use line_total()
assert total_with_vat(100.0, 1) == 115.0
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
1 passed, 1 warning in 0.01sテストはパスし、最後の行は1 passed, 1 warningと言っています。大きなスイートではこのセクションは何十もの項目に育ち、みんながそれをスクロールして通り過ぎるのを覚え、ある日非推奨の関数が削除されて「パスしていた」スイートが壊れます。警告にできることは2つ。振る舞いの一部なら期待する、そうでなければ拒むです。
pytest.warns — 警告を期待する
警告が契約の一部なら — 計画の「境界をわずかに超える」の行です — pytest.raisesで例外をテストするのと同じ方法でテストします:
import pytest
from pricing import apply_discount
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large"):
result = apply_discount(200.0, 60)
assert result == 80.0
def test_small_discount_is_quiet():
assert apply_discount(200.0, 10) == 180.0$ pytest -q test_discount.py
.. [100%]
2 passed in 0.01sそのブロックは、内部でUserWarning(またはサブクラス)が少なくとも1つ送出され、かつそのメッセージがmatch — テキスト内で検索される正規表現です、pytest.raisesと同じ — にマッチしたときにパスします。どちらが欠けていても、はっきりと失敗します。ここではコードは50で静かで、2つ目のテストは間違った単語を探しています:
import pytest
from pricing import apply_discount
def test_fifty_percent_warns():
with pytest.warns(UserWarning):
apply_discount(200.0, 50)
def test_wrong_message():
with pytest.warns(UserWarning, match="too large"):
apply_discount(200.0, 60)$ pytest -q test_discount_bad.py
FF [100%]
=================================== FAILURES ===================================
___________________________ test_fifty_percent_warns ___________________________
def test_fifty_percent_warns():
> with pytest.warns(UserWarning):
E Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted.
E Emitted warnings: [].
test_discount_bad.py:7: Failed
______________________________ test_wrong_message ______________________________
def test_wrong_message():
> with pytest.warns(UserWarning, match="too large"):
E Failed: Regex pattern did not match any of the 1 warnings emitted.
E Regex: 'too large'
E Emitted warnings: [UserWarning('discount of 60% looks unusually large')].
test_discount_bad.py:12: Failed
=============================== warnings summary ===============================
test_discount_bad.py::test_wrong_message
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED test_discount_bad.py::test_fifty_percent_warns - Failed: DID NOT WARN....
FAILED test_discount_bad.py::test_wrong_message - Failed: Regex pattern did n...
2 failed, 1 warning in 0.01s両方のレポートは実際に送出された警告を列挙します。通常、テストを直すために必要なのはこれだけで十分です。マッチしなかった警告も飲み込まれません。warnings summaryに進みます。
なぜ常にmatchを与えるのか。 pytest.warns(UserWarning)だけは弱い形式です。ブロック内のどこかからのあらゆるUserWarningで満足してしまいます — 無関係の理由で偶然呼んでいるライブラリからのものも含みます。気にしている警告が消えた後も、テストはパスし続けるかもしれません。マッチしたフレーズが、テストをこの警告に钉付けにします。
何個の警告が発火し、何と言ったかを確認するには、asで記録を残します:
import pytest
from pricing import apply_discount
def test_each_large_discount_warns_once():
with pytest.warns(UserWarning) as record:
apply_discount(100.0, 60)
apply_discount(100.0, 10)
apply_discount(100.0, 75)
assert len(record) == 2
print(record[0].message)
print(record[1].category.__name__, record[1].lineno)$ pytest -q -s test_record.py
discount of 60% looks unusually large
UserWarning 19
.
1 passed in 0.01srecordはブロックで捕まえたすべての警告を列挙します — 2つです。10%の呼び出しは静かだからです。各項目には.message、.category、.filename、.linenoがあります。
pytest.deprecated_call — ユーザーへの約束
関数を非推奨にすることは約束です。「これはまだ動くし、あなたは知らされている」。pytest.deprecated_call()は両方の半分をチェックします:
import pytest
from pricing import total_with_vat
def test_old_name_still_works():
with pytest.deprecated_call():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q test_old_api.py
. [100%]
1 passed in 0.01sこれはカテゴリを埋めたpytest.warns — DeprecationWarning、PendingDeprecationWarning、FutureWarning — で、matchも受け付けます。warnings summaryの項目は消えます。警告は期待されていたので、消費されたからです。
filterwarnings — 警告を拒む
期待された警告は今やテストです。残りはノイズか早期警報であり、取り入れる価値のあるCIの習慣は、予想外の警告はすべてエラーです。1行:
[tool.pytest.ini_options]
filterwarnings = [
"error",
]きれいなテスト1つ、まだ非推奨の名前を呼んでいるテスト1つ、編集できないライブラリのcurrency.rate()を呼び、それ自身のDeprecationWarningを送出するテスト1つからなるスイートです:
from currency import rate
from pricing import line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0
def test_rate():
assert rate("USD") == 1.0$ pytest -q --tb=short
.FF [100%]
=================================== FAILURES ===================================
________________________________ test_old_name _________________________________
test_shop.py:10: in test_old_name
assert total_with_vat(100.0, 1) == 115.0
^^^^^^^^^^^^^^^^^^^^^^^^
pricing.py:9: in total_with_vat
warnings.warn(
E DeprecationWarning: total_with_vat() is deprecated; use line_total()
__________________________________ test_rate ___________________________________
test_shop.py:14: in test_rate
assert rate("USD") == 1.0
^^^^^^^^^^^
currency.py:6: in rate
warnings.warn("rate() will need an API key from v3", DeprecationWarning)
E DeprecationWarning: rate() will need an API key from v3
=========================== short test summary info ============================
FAILED test_shop.py::test_old_name - DeprecationWarning: total_with_vat() is ...
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
2 failed, 1 passed in 0.01s各警告は今や例外としてraiseされ、それを引き起こした行へのトレースバックが付きます。すべての失敗は、一律の沈黙ではなく、判断を得ます。
間違った方法は"ignore::DeprecationWarning"です。1行で全部グリーンになりますが、あなた自身の非推奨化がライブラリのと一緒に隠されます。正しい方法は、各例外を狭めることです。フィルターの形はaction:message:category:moduleです — Python自身の-Wオプションと同じです — messageは警告テキストの先頭にマッチし、moduleは警告が帰属するモジュールです:
[tool.pytest.ini_options]
filterwarnings = [
"error",
"ignore::DeprecationWarning:currency",
]順序が重要です。後の行が優先されるので、errorが最初に、そのあとに例外が来ます。ライブラリの警告は、currencyから来るときにだけ無視されるようになりました。1つのテストには、同じフィルターをマークとして付けることができます — ここではtest_old_nameに。ファイルの残りは変更なしです:
import pytest
from pricing import total_with_vat
@pytest.mark.filterwarnings("ignore:total_with_vat:DeprecationWarning")
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
... [100%]
3 passed in 0.01sそのマークは警告を許可するだけです。「これは警告しなければならない」という意味なら、pytest.deprecated_call()の方が強い選択です。警告が消えたときにも失敗するからです。容認する警告にはマークを、約束する警告にはwarnsを使ってください。
同じフィルターは-Wで1回の実行に与えられます。方針を書き下す前に試すのに便利です。設定がまったくない、先のtest_old_api.pyで:
$ pytest -q -W error::DeprecationWarning --tb=no -ra
F [100%]
=========================== short test summary info ============================
FAILED test_old_api.py::test_old_name_still_works - DeprecationWarning: total...
1 failed in 0.01sソースが食い違うとき、最も具体的なものが勝ちます。テスト上のマークが-Wに勝ち、-Wが設定ファイルに勝ちます。3つのテストのスイートでは、-Wがcurrencyへの設定のignoreを上書きし、マークされたテストは免除を保ちます:
$ pytest -q -W error::DeprecationWarning --tb=no -ra
..F [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
1 failed, 2 passed in 0.01serrorがもう1つもたらすもの。計画の境界の行、「50では警告なし」を振り返ってください。設定にerrorがあれば、apply_discount(200.0, 50)の普通のテストがすでにそれをチェックしています。その呼び出しが警告したら、警告は例外になり、テストは失敗するからです。静かなケースに特別なコードは要りません。
完全な例
すべてのルールを1か所に置いたshopプロジェクト。pyproject.toml:
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]
log_cli_level = "INFO"src/shop/pricing.py:
import logging
import warnings
log = logging.getLogger(__name__)
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
log.info("discount %d%% on %.2f", percent, total)
return round(total * (100 - percent) / 100, 2)$ PYTHONPATH=src python -c "from shop.pricing import apply_discount; print(apply_discount(200.0, 10))"
180.0tests/test_pricing.py — 冒頭の計画を1行ずつ:
import pytest
from shop.pricing import apply_discount, line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name_is_deprecated():
with pytest.deprecated_call(match="use line_total"):
assert total_with_vat(100.0, 1) == 115.0
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large") as record:
assert apply_discount(200.0, 60) == 80.0
assert len(record) == 1
def test_fifty_percent_is_quiet():
assert apply_discount(200.0, 50) == 100.0
@pytest.mark.slow
def test_many_lines():
total = sum(line_total(1.0, 1) for _ in range(10_000))
assert round(total, 2) == 11500.0
@pytest.mark.xfail(reason="bug #41: zero quantity should be free, not an error")
def test_zero_quantity():
assert line_total(15.0, 0) == 0.0$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_pricing.py .....x [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
========================= 5 passed, 1 xfailed in 0.01s =========================
$ pytest -m "not slow" -o log_cli=true
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items / 1 deselected / 5 selected
tests/test_pricing.py::test_line_total PASSED [ 20%]
tests/test_pricing.py::test_old_name_is_deprecated PASSED [ 40%]
tests/test_pricing.py::test_large_discount_warns
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 60% on 200.00
PASSED [ 60%]
tests/test_pricing.py::test_fifty_percent_is_quiet
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 50% on 200.00
PASSED [ 80%]
tests/test_pricing.py::test_zero_quantity XFAIL (bug #41: zero quant...) [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
================== 4 passed, 1 deselected, 1 xfailed in 0.01s ==================気づく価値のあることが3つあります。
まず、どちらのコマンドにもプロジェクトのルールはありません。ルール — テストの場所、インポート可能なもの、strict markers、strict xfail、警告をエラーにすること — はすべてファイルにあるので、素のpytestを打つチームメイトは正確に最初の実行を得ます。CIも同じです。
2つ目に、warnings summaryがありません。コードが出す2つの警告はどちらも期待されたものなので、warnsとdeprecated_callが消費しました。その他の警告があれば、どれかのテストを失敗させていたはずです。きれいな実行は今や本当にきれいです。
3つ目に、test_fifty_percent_is_quietには警告のコードがありませんが、境界を守っています。errorのもとでは、50%での警告がそれを失敗させるからです。
問題が起きたとき
収集中のModuleNotFoundError: No module named 'shop' コードがテストからインポートできません。設定にpythonpath = ["src"]を追加し、ヘッダーのconfigfile:行を確認して、そのファイルが読まれているものだと確かめてください。
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!) 設定ファイルが2つあり、あなたが編集した方は負けました。何もマージされません。1つを削除してください。
`'slow' not found in markers configuration option` --strict-markers(またはstrict = true)がオンで、マーカーが未登録か綴りが間違っています。markersの下に登録するか、綴りを直してください。
PytestConfigWarning: Unknown config option: testpath 綴りの間違った設定です — testpathsをtestpathと。pytestは警告するだけなので、設定は何の働きもせず静かに終わります。strict_config = true(またはstrict = true)があれば、ERROR: Unknown config option: testpathになり、実行が止まります。それが欲しいものです。
ERROR: ... Cannot use both [tool.pytest] (native TOML types) and [tool.pytest.ini_options] (string-based INI format) simultaneously. pyproject.tomlに両方のテーブルがあります。すべてを1つに移してください。
TypeError: ... config option 'addopts' expects a list for type 'args', got str: '-ra --strict-markers' ネイティブな[tool.pytest]テーブルではaddoptsはリストです: addopts = ["-ra", "--strict-markers"]。文字列の形式は[tool.pytest.ini_options]のものです。
ERROR: ... 'minversion' requires pytest-10.0, actual pytest-9.1.1' インストールされているpytestがプロジェクトが必要とするより古くです。仮想環境の中でアップグレードしてください。
Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted. そのカテゴリのものはブロック内で送出されていません。下のEmitted warnings:を読んでください — たいてい、コードが別のカテゴリで警告しているか、この入力では警告していないのです。
Failed: Regex pattern did not match any of the 1 warnings emitted. カテゴリは正しいが、テキストが間違っています。matchは正規表現なので、(、.、?はエスケープが必要です — あるいはテキストをre.escape()で包んでください。
AttributeError: module 'builtins' has no attribute 'DeprecatedWarning'. Did you mean: 'DeprecationWarning'? filterwarnings行のカテゴリのタイポです。pytestは起動を拒否し、解析できなかった行をその少し上でprintします。
ステップ 4 / 6 — 予想する
理解度をチェック
これはpytest -q -sで実行されます。テストは何をprintしますか?
import warnings
import pytest
def check(n):
if n < 0:
warnings.warn("negative value", UserWarning)
return abs(n)
def test_record():
with pytest.warns(UserWarning) as record:
check(-1)
check(2)
check(-3)
print(len(record))- A3
- B0
- C2
- D1
プロジェクトルートに、[tool.pytest.ini_options]を持つpytest.iniとpyproject.tomlの両方があります。pytestを実行するとどうなりますか?
- Apytestは2つの設定ファイルがあるというエラーで止まる
- B`pytest.ini`が使われ、`pyproject.toml`の設定は無視され、ヘッダーがそれを警告する
- C`pyproject.toml`が勝つ — より新しい標準だから
- D2つのファイルがマージされ、穴を`pyproject.toml`が埋める
設定にaddopts = -ra --strict-markersがあり、markersにはslowだけが登録されています。pytest -m "not slow"でどうなりますか?
import pytest
@pytest.mark.slwo
def test_every_day_of_a_year():
fees = [day * 5 for day in range(366)]
assert fees[-1] == 1825- Aテストは実行され、要約に警告が出る
- B意図どおり、テストは選択から外れる
- C収集が止まる: 'slwo' not found in `markers` configuration option
- Dテストはスキップされる
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
libraryプロジェクトは延滞料金を課します。src/library/fees.py:
import warnings
PER_DAY = 5
def late_fee(days):
if days < 0:
raise ValueError("days cannot be negative")
if days > 60:
warnings.warn(f"{days} days late looks like a lost book", UserWarning)
return days * PER_DAY
def fine(days):
warnings.warn(
"fine() is deprecated; use late_fee()",
DeprecationWarning,
stacklevel=2,
)
return late_fee(days)$ PYTHONPATH=src python -c "from library.fees import late_fee; print(late_fee(4))"
20[tool.pytest.ini_options]テーブルを書いて、素のpytestがルートからでもtests/の中からでもlibraryをインポートし、tests/だけを収集し、-raの要約をprintし、未知のマーカーを拒否し、予想外のxfailのパスで失敗し、予想外の警告をすべてエラーにします。slowマーカーを登録します。- まずテスト計画を書きます — ケース、入力、期待 — 期限内の返却、60日の境界、61日、負の数、非推奨の
fine()をカバーします。 - その計画から
tests/test_fees.pyを書きます。1年間のすべての日をなでるslowテストを1つ含めます。 pytest、次にpytest -m "not slow"、次にcd tests && pytestを実行します。
次に2つの実験です。結果を先に予測してから行います。deprecated_callのテストをfine(3)の素の呼び出しに置き換えます。そしてslowをslwoと綴り間違えます。
解答例
計画:
| ケース | 入力 | 期待 | | --- | --- | --- | | 期限内に返却 | late_fee(0) | 0、警告なし | | 境界、まだ静か | late_fee(60) | 300、警告なし | | 境界をわずかに超える | late_fee(61) | 305と、"lost book"に言及するUserWarningが1つ | | 不正な入力 | late_fee(-1) | "negative"に言及するValueError | | 非推奨の名前 | fine(3) | 15と、late_feeに言及するDeprecationWarning | | 1年間(slow) | late_fee(0) … late_fee(365) | 最後の料金1825、警告が期待される |
pyproject.toml:
[project]
name = "library"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]tests/test_fees.py:
import pytest
from library.fees import fine, late_fee
def test_returned_on_time():
assert late_fee(0) == 0
def test_sixty_days_is_quiet():
# filterwarnings = error: any warning here would fail this test
assert late_fee(60) == 300
def test_sixty_one_days_warns():
with pytest.warns(UserWarning, match="lost book") as record:
assert late_fee(61) == 305
assert len(record) == 1
def test_negative_days_rejected():
with pytest.raises(ValueError, match="negative"):
late_fee(-1)
def test_fine_is_deprecated():
with pytest.deprecated_call(match="use late_fee"):
assert fine(3) == 15
@pytest.mark.slow
def test_every_day_of_a_year():
with pytest.warns(UserWarning):
fees = [late_fee(day) for day in range(366)]
assert fees[-1] == 1825$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/library
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_fees.py ...... [100%]
============================== 6 passed in 0.01s ===============================
$ pytest -m "not slow" -q
..... [100%]
5 passed, 1 deselected in 0.01s
$ cd tests && pytest -q
...... [100%]
6 passed in 0.01s2つの実験:
$ pytest -q # test_fine_still_works calls fine(3) with no deprecated_call
=========================== short test summary info ============================
FAILED tests/test_fees.py::test_fine_still_works - DeprecationWarning: fine()...
1 failed, 5 passed in 0.01s
$ pytest -q # @pytest.mark.slwo
ERROR tests/test_fees.py - Failed: 'slwo' not found in `markers` configuratio...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s各判断の理由:
pythonpathとtestpathsはコマンドラインではなくファイルに。 だからcd tests && pytestでも同じ6つのパスが出ます。どちらもrootdirからの相対で、pytestはpyproject.tomlまで上に歩いて見つけます。addoptsにはルールだけ。-raと--strict-markersは誰もが共有する方針です。-vや-xは個人の癖で、自分のコマンドラインに置いたままにします。filterwarnings = ["error"]に例外なし。 プロジェクトにはまだサードパーティの警告がなく、免除すべきものもありません。1つ現れた日に、狭いignore::...:module行を追加してください。一律のignoreは決して使わないでください。- 60日のテストには警告のコードがない。
errorのもとでは、60日でのあらゆる警告がそれを失敗させるので、設定が境界を守ります。 - 1つのメッセージを狙うすべてのチェックに
matchを。 "lost book"と"use late_fee"は読み手が行動する単語です。残りは自由に言い換えられます。1年間のテストは意図的にmatchを省いています。料金についてのものなので、そこで警告が期待されると言うだけで十分だからです。 - 61日のテストの
record。 1回の呼び出しが1回だけ警告すること — 内部の各ステップでではなく — を証明します。 - 実験。
deprecated_callがないと非推奨化は予想外になるので、errorがそれを失敗に変えます。誰かが古いAPIを呼んだときにCIがすべきこととまさに同じです。綴りの間違ったマーカーは、--strict-markersがaddoptsにあるので、収集を止めます。(ここでは未知のマーカーがPytestUnknownMarkWarningを送出するので、errorフィルターもそれを捕まえていました — しかしマーカーのルールは警告の方針に依存してはいけないので、両方を残します。)