ディスカバリとテストの実行 — pytestが実行するものとその選び方
pytestがテストとして扱うファイル・関数・クラスのルール、src/とtests/構成でのコードのインポート、ノードID、そして-k、-x、--lf、--ff、--coを使って必要なテストだけを過不足なく実行する方法を学びます。
どうやって学びますか?
解決する問題
これまでは、テストするコードの隣にある1つのファイルにすべてのテストを配置し、素のpytestを実行するだけで十分でした。しかし、実際のプロジェクトがこれほど小さいままでいることはありません。コードはパッケージに移動し、テストは専用のフォルダに配置され、その直後の実行結果は次のようになります:
$ pytest -q
==================================== ERRORS ====================================
_____________________ ERROR collecting tests/test_cart.py ______________________
ImportError while importing test module '/home/you/shop/tests/test_cart.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_cart.py:1: in <module>
from shop.cart import Cart
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_cart.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sテストは1つも実行されませんでした。コード自体に問題はなく、pytestがそれを見つけることができなかっただけです。
その問題を解決すると、2つ目の問題が生じます。テストが何十個にも増え、そのうちの1つが失敗したとき、再実行するたびにすべてのテストが毎回実行されてしまうのです。実行したいのはそのテストだけ — あるいは前回失敗したテストだけです。
どちらの問題も原因は同じところにあります。pytestは何かを実行する前に、収集(コレクション)を行います。フォルダを探索し、ファイルをインポートして、テストの一覧を構築するのです。この章では、そのリストがどのように構築されるのか、そしてそこからどのようにテストを選択するのかについて学びます。
この章を終える頃には
pytestが何をもってテストと判断するのか、そのルールを説明できるようになるsrc/とtests/でプロジェクトを構成し、テストからコードをインポートできるように設定できるようになる- クラスを使ってテストをグループ化し、
tests/test_cart.py::TestCart::test_total_adds_taxのようなノードIDを読み取れるようになる - 1つのファイル、1つのクラス、1つのテスト、あるいは
-kによる選択でテストを実行できるようになる - 日常業務で
-x、--maxfail、--lf、--ff、--co、--durationsを活用できるようになる pytestの終了コードを読み取り、それが何を意味しているかを説明できるようになる
前提条件: assertと失敗レポートの読み方。
テストを書く前に
この章では、テストコードの最初の1行を書く前の問いは、何をテストするかだけでなく、テストをどこに配置し、`pytest`がそれをどのように見つけるかでもあります。収集(コレクト)されないテストは、テストがまったく存在しないことよりも有害です。保護されているように見えて、実際には保護されていないからです。
1. 仕様(約束事)を把握する。 テスト対象となるコードは、小さなオンラインショップのパッケージです:
apply_discount(price, percent)は、価格からpercent分の割引を適用し、小数第2位に四捨五入した値を返します。0〜100の範囲外の割合にはValueErrorを送出します。add_tax(price, rate=0.15)は、税を加算した価格を小数第2位に四捨五入して返します。Cartは(name, price, quantity)の項目を保持します。subtotal()は数量を掛け合わせた小計を計算し、total()は税込みの合計を計算します。
2. 開発環境を準備する。 pytestがインストールされた仮想環境、src/shop/に配置されたコード、そして — この章のテーマである — テストからimport shopできるようにする方法です。この最後の部分が欠けていれば、他のすべては何の意味も持ちません。
3. ケースを計画し、それぞれの配置場所を決める。 1モジュールにつき1つのテストファイル(tests/test_pricing.pyはsrc/shop/pricing.pyに対応)を用意し、カートのテストは1つのクラスにグループ化します。計画にノードIDを書き込んでおくことで、事前に配置場所を決定せざるを得なくなります:
| ケース | 入力 | 期待値 | ノードID | |---|---|---|---| | 通常の割引 | apply_discount(200.0, 10) | 180.0 | tests/test_pricing.py::test_discount_ten_percent | | 境界値:割引なし | apply_discount(200.0, 0) | 200.0 | tests/test_pricing.py::test_discount_zero | | デフォルト税率 | add_tax(100.0) | 115.0 | tests/test_pricing.py::test_add_tax_default_rate | | エッジケース:空のカート | Cart().subtotal() | 0 | tests/test_cart.py::TestCart::test_empty_cart_subtotal | | 数量の計算 | 1個15.0のペンを3本 | 45.0 | tests/test_cart.py::TestCart::test_subtotal_counts_quantity | | 税込みの合計 | 1個100.0のバッグを1個 | 115.0 | tests/test_cart.py::TestCart::test_total_adds_tax | | 不正な割引率 | apply_discount(200.0, 150) | ValueError | 今後扱います — 例外のテストには専用の章があります |
4. テストしないものを決める。 round()やlist.appendをテストしてはいけません — Python自体の挙動はあなたのコードが約束する仕様ではありません。Cart.itemsがタプルのリストであることをテストしてもいけません。それは明日変更するかもしれない内部実装の詳細です。呼び出し側から見えるもの、すなわちsubtotal()とtotal()をテストしてください。
ここから、まさにこの計画に沿って実装を進めていきます。その過程で直面するすべての問題は、ディスカバリ(テスト収集)に関する問題です。
ディスカバリのルール
pytestは見つかったものを手当たり次第に実行するわけではありません。4つのルールに従っており、これらに当てはまらないものは何も告げずに無視されます:
test_*.pyまたは*_test.pyという名前のファイル- それらのファイル内の、名前が
testで始まる関数 - 名前が
Testで始まり、かつ__init__メソッドを持たないクラス - そのようなクラス内の、名前が
testで始まるメソッド
「すべての関数を実行する」のではなく、なぜわざわざルールを設けているのでしょうか?それは、プロジェクト内にはテストではない関数(ヘルパー関数やテスト対象のコードそのもの)がたくさん含まれており、明示的な登録なしにそれらを判別する方法をpytestが必要としているからです。
これらのルールを最も手っ取り早く実感するには、意図的にルールを破ってみることです。以下はtest_rules.pyです:
def test_found():
assert True
def check_not_found():
assert False
class TestGroup:
def test_method_found(self):
assert True
def helper(self):
assert False
class CartTests:
def test_ignored(self):
assert False
class TestWithInit:
def __init__(self):
self.value = 1
def test_never_runs(self):
assert self.value == 1その隣に、パスするtest_suffix_styleが1つ入ったrules_test.pyと、assert Falseを実行するtest_in_helpersが入ったhelpers.pyを置きます。ここでpytest -vを実行してみます:
collecting ... collected 3 items
rules_test.py::test_suffix_style PASSED [ 33%]
test_rules.py::test_found PASSED [ 66%]
test_rules.py::TestGroup::test_method_found PASSED [100%]
=============================== warnings summary ===============================
test_rules.py:22
/home/you/disc/test_rules.py:22: PytestCollectionWarning: cannot collect test class 'TestWithInit' because it has a __init__ constructor (from: test_rules.py)
class TestWithInit:
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.01s =========================そのフォルダ内には5行のassert Falseが存在するにもかかわらず、テスト結果はグリーンです。何がスキップされ、その理由は何だったのでしょうか:
check_not_found— 名前がtestで始まっていません。TestGroup.helper— テストクラス内にありますが、メソッド名がtestで始まっていません。ヘルパーメソッドの定義は許可されていますが、テストとしては扱われません。CartTests— クラス名はTestで始まる必要があります。TestWithInit—pytestはテストごとに引数なしでクラス自体のインスタンスを生成する必要があります。独自の__init__を持つクラスではそれが保証されないためスキップされます — この実行全体で唯一の警告が表示されています。helpers.py— ファイル名がどちらのパターンにも一致しないため、インポートすらされません。
警告もなく静かに無視されることこそが危険です。 誤ってdef tset_total():と書いてしまうと、テストスイートは永久にグリーンのままになります。新しいテストがあまりにもあっけなく「パス」したときは、そもそも収集されているかどうかを確認してください — その方法は後述の--coです。
実践的な構成:src/とtests/
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── cart.py
│ └── pricing.py
└── tests/
├── test_cart.py
└── test_pricing.pyコードはsrc/shop/に配置され、テストはtests/に配置されてパッケージと一緒に出荷されることはありません。これがsrcレイアウト(src layout)です。この構成の利点は、たまたまプロジェクトフォルダにいるというだけの理由でコードが「偶然」インポートされてしまうのを防ぎ、実際のユーザーと同じ方法でテストからインポートできるようにする点にあります。
src/shop/pricing.py:
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError(f"percent must be between 0 and 100: {percent}")
return round(price * (100 - percent) / 100, 2)
def add_tax(price, rate=0.15):
return round(price * (1 + rate), 2)src/shop/cart.py:
from shop.pricing import add_tax
class Cart:
def __init__(self):
self.items = []
def add(self, name, price, quantity=1):
self.items.append((name, price, quantity))
def subtotal(self):
return sum(price * quantity for _, price, quantity in self.items)
def total(self):
return add_tax(self.subtotal())tests/test_pricing.py — 計画の最初の3行分です:
from shop.pricing import add_tax, apply_discount
def test_discount_ten_percent():
assert apply_discount(200.0, 10) == 180.0
def test_discount_zero():
assert apply_discount(200.0, 0) == 200.0
def test_add_tax_default_rate():
assert add_tax(100.0) == 115.0このfrom shop.pricing import ...こそが、冒頭で発生したModuleNotFoundErrorの原因です。
なぜshopが見つからないのか
import shopが成功するのは、sys.pathに含まれるいずれかのフォルダにshopフォルダが入っている場合だけです。パッケージはsrc/の中にあり、src/はsys.pathに含まれていません。pytestは各テストファイルのフォルダ(ここではtests/)をsys.pathに追加するためテスト同士でインポートはできますが、src/を追加するものは何もありません。
間違ったやり方は、テストファイル内でsys.pathを書き換えることです:
import sys
sys.path.insert(0, "src")
from shop.cart import Cart
def test_empty_cart_subtotal():
assert Cart().subtotal() == 0プロジェクトフォルダから実行すればパスします。しかし、誰かがtests/の中から実行したとします:
$ cd tests
$ pytest -q
==================================== ERRORS ====================================
________________________ ERROR collecting test_cart.py _________________________
ImportError while importing test module '/home/you/shop/tests/test_cart.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_cart.py:5: in <module>
from shop.cart import Cart
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_cart.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s"src"は現在いる場所からの相対パスであるため、コマンドを入力したフォルダにテストが依存してしまうことになります。その上、すべてのテストファイルに同じ3行を書かなければなりません。これには2つの正しい方法があります。
正しい方法1:コードの場所をpytestに伝える。 pyproject.tomlに記述します:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]pythonpathは、テストがインポートされる前にsrc/をsys.pathに追加します — これは現在いる場所からではなく、プロジェクトルートからの相対パスとして解決されます。testpathsは、通常のpytestを実行したときに探索する場所を指定します。これで次のようになります:
$ 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
testpaths: tests
collected 6 items
tests/test_cart.py ... [ 50%]
tests/test_pricing.py ... [100%]
============================== 6 passed in 0.01s ===============================ヘッダーのconfigfile: pyproject.tomlは、pytestが設定を読み込んだ証拠です。設定ファイルについては後の章で詳しく扱いますが、現時点ではこの2行だけで十分です。
正しい方法2:パッケージをインストールする。 pyproject.tomlに本物のパッケージ定義([build-system]と[project]テーブル)が含まれている場合は、pip install -e .を1回実行します。これによりパッケージが編集可能(editable)モードでインストールされます。import shopがテスト、REPL、スクリプトなどあらゆる場所で機能するようになり、src/への変更も再インストールなしで即座に反映されます。インストール後は、pythonpathを設定しなくてもpytest -qで同じく6 passedが得られます。パッケージ化された多くのプロジェクトではこの方法が使われます。プロジェクトがパッケージではない場合は、pythonpathを使う方が手軽な選択肢です。
クラスでテストをグループ化する
tests/test_cart.pyでは、計画にあるカート関連のテストを1つのクラスにまとめます:
from shop.cart import Cart
class TestCart:
def test_empty_cart_subtotal(self):
assert Cart().subtotal() == 0
def test_subtotal_counts_quantity(self):
cart = Cart()
cart.add("pen", 15.0, quantity=3)
assert cart.subtotal() == 45.0
def test_total_adds_tax(self):
cart = Cart()
cart.add("bag", 100.0)
assert cart.total() == 115.0$ pytest -q tests/test_cart.py
... [100%]
3 passed in 0.01s継承すべき基底クラスはありません — TestCartはごく普通のクラスであり、selfは単に必要な第1引数にすぎません。なぜわざわざクラスを使うのでしょうか?それは、クラスによって関連するテストに1つの名前を与えることができ、その名前を使ってテストを選択できるようになるからです。引数を1つ指定するだけで「カート関連のすべてのテスト」を実行できます。
注意すべきなのは、クラスは状態を共有するためのものではないということです。pytestはテストメソッドごとに新しいインスタンスを生成するため、あるテストでselfに保存された値は次のテストには残りません。これは意図的な設計です — 他のテストの残骸に依存するテストは、実行順序によってパスしたり失敗したりしてしまうからです。上記の各テストは、それぞれ独自にCart()を生成しています。共有のセットアップを行うための適切な道具は、後の章で学ぶフィクスチャ(fixture)です。
ノードID:テストのアドレス
何も実行せずに、収集されたテストの一覧だけを表示するようpytestに指示します — --collect-only、短縮形では--coを指定します:
$ pytest --co -q
tests/test_cart.py::TestCart::test_empty_cart_subtotal
tests/test_cart.py::TestCart::test_subtotal_counts_quantity
tests/test_cart.py::TestCart::test_total_adds_tax
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
6 tests collected in 0.01s計画の最後の列と見比べてみてください。すべてのテストが、指定したアドレスに配置されています。各行はノードID(node ID)です — ファイルパス、続いて::、クラスがあればそのクラス名、そして::とテスト名という構成になっています。これは失敗サマリーのFAILEDの後に表示される文字列とまったく同じなので、失敗レポートからコピーしてコマンドラインにそのまま貼り付けて再実行できます。
実行するテストの選択
パスまたはノードIDで指定する
1つのテストだけを実行する間違った方法は、他のテストをコメントアウトしたり、名前を変更してpytestにスキップさせたりすることです — そして後で元に戻すのを忘れてしまいます。正しい方法は、pytestにアドレスを指定することです:
$ pytest -q tests/test_pricing.py
... [100%]
3 passed in 0.01s
$ pytest -q tests/test_cart.py::TestCart
... [100%]
3 passed in 0.01s
$ pytest -q tests/test_cart.py::TestCart::test_total_adds_tax
. [100%]
1 passed in 0.01sフォルダ、ファイル、クラス、単一のテスト — ノードIDの階層を深めるごとに選択範囲が絞り込まれ、コードには一切手を加える必要がありません。
名前で指定する:-k
-kは指定された式を受け取り、名前に一致するテストのみを残します:
$ pytest -q -k discount
.. [100%]
2 passed, 4 deselected in 0.01sマッチングは大文字・小文字を区別しない部分文字列検索であり、単語はand、or、not、および丸括弧を使って組み合わせることができます。シェルが式を1つの引数として渡せるように、式をクォートで囲んでください:
$ pytest -q -k "discount or tax"
.... [100%]
4 passed, 2 deselected in 0.01s
$ pytest -q -k "TestCart and not tax"
.. [100%]
2 passed, 4 deselected in 0.01sテスト名を慎重に決める価値があるのはこのためです。test_discount_zeroという名前であれば、discountでもzeroでも、あるいはその両方でも検索できます。test_2のような名前では、何も検索できません。
誰しも一度は引っかかる落とし穴があります。-kはテスト自身の名前だけでなく、その上位にある名前 — 所属するクラス名やファイル名 — にもマッチします:
$ pytest --co -q -k "not cart"
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
3/6 tests collected (3 deselected) in 0.01sTestCartの3つのテスト自身の名前にはcartが含まれていませんが、それらがあるのはtest_cart.pyであるため、3つすべてがcartにマッチし、not cartによって3つとも除外されました。-kの選択結果に違和感を持ったときは、実行する前に--coを付けて確認してみましょう。
名前ではなく自身で設定したラベルによってテストを選択するには、第12章で学ぶマーカーと-mを使用します。
テストが失敗したとき
意図的にコードを壊してみましょう。add_taxのデフォルトrateを0.15から0.18に変更します。これで2つのテストが失敗するようになります(--tb=noは実行の全体像を見やすくするために失敗レポートを非表示にしています。レポートの読み方は前章で学びました)。
$ pytest -q --tb=no
..F..F [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 4 passed in 0.01s-xは最初の失敗で停止します。 1つの壊れた関数によって10個のテストが失敗するとき、読む価値があるのは最初のレポートだけであり、残りの9個はその余波にすぎません:
$ pytest -q -x --tb=no
..F
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 2 passed in 0.01s--maxfail=NはN件の失敗後に停止します。 -xは--maxfail=1とまったく同じです。
--lf(last failed)は前回失敗したものだけを再実行します。 pytestはプロジェクト内の.pytest_cacheフォルダに失敗の記録を保存しています:
$ pytest -q --lf --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 2 deselected in 0.01s失敗した2つのテストだけが実行されました。税率を元に戻すと、--lfはその2つを再実行してパスします。失敗するテストがなくなった状態でもう一度--lfを実行すると、pytestはrun-last-failure: no previously failed tests, not deselecting items.と出力し、すべてのテストを実行します。
--ff(failed first)は失敗したものを最初に実行し、その後に残りのすべてを実行します。 素早いフィードバックを先頭で得て、その後に安全確認のための全体チェックが行われます:
$ pytest -q --ff --tb=no
FF.... [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 4 passed in 0.01sFF.... — 失敗した2つが、パスした4つよりも先に実行されています。
バグ修正時の優れた開発ループは、グリーンになるまでpytest --lf -xを繰り返し、最後に通常のpytestを1回実行することです。なぜ最後のステップが必要なのでしょうか?--lfはレッドだったものしか再実行しないため、今回の修正によって以前はグリーンだった別の箇所が壊れてしまったかどうかを教えてくれないからです。
テストからの出力:キャプチャと-s
テスト内のprintは何もしないように見えます(以下の--tb=shortは単に失敗レポートを短くするためのものです)。pytestはテストが画面に出力するすべての内容をキャプチャし、そのテストが失敗した場合にのみ表示します:
def test_quiet():
print("subtotal is", 45.0)
assert 45.0 == 45.0
def test_loud():
print("subtotal is", 40.0)
assert 40.0 == 45.0$ pytest -q --tb=short
.F [100%]
=================================== FAILURES ===================================
__________________________________ test_loud ___________________________________
test_print.py:8: in test_loud
assert 40.0 == 45.0
E assert 40.0 == 45.0
----------------------------- Captured stdout call -----------------------------
subtotal is 40.0
=========================== short test summary info ============================
FAILED test_print.py::test_loud - assert 40.0 == 45.0
1 failed, 1 passed in 0.01sパスしたテストの出力は消去され、失敗したテストの出力はそのレポートのCaptured stdout callの下に添付されます。何百ものテストがある場合、まさにこれが求められる挙動です — 問題がないときは静かに、問題があるときは証拠を提示してくれます。出力されたものをリアルタイムですべて確認したい場合は、-sを指定してキャプチャを無効にします:
$ pytest -q -s --tb=no
subtotal is 45.0
.subtotal is 40.0
F
=========================== short test summary info ============================
FAILED test_print.py::test_loud - assert 40.0 == 45.0
1 failed, 1 passed in 0.01s終了コード
pytestが終了すると、シェルに数値を渡します。CIシステムはこの数値だけをもとに「グリーン」か「レッド」かを判定します:
| コード | 意味 | |---|---| | 0 | 収集されたすべてのテストがパスした | | 1 | テストが実行され、少なくとも1つが失敗した | | 2 | 実行が中断された — 収集時のエラー、またはCtrl+C | | 3 | pytestまたはプラグイン内部のエラー | | 4 | コマンドの使い方に関するエラー(usage error) — 未知のオプション、または存在しないパスやノードID | | 5 | テストが1件も収集されなかった |
5には注意が必要です。何にもマッチしない-kや、正しい命名規則に従ったファイルが1つもないフォルダは、パスではありません — そしてCIがこれを失敗として扱うのは、完全に正当です:
$ pytest -q -k refund
6 deselected in 0.01s
$ echo $?
5この章の冒頭で発生したModuleNotFoundErrorは、Interrupted: 1 error during collectionで終わっていました — 終了コードは2です。コレクションエラーはテストの失敗とは異なります。pytestはテストの実行段階にすら到達していなかったのです。
完全な例
計画は実装され、すべてグリーンになりました。ここで、実行に時間のかかるテストを専用のクラスとしてtests/test_cart.pyに追加します:
from shop.cart import Cart
class TestBigCart:
def test_many_items(self):
cart = Cart()
for _ in range(300_000):
cart.add("pen", 1.0)
assert cart.subtotal() == 300_000.0このクラスの上には、以前のTestCartがそのまま残っています。何かを実行する前に、まずはpytestにどう見えているかを確認します:
$ pytest --co -q
tests/test_cart.py::TestCart::test_empty_cart_subtotal
tests/test_cart.py::TestCart::test_subtotal_counts_quantity
tests/test_cart.py::TestCart::test_total_adds_tax
tests/test_cart.py::TestBigCart::test_many_items
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
7 tests collected in 0.01s7つのテストがあり、それぞれにアドレスが割り当てられています。すべてを実行し、最も遅い上位3つを表示させてみます:
$ pytest -q --durations=3
....... [100%]
============================= slowest 3 durations ==============================
0.04s call tests/test_cart.py::TestBigCart::test_many_items
(2 durations < 0.005s hidden. Use -vv to show these durations.)
7 passed in 0.12s一覧に表示されるほど遅いテストは1つだけでした。実際のプロジェクトでは、テストスイート全体の実行速度を低下させている一握りのテストを見つけるために--durations=10を使用します。カート機能の開発中は、時間のかかるテストを除外して実行します:
$ pytest -q -k "cart and not many"
... [100%]
3 passed, 4 deselected in 0.01sこれが機能した理由は3つあります。pythonpath = ["src"]によってそもそもfrom shop.cart import Cartが成功すること、TestBigCartの名前がTestで始まり__init__を持たないため収集されること、そして-k "cart and not many"によってTestCartのテストだけが正確に残されたことです(cartはファイル名を通じてtest_cart.py内のすべてに一致し、not manyによって遅いテストが除外されました)。
問題が起きたとき
収集時のModuleNotFoundError: No module named 'shop' テスト対象のコードがsys.pathに含まれていません。src/構成の場合は、[tool.pytest.ini_options]の下にpythonpath = ["src"]を追加するか、pip install -e .でパッケージをインストールしてください。実行はInterrupted: 1 error during collectionで終了し、終了コードは2になります。
python -m pytestでは動くがpytestでは動かない python -m pytestはカレントフォルダをsys.pathに追加しますが、素のpytestは追加しません。そのため、プロジェクトルートにあるモジュールは一方の方法でのみインポート可能になります。この動作の違いに依存せず、pythonpathを設定するか、パッケージをインストールしてください。
PytestCollectionWarning: cannot collect test class 'TestCart' because it has a __init__ constructor テストクラスで__init__を定義してはいけません。これを削除し、各テストに必要なものはテストの内部で作成してください。
no tests ran、終了コード5 ディスカバリのルールに一致するものが1つもありませんでした。ファイル名(test_*.pyまたは*_test.py)、関数名(test...)、クラス名(Test...)を確認してください。-kを使用した場合は、何もマッチしなかった可能性があります。
ERROR: not found: .../tests/test_cart.py::test_total_adds_tax ノードIDが間違っています。そのテストはTestCartクラス内にあるため、アドレスはtests/test_cart.py::TestCart::test_total_adds_taxになります。ノードIDは手入力せず、pytest --co -qからコピーしてください。終了コードは4です。
ERROR: file or directory not found: tests/test_carts.py 指定したパスにタイプミスがあります。終了コードは4です。
pytest: error: unrecognized arguments: --maxfial=2 オプションの綴りが間違っています。正しいオプションの一覧はpytest --helpで確認できます。終了コードは4です。
ステップ 4 / 6 — 予想する
理解度をチェック
このファイルはtest_rules.pyとして保存されています。pytest -qを実行したとき、最後の行にはいくつのパス(pass)が報告されますか?
def test_found():
assert True
def check_total():
assert True
class TestGroup:
def test_method(self):
assert True
def helper(self):
assert True
class CartTests:
def test_ignored(self):
assert True- A2 passed
- B1 passed
- C3 passed
- D4 passed
コードはsrc/shop/pricing.pyにあります。pyproject.tomlの[tool.pytest.ini_options]の下にはpythonpath = ["shop"]が設定されています。それにもかかわらず、pytestはModuleNotFoundError: No module named 'shop'を報告します。何が間違っていますか?
# tests/test_pricing.py
from shop.pricing import add_tax
def test_add_tax_default_rate():
assert add_tax(100.0) == 115.0- A関数名は`Test`で始まる必要がある
- B`pythonpath`は`shop`パッケージを*含む*フォルダを指す必要がある:`["src"]`
- Cテストファイル名は`_test.py`で終わる必要がある
- D何かをインポートする前に`tests/`に`__init__.py`が必要である
2つのテストが失敗しており、バグを修正しました。その2つのテストだけを再実行するコマンドはどれですか?
- Apytest --co
- Bpytest -x
- Cpytest --ff
- Dpytest --lf
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
図書館の延滞料金を計算するシステムです。以下の構成でプロジェクトを作成してください:
library/
├── src/
│ └── library/
│ ├── __init__.py
│ └── fees.py
└── tests/
└── test_fees.pysrc/library/fees.py:
def late_fee(days_late, daily_rate=5):
if days_late < 0:
raise ValueError(f"days_late cannot be negative: {days_late}")
return min(days_late * daily_rate, 100)- まずテスト計画を立てます:期日通り、数日遅れ、上限ぴったり、上限超過、カスタムの日額料金。
- それらのケースを1つのクラス
TestLateFeeのメソッドとして記述したtests/test_fees.pyを作成します。pyproject.tomlが存在しない状態でpytestを実行し、エラーメッセージを読み、echo $?を確認します。 - テストから
libraryをインポートできるようにpyproject.tomlを追加します。テストをグリーンにします。 --co -qでノードIDを一覧表示します。「上限超過」のテストだけをノードIDで指定して実行します。-kを使って、上限(cap)に関するテストだけを実行し、次にそれ以外のすべてのテストを実行します。fees.pyの上限値を100から90に変更します。テストスイートを実行し、続いて--lfを実行します。値を元に戻し、--lfを実行してから、すべてのテストを実行します。
解答例
計画:
| ケース | 入力 | 期待値 | |---|---|---| | 期日通り | late_fee(0) | 0 | | 数日遅れ | late_fee(3) | 15 | | 境界値:上限ぴったり | late_fee(20) | 100 | | 上限超過 | late_fee(30) | 100 | | カスタムの日額料金 | late_fee(3, daily_rate=2) | 6 | | 負の日数 | late_fee(-1) | ValueError — 今後pytest.raisesを使って扱います |
tests/test_fees.py:
from library.fees import late_fee
class TestLateFee:
def test_on_time_costs_nothing(self):
assert late_fee(0) == 0
def test_three_days_late(self):
assert late_fee(3) == 15
def test_fee_reaches_cap_exactly(self):
assert late_fee(20) == 100
def test_fee_never_exceeds_cap(self):
assert late_fee(30) == 100
def test_custom_daily_rate(self):
assert late_fee(3, daily_rate=2) == 6設定がない状態では、ModuleNotFoundError: No module named 'library'、Interrupted: 1 error during collectionとなり、収集段階で停止します。echo $?は2を出力します。そこでpyproject.tomlを追加します:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]$ pytest -q
..... [100%]
5 passed in 0.01s
$ pytest --co -q
tests/test_fees.py::TestLateFee::test_on_time_costs_nothing
tests/test_fees.py::TestLateFee::test_three_days_late
tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly
tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap
tests/test_fees.py::TestLateFee::test_custom_daily_rate
5 tests collected in 0.01s
$ pytest -q tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap
. [100%]
1 passed in 0.01s
$ pytest -q -k cap
.. [100%]
2 passed, 3 deselected in 0.01s
$ pytest -q -k "not cap"
... [100%]
3 passed, 2 deselected in 0.01s上限値を90に変更した場合:
$ pytest -q --tb=no
..FF. [100%]
=========================== short test summary info ============================
FAILED tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly - assert...
FAILED tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap - assert 9...
2 failed, 3 passed in 0.01s
$ pytest -q --lf --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly - assert...
FAILED tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap - assert 9...
2 failed, 3 deselected in 0.01s上限値を100に戻すと、pytest -q --lfは2 passed, 3 deselectedとなり、全体のpytest -qは5 passedになります。
これらの設計判断の理由:
- 上限のテストが1つではなく2つある理由。
late_fee(20)はちょうど境界値であり、料金が上限に達する最初の日です。late_fee(30)はそれを十分に超えた値です。オフバイワン(1つの違いによる)エラーは境界値で発生しやすいため、境界値には専用のテストを用意します — そして2つ目のテストによって、上限を超えても上限値が維持されることを確認します。 - 両方の上限テストの名前にのみ
capという単語が含まれている理由。 これにより、-k capと-k "not cap"で的確に選択できるようになります — テスト選択を意識して名前が選ばれています。 - 1つのクラスにまとめ、
__init__を持たず、状態を共有しない理由。 各メソッドは自身でlate_feeを呼び出します。クラスはグループにtests/test_fees.py::TestLateFeeという1つのアドレスを与えるためだけに存在します。 - テストファイル内の
sys.pathの書き換えではなくpythonpathを使用する理由。 これにより、どのフォルダから実行してもテストがパスするようになります。 --lfの後に全体を実行する理由。--lfはレッドだった2つのテストが再びグリーンになったことしか証明しません。残りの3つもグリーンのままであることを証明できるのは、全体のテスト実行だけです。