pytest によるテスト
`assert` から pytest へ、失敗レポートの読み方、`pytest.raises`、`parametrize`、`tmp_path`、フィクスチャ、そしてどんな形のコードがテストしやすいか。
どうやって学びますか?
解決する問題
これまで、コードを「テスト」する方法は一つだけでした。実行して、出力を読み、正しそうに見えるかどうかを自分で判断する、というやり方です。
一度ならそれでうまくいきます。問題は二度目から始まります。
25 章のプログラムでは TAX_RATE が 0.15 でした。これが 0.18 に変わったとしましょう。プログラムは動き、すべての数字が表示され、エラーも出ません。それでも、前回の出力をたまたま覚えていなければ、何かが変わったかどうかは分かりません。
でも、覚えておくべきことは、いずれ必ず忘れられてしまいます。
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 1) == 17.25
print("ok")okassert は単純な一文です:「これは正しいはずだ」。正しければ何も起きません。正しくなければ、プログラムは AssertionError で止まります。
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 3) == 51.0
print("ok")AssertionErrorこれが土台です。この章では、その土台の上に作られた道具 pytest を使います。pytest は、こうした「正しいはず」を何百個も一度に実行し、どれかが失敗したときにはなぜ失敗したかを教えてくれます。
この章を終える頃には
- テストファイルを書き、
pytestを実行できる - 失敗レポートを読み、そこから問題を見つけられる
pytest.raisesでエラーをテストできる@pytest.mark.parametrizeで、1 つのテストを多くのケースに使えるtmp_pathを使って、ファイルを扱うコードをテストできる- どのコードがテストしやすいか、その理由を説明できる
前提条件: dataclass と型ヒント。
最初のテスト
ルールは 2 つだけです。ファイル名は test_ で始め、テスト関数の名前も test_ で始めます。(pytest は _test.py で終わるファイルも探しますが、このコースでは名前を test_ で始めることにします。)
pricing.py:
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)test_pricing.py:
from pricing import line_total
def test_one_item():
assert line_total(15.0, 1) == 17.25
def test_three_items():
assert line_total(15.0, 3) == 51.75次に pytest -q を実行します:
.. [100%]
2 passed in 0.00sドットが 2 つ、テストが 2 つ通ったという意味です。pytest はファイルを見つけ、関数を見つけ、実行します — どこにも登録する必要はありません。最後の時間(0.00s)は、お使いの環境では少し違っても構いません。
失敗レポートこそが大事
test_pricing.py の 2 つ目のテストに、わざと間違った数字を入れてみましょう — 51.75 の代わりに 51.0 と書きます — そして、もう一度 pytest -q を実行してください:
.F [100%]
=================================== FAILURES ===================================
_______________________________ test_three_items _______________________________
def test_three_items():
> assert line_total(15.0, 3) == 51.0
E assert 51.75 == 51.0
E + where 51.75 = line_total(15.0, 3)
test_pricing.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_three_items - assert 51.75 == 51.0
1 failed, 1 passed in 0.01s1 行目の .F は、1 つ目のテストが通り、2 つ目が失敗したことを表します。普通の assert が教えてくれるのは AssertionError だけでした — 一語だけで、情報はありません。ここでは次のことが分かります:
- どのテスト(
test_three_items)の、どの行(test_pricing.py:9)か - 失敗した
assertそのもの。>で印が付いています assert 51.75 == 51.0— 実際に出た値と、期待した値where 51.75 = line_total(15.0, 3)— その数字がどこから来たか- 最後の行の件数:
1 failed, 1 passed
これを assertion introspection(アサーションの内訳を表示する仕組み)と呼び、pytest を使う最大の理由がこれです。多くのテストツールでは、これのために assertEqual(a, b) のような別のメソッドが必要です。pytest では、普通の == で十分です。
エラーも振る舞いの一部
24 章では、関数は不正な入力に対して raise すべきだと説明しました。その振る舞いも一つの約束なので、テストできます:
import pytest
from pricing import line_total
def test_zero_is_rejected():
with pytest.raises(ValueError):
line_total(15.0, 0)
def test_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3).. [100%]
2 passed in 0.01spytest.raises のブロックは「このエラーは、ここで起きるはずだ」と宣言しています。起きなければ、テストは失敗します。たとえば、test_one_is_rejected というテストが、line_total(15.0, 1) で ValueError が出ることを期待しているとします — 数量 1 は正しい値なのですが:
F [100%]
=================================== FAILURES ===================================
_____________________________ test_one_is_rejected _____________________________
def test_one_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_pricing.py:7: Failed
=========================== short test summary info ============================
FAILED test_pricing.py::test_one_is_rejected - Failed: DID NOT RAISE ValueError
1 failed in 0.01smatch= の部分は、エラーメッセージにその文字列が含まれているかを確かめます。これは書く価値があります。24 章のルール — 問題の値をメッセージに入れる — が、それ自体テストされる約束になるからです。
1 つのテストで多くのケース
4 つのケースのために 4 つの関数を書くのは退屈ですし、コピー&ペーストのミスを招きやすくなります。
import pytest
from pricing import line_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(100.0, 2, 230.0),
],
)
def test_line_total(price, quantity, expected):
assert line_total(price, quantity) == expectedpytest -v で実行すると:
============================= test session starts ==============================
collecting ... collected 4 items
test_pricing.py::test_line_total[15.0-1-17.25] PASSED [ 25%]
test_pricing.py::test_line_total[15.0-3-51.75] PASSED [ 50%]
test_pricing.py::test_line_total[0.0-5-0.0] PASSED [ 75%]
test_pricing.py::test_line_total[100.0-2-230.0] PASSED [100%]
============================== 4 passed in 0.01s ===============================1 つの関数から、4 つの独立したテストが作られます。各テスト名には、その値が含まれています。そのため、どれかが失敗したときに、どれが問題かすぐに分かります。ループで書くと、最初の失敗で残りが止まってしまい、どの値が悪かったのかも教えてくれません。
2 つ目のケースに 51.75 の代わりに 51.0 を入れて pytest -q を実行してください。レポートの先頭に、その値も表示されます:
.F.. [100%]
=================================== FAILURES ===================================
_________________________ test_line_total[15.0-3-51.0] _________________________
price = 15.0, quantity = 3, expected = 51.0ファイルが必要なとき — tmp_path
23 章のようなファイル読み込みのコードをテストするには、ファイルが必要です。ファイルをリポジトリに置いたままにするのは良くない習慣です。テスト同士が絡み合い、あるテストがファイルを書き換えると、別のテストが壊れてしまいます。
reader.py に次の関数があるとしましょう。テキストファイルの、空白だけではない行を返す関数です:
from pathlib import Path
def read_names(path: Path) -> list[str]:
"""Return the non-blank lines of a text file, without spaces at the ends."""
text = path.read_text(encoding="utf-8")
return [line.strip() for line in text.splitlines() if line.strip()]pytest は、テストごとにそれ専用の空のフォルダを用意できます。test_reader.py:
from reader import read_names
def test_blank_lines_are_skipped(tmp_path):
path = tmp_path / "names.txt"
path.write_text("one\n\ntwo\n", encoding="utf-8")
assert read_names(path) == ["one", "two"]
def test_empty_file_gives_empty_list(tmp_path):
path = tmp_path / "names.txt"
path.write_text("", encoding="utf-8")
assert read_names(path) == [].. [100%]
2 passed in 0.01stmp_path は pathlib.Path です — 23 章に出てきた Path と同じもので、ここでも / でつなげられます。pytest に何を渡すべきかを伝えるのは、引数の名前です。テストごとに新しいフォルダが作られるので、2 つのテストが同じファイル名を使っても衝突しません。
準備を使い回す — fixture
同じ入力を複数のテストが必要とするときは、一度だけ書いて名前を付けられます。ここで使う order_total は、pricing.py にあるもう一つの関数で、注文全体の合計を求めます。完全なファイルは、後の「完全な例」にあります:
import pytest
from pricing import order_total
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1)]
def test_total(order):
assert order_total(order) == 1029.25
def test_one_line_removed(order):
assert order_total(order[:1]) == 51.75.. [100%]
2 passed in 0.01stmp_path と同じく、pytest は引数の名前で対応づけます。そして関数はテストごとに実行し直されます。ですから、あるテストがリストを変更しても、次のテストには新しいリストが渡されます — 21 章で扱った共有データの問題が、設計によって取り除かれているのです。
浮動小数点数の罠
def test_addition():
assert 0.1 + 0.2 == 0.3F [100%]
=================================== FAILURES ===================================
________________________________ test_addition _________________________________
def test_addition():
> assert 0.1 + 0.2 == 0.3
E assert (0.1 + 0.2) == 0.3
test_money.py:2: AssertionError
=========================== short test summary info ============================
FAILED test_money.py::test_addition - assert (0.1 + 0.2) == 0.3
1 failed in 0.01s5 章でも扱った古い問題です。テストは、この問題が最初に表面化する場所であることが多いです。
import pytest
def test_addition():
assert 0.1 + 0.2 == pytest.approx(0.3). [100%]
1 passed in 0.00spytest.approx は「だいたい同じならよい」と宣言します。浮動小数点数が関わるテストでは、これを使ってください。ただし、line_total のように round() で値をあらかじめ丸めてある場合は、この限りではありません。
何をテストするか
一つのルールが、他のどれよりも価値があります:値を受け取って値を返す関数は、テストしやすいです。
21 章の純粋な関数を覚えていますか? この章では、それがなぜ価値があるのかが分かります。line_total のテストには、ファイルも、入力も、準備も要りませんでした — 呼び出して、結果を見るだけです。
一方で、表示したり、入力を求めたり、ファイルに書き込んだりする関数は、テストする前に余分な準備が必要です。23 章の report がファイルを受け取らなかったのは、そのためです。リストを受け取り、リストを返していました。
テストが書きにくいのは、たいてい、テストのせいではなくコードの形のせいです。
テストすべきものは 3 種類です:普通に起きること、境界(0、空、1)、そして失敗するはずのこと。
完全な例
pricing.py:
"""Prices and tax. Pure functions, which is what makes them testable."""
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)
def order_total(lines: list[tuple[str, float, int]]) -> float:
return round(sum(line_total(price, qty) for _, price, qty in lines), 2)test_pricing.py:
"""Tests for pricing. Each one names the behaviour it protects."""
import pytest
from pricing import line_total, order_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(0.01, 1, 0.01),
],
)
def test_line_total_applies_tax(price, quantity, expected):
assert line_total(price, quantity) == expected
@pytest.mark.parametrize("quantity", [0, -1, -100])
def test_quantity_below_one_is_rejected(quantity):
with pytest.raises(ValueError, match="at least 1"):
line_total(15.0, quantity)
def test_error_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3)
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1), ("ink", 120.0, 2)]
def test_order_total_sums_the_lines(order):
assert order_total(order) == 1305.25
def test_empty_order_totals_zero():
assert order_total([]) == 0
def test_one_bad_line_stops_the_order(order):
with pytest.raises(ValueError):
order_total(order + [("clip", 5.0, 0)])........... [100%]
11 passed in 0.01s注目すべき点は 4 つです。
6 つのテスト関数から、11 個のテスト。 parametrize がその差を生みました。ケースは一つずつ別々に数えられます。
名前には、コードではなく振る舞いを書きます。 test_quantity_below_one_is_rejected は、プログラムが何を約束しているかを教えてくれます。test_line_total_2 は何も教えてくれません。失敗レポートで最初に表示されるのがこの名前なので、文章にしておく価値があります。
0.01 のケースは、適当に選んだものではありません。 取りうる最小の価格で、境界です — そして、round() の振る舞いが最も怪しい場所でもあります。バグは真ん中ではなく、境界に潜んでいるものです。
最後のテストは、間接的な約束を守っています。 order_total は独自の検証をしていません — line_total に頼っています。もし将来、誰かが不正な行を黙ってスキップさせようと order_total に try/except を入れたら、このテストは失敗して問いかけます:「本当にそのつもりでしたか?」
うまくいかないとき
no tests ran ファイル名が test_ で始まっていない(または _test.py で終わっていない)か、関数名が test_ で始まっていません。両方が必要です。
ModuleNotFoundError: No module named 'pricing' ファイルがあるフォルダで pytest を実行してください。
テストは通るのに、何もチェックしていない assert が抜けています。関数を呼ぶだけなら、例外が出ない限りテストは通ってしまいます。
fixture 'order' not found @pytest.fixture が抜けているか、名前が引数の名前と一致していません。
数字は合っているように見えるのに、浮動小数点数の比較が失敗する pytest.approx を使うか、関数の中で round() を使ってください。
DID NOT RAISE pytest.raises ブロックの中のコードが、エラーを出しませんでした。コードが間違っているか、テストの期待が間違っているかのどちらかです。
単独では通るのに、ほかのテストと一緒に実行すると失敗する テスト同士が何かを共有しています — たいていはモジュールレベルのリストや辞書です。21 章と 26 章で扱ったミュータブルなデータです。fixture を使ってください。
ステップ 4 / 6 — 予想する
理解度をチェック
pytest -q を実行したとき、レポートの最後の行は何でしょうか?
# checks.py
def is_even(n):
return n % 2 == 0
# test_checks.py
from checks import is_even
def test_even():
assert is_even(4)
def test_odd():
assert is_even(7)- A2 failed in 0.01s
- B1 failed, 1 passed in 0.01s
- C1 failed in 0.01s
- D2 passed in 0.01s
add(2, 2) は 5 ではありません。pytest -q は何と言うでしょうか?
# test_a.py
def add(a, b):
return a + b
def test_add():
add(2, 2) == 5- A`AssertionError`
- B1 failed — 想定どおり
- Cno tests ran
- D1 passed — テストは通る
1 つ目のテストがカゴに品物を入れました。2 つ目のテストには何が見えるでしょうか?
# helpers.py
def build():
return []
# test_helpers.py
import pytest
from helpers import build
@pytest.fixture
def basket():
return build()
def test_first(basket):
basket.append("one")
assert len(basket) == 1
def test_second(basket):
assert len(basket) == 0- Afixture 'basket' not found
- B2 failed
- C2 passed — 2 つ目のテストには、新しい空のリストが渡される
- D1 failed, 1 passed — 2 つ目には品物が 1 つ見える
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
27 章の library.py(Book と Shelf)を使い、その隣に test_library.py を書いてください。
最低限、次のものを書いてください:
parametrizeを使い、total_pages()を複数のケースでテストする(空の棚を含める)pages=0が拒否されることを、match=付きのpytest.raisesでテストする- 空の棚では
longest()がNoneを返すことをテストする by_authorが、その著者の本だけを返すこと、該当者がいないときは空のリストを返すことをテストするtmp_pathを使うテストを 1 つ書く。棚を JSON に書き出し、それを読み戻す
次に、5 つの実験をしてください:
- テストから
assertを取り除き、関数を呼ぶだけにしてください。テストは通るでしょうか? - テストファイルの名前を
check_library.pyに変えてpytestを実行してください。何が起きるでしょうか?(library_test.pyという名前なら見つかります — なぜでしょうか?) Bookのpages < 1のチェックを取り除いてください。どのテストが失敗するでしょうか、そしてレポートは理由を教えてくれるでしょうか?total_pages()にわざと間違いを入れてください — たとえばsum(...) + 1です。失敗レポートには、実際の数字と期待された数字の両方が表示されるでしょうか?0.1 + 0.2 == 0.3を確かめるテストを書いてください。次に、pytest.approxを使って直してください。
3 つ目の実験が、いちばん多くを教えてくれます。よいテストスイートは、壊れたときに、どの約束が破られたかを教えてくれます — そしてそれこそが、6 か月後にもプログラムを安全に変更できる理由です。