例外のテスト — pytest.raises
エラーも振る舞いの一部であり、テストが必要です。pytest.raises、DID NOT RAISE、サブクラスのマッチング、match=が正規表現である理由、excinfoによるキャッチしたエラーの検査、そしてwithブロック内には最後の1行だけを入れるべき理由を学びます。
どうやって学びますか?
解決する問題
以下は残高を保護する小さな関数です。この関数には2つの拒否が組み込まれています:正の数ではない金額と、残高を超える金額です。
wallet.py:
class InsufficientFunds(Exception):
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
super().__init__(f"cannot withdraw {amount:.2f}; balance is {balance:.2f}")
def withdraw(balance: float, amount: float) -> float:
if amount <= 0:
raise ValueError(f"amount must be positive (got {amount})")
if amount > balance:
raise InsufficientFunds(balance, amount)
return balance - amountこれら2つのraiseの行は、returnとまったく同じようにコードの振る舞いです。呼び出し元はwithdraw(100.0, -5.0)が拒否されることを当てにしており、もしある日その拒否が消えてしまえば、お金が誤った方向へ移動してしまいます。そのため、これらもテストする価値があります。多くの人が最初に書くテストは、次のようになります:
from wallet import withdraw
def test_negative_amount_is_rejected():
try:
withdraw(100.0, -5.0)
except ValueError:
passここで、誰かがwithdrawからif amount <= 0のチェックを削除してしまったとしましょう。pytest -qを実行します:
. [100%]
1 passed in 0.01sチェックが削除されたにもかかわらず、テストはパスしてしまいます。例外が発生しないため、tryブロックは単に終了し、exceptはスキップされ、関数は何のアサートも失敗することなく終わります。このテストは、もしValueErrorが発生したらそれを無視する、としか確認していません。エラーが必ず発生しなければならないとは一切主張していないのです。
この章では、それを適切に主張する方法 — そして、エラーのテストが本来失敗すべきときにパスしてしまういくつかの落とし穴について学びます。
この章を終える頃には
pytest.raisesを使ってコードが例外を送出することをテストし、DID NOT RAISEを読み解けるようになるpytest.raises(Exception)がほぼ常に広すぎる理由を説明できるようになるmatch=が正規表現であることを理解した上で、メッセージを検証できるようになるexcinfoを通してキャッチした例外を検査できるようになるwithブロック内には例外を送出する行だけを置き、その理由を説明できるようになるExceptionGroupを認識し、その内部を検証できるようになる
前提条件: ディスカバリとテストの実行。
テストを書く前に
エラーのテストの質は、その背後にある設計判断の質で決まります。pytest.raisesとキーボードを叩く前に、まず3つのことを紙の上で整理しておきましょう。
1. 具体的に何を約束しているか? withdrawをコードではなく契約として読み解きます:
- 入力: 残高と金額。出力: 新しい残高、
balance - amount。 - 拒否その1: 金額がゼロまたは負の数の場合は
ValueErrorを送出し、メッセージにはその金額が含まれる。 - 拒否その2: 金額が残高より大きい場合は
InsufficientFundsを送出し、この例外は属性としてbalanceとamountを保持する。 - 副作用: なし。
withdrawは自身の外部を何も変更しないため、「拒否された後に何も変更されていないこと」はここでは自動的に満たされます — 最後の銀行の例ではそうはいきません。
それぞれの拒否は独立した約束であり、検証できる3つの要素があります:どのクラスか、どんなメッセージか、そしてどんな状態が残されるかです。
2. 何を準備しておく必要があるか? 前章の内容以外に特別なものはありません。pytest.raisesはpytestの一部であり、reは標準ライブラリに含まれています。仮想環境が有効化され、pytestがインストールされており、テストファイルからwallet.pyをインポートできる状態になっている必要があります — この章では、test_wallet.pyと同じフォルダに配置します。最初の2つを一度確認しておきましょう:
$ python -m pytest --version
pytest 9.1.1次に、コードに対する主張を1行でも書く前に、実際の振る舞いを確認します。短い検証コードを実行してみることで、当て推量では分からない疑問が解消されます — 0.0は返されるのか、それとも拒否されるのか?メッセージはどのような形式なのか?
from wallet import InsufficientFunds, withdraw
print(withdraw(100.0, 30.0))
print(withdraw(100.0, 100.0))
try:
withdraw(100.0, 100.01)
except InsufficientFunds as error:
print(repr(error))70.0
0.0
InsufficientFunds('cannot withdraw 100.01; balance is 100.00')3. どのようなケースを扱うか? エラーは境界値に潜むため、境界を中心に計画を立てます — 通常のケース、各拒否の両側の境界、そして明らかに不正なケースです:
| ケース | 入力 | 期待される結果 | |---|---|---| | 通常の引き出し | withdraw(100.0, 30.0) | 70.0を返す | | 残高全額(境界値、許可) | withdraw(100.0, 100.0) | 0.0を返す、エラーなし | | わずかに超過(境界値、拒否) | withdraw(100.0, 100.01) | InsufficientFunds、amount == 100.01 | | ゼロ(境界値、拒否) | withdraw(100.0, 0) | ValueError | | 負の数 | withdraw(100.0, -5.0) | ValueError、メッセージに(got -5.0)が含まれる | | 明らかな残高不足 | withdraw(100.0, 150.0) | InsufficientFunds、balance == 100.0、amount == 150.0 |
2つの「許可」の行は、拒否の行と同じくらい重要です。>の代わりに>=と書かれたチェックは残高全額のケースを誤って拒否してしまいますが、そこでエラーが起きないことを期待するテストがあって初めてそれに気づくことができます。
テストすべきでないこと。 Python自体の振る舞いです:引数を忘れてwithdraw(100.0)を呼び出したときにTypeErrorが送出されるのはPython側の約束であり、あなたがテストすべきものではありません。すべてのメッセージの完全な文言も、文言自体が契約の一部でない限りテストすべきではありません — 値を含んだ一部分の検証で十分です。また、文字列"ten"を渡したときに何が起きるかなど、関数が決して約束していないことも対象外です。これらをテストすると、無害な変更で壊れてしまい、何の保護にもならないテストが増えるだけです。
この章の残りの部分では、ツールを1つずつ使いながら、この表をテストへと落とし込んでいきます。
pytest.raises — 「例外を送出しなければならない」
pytest.raisesはコンテキストマネージャです。withブロック内のすべては、指定された例外を送出することが期待されます:
import pytest
from wallet import withdraw
def test_negative_amount_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, -5.0)
def test_zero_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, 0).. [100%]
2 passed in 0.01sブロックからValueErrorが発生した場合、pytest.raisesがそれをキャッチし、テストはブロックの後に続行します。これはtry/exceptでもすでにできていた半分です。もう半分は、何も例外が発生しなかった場合にどうなるかです。もう一度if amount <= 0のチェックを削除して、同じファイルを実行してみます:
FF [100%]
=================================== FAILURES ===================================
_______________________ test_negative_amount_is_rejected _______________________
def test_negative_amount_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_wallet.py:7: Failed
____________________________ test_zero_is_rejected _____________________________
def test_zero_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_wallet.py:12: Failed
=========================== short test summary info ============================
FAILED test_wallet.py::test_negative_amount_is_rejected - Failed: DID NOT RAI...
FAILED test_wallet.py::test_zero_is_rejected - Failed: DID NOT RAISE ValueError
2 failed in 0.01sDID NOT RAISE ValueErrorこそ、try/exceptバージョンでは決して言えなかった言葉です。pytest.raisesの本質はこれだけです:期待されたエラーをキャッチし、それが届かなかったときにはっきりと失敗を報告することです。
どの例外がマッチとみなされるか
pytest.raises(X)はexcept X:とまったく同じようにマッチします:X自身またはそのサブクラスです。Pythonの例外は家系図を形成しており、その枝の1つを確認できます:
print(KeyError.__mro__)
print(issubclass(KeyError, LookupError))
print(issubclass(IndexError, LookupError))(<class 'KeyError'>, <class 'LookupError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
True
Trueつまり、KeyErrorはLookupErrorの一種です。これは便利であると同時に、落とし穴でもあります。以下は両方ともパスする2つのテストです:
import pytest
from wallet import withdraw
def test_missing_price_is_a_lookup_error():
prices = {"pen": 15.0}
with pytest.raises(LookupError):
prices["eraser"]
def test_too_broad():
with pytest.raises(Exception):
withdraw(100.0) # forgot the amount.. [100%]
2 passed in 0.01s1つ目は問題ありません — 意図的に親クラスをキャッチしています。2つ目は何も証明していないテストです。withdraw(100.0)は関数本体に到達することすらありませんでした:不足している引数に対してPythonがTypeErrorを送出し、通常のすべての例外はExceptionのサブクラスであるため、pytest.raises(Exception)はそれを快く受け入れてしまったのです。タイポ、誤ったインポート、AttributeError — そのどれがあっても、このテストはパス(グリーン)になってしまいます。
ルール:コードが送出することを約束している、最も具体的な例外の名前を指定する。 クラスの範囲が広ければ広いほど、無関係な失敗を隠してしまうことになります。
逆方向のチェックは厳格です。指定されたクラスでもサブクラスでもない例外はキャッチされません。ブロックの外に抜け出し、通常の失敗としてテストを落とします。(pytest -q --tb=shortはトレースバックをフレームごとに1行に短縮します。)
import pytest
from wallet import withdraw
def test_overdraft_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, 150.0)F [100%]
=================================== FAILURES ===================================
__________________________ test_overdraft_is_rejected __________________________
test_wallet.py:8: in test_overdraft_is_rejected
withdraw(100.0, 150.0)
wallet.py:12: in withdraw
raise InsufficientFunds(balance, amount)
E wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00
=========================== short test summary info ============================
FAILED test_wallet.py::test_overdraft_is_rejected - wallet.InsufficientFunds:...
1 failed in 0.01sDID NOT RAISEではありません — 例外は送出されたのですが、指定したものではなかったためであり、pytestはそれがどこから発生したかを示してくれます。
match= — メッセージを検証する
型はどのような種類の拒否だったかを示します。メッセージはなぜ拒否されたかを示し、問題となった値を保持していることがよくあります。match=はそれをチェックします:
import pytest
from wallet import withdraw
def test_message_says_positive():
with pytest.raises(ValueError, match="must be positive"):
withdraw(100.0, -5.0)
def test_whole_message():
with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
withdraw(100.0, -5.0).F [100%]
=================================== FAILURES ===================================
______________________________ test_whole_message ______________________________
def test_whole_message():
> with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
E AssertionError: Regex pattern did not match.
E Expected regex: 'amount must be positive (got -5.0)'
E Actual message: 'amount must be positive (got -5.0)'
E Did you mean to `re.escape()` the regex?
test_wallet.py:12: AssertionError
=========================== short test summary info ============================
FAILED test_wallet.py::test_whole_message - AssertionError: Regex pattern did...
1 failed, 1 passed in 0.01s期待されるテキストと実際のメッセージは完全に同一であるにもかかわらず、テストは失敗しました。その理由は先頭の行にあります:match=は単なる文字列ではありません。これは正規表現であり、pytestはre.search(pattern, str(exception))を実行します。ここから2つの重要な性質が生まれます:
re.searchはメッセージ内の任意の位置を探す。"must be positive"はメッセージの途中でマッチしました。先頭や末尾を厳密に固定したい場合は、^や$を使用します。- 一部の文字には特別な意味がある。 正規表現では、
( )はグループを形成し、丸括弧そのものにはまったくマッチしません。.は任意の文字にマッチします。+、*、?、[、$にもそれぞれの役割があります。
.のケースはより静かに問題を起こします。なぜなら、意図せずパスする方向で失敗するからです:
import re
print(re.search("100.00", "balance is 100.00"))
print(re.search("100.00", "balance is 100500"))
print(re.search(re.escape("100.00"), "balance is 100500"))<re.Match object; span=(11, 17), match='100.00'>
<re.Match object; span=(11, 17), match='100500'>
None"100.00"は100500にも難なくマッチしてしまいました。re.escapeはすべての特殊文字を文字通りのリテラルに変換するため、「この完全なテキスト」を意味する場合は、pytestのヒントが提案してくれた通りにテキストをこれに通します:
import re
import pytest
from wallet import withdraw
def test_whole_message():
message = "amount must be positive (got -5.0)"
with pytest.raises(ValueError, match=re.escape(message)):
withdraw(100.0, -5.0). [100%]
1 passed in 0.01sメッセージのどの程度をマッチさせるかは設計判断です。短く特徴的な一部分 — 値やキーワード — であれば、無害な表現の修正があってもテストが壊れることはありません。文章全体をマッチさせようとすると、誰かがタイポを1つ直しただけでテストが失敗してしまいます。
excinfo — キャッチした例外
with pytest.raises(...) as excinfoを使うと、キャッチした例外への参照を取得できます。pytest -q -s(-sはprintの出力をそのまま表示します)を使って、一度中身を確認してみましょう:
import pytest
from wallet import InsufficientFunds, withdraw
def test_look_inside():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
print()
print("type :", excinfo.type)
print("value :", repr(excinfo.value))
print("str :", str(excinfo.value))
print("fields:", excinfo.value.balance, excinfo.value.amount)type : <class 'wallet.InsufficientFunds'>
value : InsufficientFunds('cannot withdraw 150.00; balance is 100.00')
str : cannot withdraw 150.00; balance is 100.00
fields: 100.0 150.0
.
1 passed in 0.01sexcinfo.typeは実際に送出されたクラスです — 親クラスをキャッチし、どのサブクラスが届いたのかを知りたいときに役立ちます。excinfo.valueは例外オブジェクトそのものであり、保持しているすべての属性が含まれます。str(excinfo.value)はメッセージです —match=が検索するのと同じ文字列です。
printによる出力は中身を確認するためだけのものでした。実際のテストではassertを使用します:
import pytest
from wallet import InsufficientFunds, withdraw
def test_overdraft_reports_both_numbers():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
assert excinfo.value.balance == 100.0
assert excinfo.value.amount == 150.0
assert str(excinfo.value) == "cannot withdraw 150.00; balance is 100.00". [100%]
1 passed in 0.01s例外がデータ属性を保持している場合、メッセージの文字列を解析するよりも属性に対してアサートを行うほうが堅牢です。アサートがwithブロックの内部ではなく、後に置かれていることに注目してください。これは単なる好みの問題ではありません。次のセクションでその理由を説明します。
例外を送出する行だけを中に入れる
例外が送出された瞬間、Pythonはブロックを離脱します。例外を送出した行より後の行はすべてスキップされます — アサートも含めてです:
import pytest
from wallet import InsufficientFunds, withdraw
def test_failed_withdrawal_keeps_balance():
balance = 100.0
with pytest.raises(InsufficientFunds):
balance = withdraw(balance, 150.0)
assert balance == 999.0 # never runs
def test_text_amount_is_rejected():
with pytest.raises(ValueError):
amount = float("ten") # raises ValueError itself
withdraw(100.0, amount).. [100%]
2 passed in 0.01s両方のテストがパス(グリーン)していますが、どちらも間違っています。1つ目では、assert balance == 999.0という偽の主張が一度も実行されませんでした。2つ目では、準備の行にあるfloat("ten")自体がValueErrorを送出してしまったため、withdrawは呼び出されもしませんでした — withdrawが何かをチェックしていようといまいと、テストはパスしてしまいます。
ルール:準備(セットアップ)はブロックの前、例外を送出する1回の呼び出しをブロックの中、検証(チェック)はブロックの後に置く。
import pytest
from wallet import InsufficientFunds, withdraw
def test_failed_withdrawal_keeps_balance():
balance = 100.0
with pytest.raises(InsufficientFunds):
balance = withdraw(balance, 150.0)
assert balance == 100.0. [100%]
1 passed in 0.01sこれでアサートが実行され、実際の振る舞いが検証されます:代入処理が実行されなかったため、拒否された引き出しによってbalanceが変更されずに残ったことが確認できました。
許容される複数の例外
pytest.raisesはexceptと同様に、タプルを受け取ることもできます。リストされたクラスのいずれかであれば受け入れられ、excinfo.typeによって実際にどれが届いたかが分かります:
import pytest
from wallet import InsufficientFunds, withdraw
def test_bad_withdrawal_is_refused():
with pytest.raises((ValueError, InsufficientFunds)) as excinfo:
withdraw(100.0, 150.0)
print()
print(excinfo.type.__name__)InsufficientFunds
.
1 passed in 0.01sこれは、契約が本当に「これらのいずれか」である場合 — 例えばプラットフォームによってどちらかの例外が正当に送出されるようなコード — に使用します。自分で作成する関数については、ほぼ常に1つの正確なクラスを約束するほうが適切です。
例外が送出されないことをテストする
pytest.does_not_raise()のような関数は存在しませんし、探す必要もありません。単にコードを呼び出してください。もし例外が送出されれば、テストはその例外とトレースバックを出して失敗します:
from wallet import withdraw
def test_whole_balance_can_be_withdrawn():
assert withdraw(100.0, 100.0) == 0.0. [100%]
1 passed in 0.01swithdrawの中のamount > balanceをamount >= balanceに変更してみましょう — これは多くの人がやってしまう典型的なオフバイワン(1つの違い)エラーです — するとpytest -q --tb=shortは次のように報告します:
F [100%]
=================================== FAILURES ===================================
_____________________ test_whole_balance_can_be_withdrawn ______________________
test_wallet.py:5: in test_whole_balance_can_be_withdrawn
assert withdraw(100.0, 100.0) == 0.0
^^^^^^^^^^^^^^^^^^^^^^
wallet.py:12: in withdraw
raise InsufficientFunds(balance, amount)
E wallet.InsufficientFunds: cannot withdraw 100.00; balance is 100.00
=========================== short test summary info ============================
FAILED test_wallet.py::test_whole_balance_can_be_withdrawn - wallet.Insuffici...
1 failed in 0.01s予期せぬ例外が発生した時点で、テストはすでに失敗です。呼び出しをtry/except: pytest.fail()で囲んでしまうと、有用なトレースバックを捨ててしまうだけになります。
複数のエラーを一度に扱う:ExceptionGroup
Python 3.11以降、コードは複数の例外をまとめてExceptionGroupとして送出できるようになりました。すべての引き出しを試行し、失敗をすべてまとめて報告するバッチ処理バージョンをwallet.pyに追加します:
def withdraw_all(balance: float, amounts: list[float]) -> float:
errors = []
for amount in amounts:
try:
balance = withdraw(balance, amount)
except (ValueError, InsufficientFunds) as error:
errors.append(error)
if errors:
raise ExceptionGroup(f"{len(errors)} withdrawals failed", errors)
return balanceグループ自体が独立した例外クラスです。pytest.raises(ValueError)はその内部を見に行きません。中身を検証するには、グループをキャッチしてexcinfo.group_containsに問い合わせるか、pytest.RaisesGroupでグループ全体を指定します:
import pytest
from wallet import InsufficientFunds, withdraw_all
def test_plain_raises_does_not_look_inside():
with pytest.raises(ValueError):
withdraw_all(100.0, [-5.0, 30.0, 500.0])
def test_group_contains():
with pytest.raises(ExceptionGroup) as excinfo:
withdraw_all(100.0, [-5.0, 30.0, 500.0])
assert excinfo.group_contains(ValueError, match="must be positive")
assert excinfo.group_contains(InsufficientFunds)
assert not excinfo.group_contains(KeyError)
def test_raises_group():
with pytest.RaisesGroup(ValueError, InsufficientFunds):
withdraw_all(100.0, [-5.0, 30.0, 500.0])結果だけを確認するためにpytest -q --tb=noで実行します:
F.. [100%]
=========================== short test summary info ============================
FAILED test_wallet.py::test_plain_raises_does_not_look_inside - ExceptionGrou...
1 failed, 2 passed in 0.01s最初のテストは失敗します:グループは何の処理もされずにpytest.raises(ValueError)をすり抜けてしまいました。group_containsは「この中のいずれかが内部のどこかに含まれているか?」に答え、match=も受け入れます。pytest.RaisesGroupはさらに厳格です:グループはリストされた例外を過不足なく正確に保持している必要があります(順序は問われません)。これらは両方ともpytest 8で導入され、pytest 9でも利用可能です。頻繁に使うことはありませんが、ライブラリからグループを渡された日には必須となります。
完全な例
3つの拒否を持つ小さな銀行システムです:未知の口座、正の数ではない金額、そして残高不足です。金額のチェックはwithdrawのものとまったく同じように動作するため、以下のテストでは残りの2つに重点を置いています。AccountNotFoundは意図的にLookupErrorのサブクラスにしてあるため、呼び出し元は辞書のキーが見つからないときと同じ方法でキャッチできます。
bank.py:
class AccountNotFound(LookupError):
def __init__(self, account_id: str):
self.account_id = account_id
super().__init__(f"no account with id {account_id!r}")
class InsufficientFunds(Exception):
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
super().__init__(f"cannot withdraw {amount:.2f}; balance is {balance:.2f}")
class Bank:
def __init__(self) -> None:
self._balances: dict[str, float] = {}
def open(self, account_id: str, deposit: float) -> None:
self._balances[account_id] = deposit
def balance(self, account_id: str) -> float:
try:
return self._balances[account_id]
except KeyError:
raise AccountNotFound(account_id) from None
def transfer(self, source: str, target: str, amount: float) -> None:
if amount <= 0:
raise ValueError(f"amount must be positive (got {amount})")
# Look both accounts up first, so nothing moves if either is missing.
available = self.balance(source)
self.balance(target)
if amount > available:
raise InsufficientFunds(available, amount)
self._balances[source] -= amount
self._balances[target] += amounttest_bank.py:
import pytest
from bank import AccountNotFound, Bank, InsufficientFunds
def make_bank() -> Bank:
bank = Bank()
bank.open("alice", 100.0)
bank.open("bob", 20.0)
return bank
def test_transfer_moves_money():
bank = make_bank()
bank.transfer("alice", "bob", 30.0) # must not raise
assert bank.balance("alice") == 70.0
assert bank.balance("bob") == 50.0
def test_unknown_account_is_a_lookup_error():
bank = make_bank()
with pytest.raises(LookupError) as excinfo:
bank.balance("carol")
assert excinfo.type is AccountNotFound
assert excinfo.value.account_id == "carol"
def test_unknown_target_moves_nothing():
bank = make_bank()
with pytest.raises(AccountNotFound, match="'carol'"):
bank.transfer("alice", "carol", 30.0)
assert bank.balance("alice") == 100.0
def test_overdraft_reports_both_numbers():
bank = make_bank()
with pytest.raises(InsufficientFunds) as excinfo:
bank.transfer("bob", "alice", 25.0)
assert excinfo.value.balance == 20.0
assert excinfo.value.amount == 25.0
assert bank.balance("bob") == 20.0pytest -v:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/bank
collecting ... collected 4 items
test_bank.py::test_transfer_moves_money PASSED [ 25%]
test_bank.py::test_unknown_account_is_a_lookup_error PASSED [ 50%]
test_bank.py::test_unknown_target_moves_nothing PASSED [ 75%]
test_bank.py::test_overdraft_reports_both_numbers PASSED [100%]
============================== 4 passed in 0.01s ===============================注目すべき点が3つあります。
1つ目は、すべてのテストがwithブロックの中を1つの呼び出しにとどめ、状態の検証をブロックの後に行っていることです。特に価値が高いのがtest_unknown_target_moves_nothingです:もしtransferがCarolが存在しないことを見つける前にAliceからお金を引き落としていたとしても、依然としてAccountNotFoundは送出されます — そして、ブロックの後に置かれたアサートがassert 70.0 == 100.0によって中途半端な送金を検知できるのです。
2つ目は、test_unknown_account_is_a_lookup_errorが意図的に親クラスをキャッチしていることです。なぜならそれがbank.pyが約束していることであり、その上でexcinfo.typeを使ってどのサブクラスが届いたかを特定しているからです。
3つ目は、match="'carol'"が、エラーを読んだ人が最も必要とする詳細情報である「要求された口座名」がメッセージに含まれていることを検証している点です。
問題が起きたとき
Failed: DID NOT RAISE ValueError ブロックが例外を送出することなく終了しました。コードから本当にチェック処理が消えてしまったか(テストが役割を果たした)、あるいはブロック内の呼び出しが想定と異なっています — たまたま有効になってしまった誤った引数や、例外を送出する代わりにエラー値を返す関数などです。
AssertionError: Regex pattern did not match.に続いてDid you mean to re.escape() the regex?と表示される メッセージは合っていますが、正規表現の特殊文字が含まれています(通常は(、)、.、[、+など)。テキストをre.escape(...)で囲んでください。このヒントが出ていない場合は、メッセージが実際に異なっています:Expected regexとActual messageの行を1文字ずつ見比べてみてください。
Failed: Invalid regex pattern provided to 'match': missing ), unterminated subpattern at position 0 パターンが有効な正規表現ですらありません — ここでは、閉じ括弧のない開き括弧(です。修正方法は同じで、re.escapeを使います。
テストが別の例外で失敗する(例:wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00) 例外は送出されましたが、指定された例外でもそのサブクラスでもなかったため、pytest.raisesがそのまま素通りさせました。コードがどちらの例外を送出すべきなのかを判断し、テストかコードのいずれかを修正してください。
AssertionError: .value can only be used after the context manager exits withブロックの内部でexcinfo.valueにアクセスしてしまいました。その行をブロックの下に移動してください。
TypeError: Expected a BaseException type, but got 'str' pytest.raises("ValueError")と書いてしまっています。クォートを付けずにクラスそのものを渡してください。
pytest.raises(ValueError)のブロックからFAILED ... - ExceptionGroup...が発生する コードがValueErrorを含むExceptionGroupを送出しました。グループはValueErrorではないため、excinfo.group_contains(ValueError)を指定したpytest.raises(ExceptionGroup)を使うか、pytest.RaisesGroup(ValueError)を使用してください。
テストはパスしているが、失敗すべきであると確信している場合 withブロック内の例外を送出する行の後に置かれた行(絶対に実行されません)や、ほぼあらゆる例外を受け入れてしまうpytest.raises(Exception)を探してください。その後、コードを意図的に壊してみて、テストが赤(失敗)になることを確認してください。
ステップ 4 / 6 — 予想する
理解度をチェック
pytest -qが出力する最後の行は何ですか?
import pytest
def test_missing_key():
prices = {"pen": 15.0}
with pytest.raises(LookupError):
prices["eraser"]
def test_missing_index():
prices = [15.0, 60.0]
with pytest.raises(KeyError):
prices[5]- A2 passed in 0.01s
- B2 failed in 0.01s
- C1 failed, 1 passed in 0.01s
- D1 passed, 1 error in 0.01s
このテストはパスします。何が問題なのでしょうか?
import pytest
from wallet import InsufficientFunds, withdraw
def test_overdraft_names_the_amount():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
assert excinfo.value.amount == 999.0- Aassertが`AssertionError`を送出したため、`pytest.raises`がテストを失敗させる
- Bassertがブロック内の例外を送出する行の後に置かれているため、一度も実行されない
- C問題ない — `excinfo.value.amount`は実際に999.0である
- D`excinfo`は`with`文で指定できない
withdraw(100.0, -5.0)は、まさにこのメッセージ:amount must be positive (got -5.0)とともにValueErrorを送出します。このテストはどうなりますか?
import pytest
from wallet import withdraw
def test_whole_message():
with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
withdraw(100.0, -5.0)- Aパスする — 2つのテキストは完全に同一だから
- B`Invalid regex pattern`で失敗する
- C`DID NOT RAISE ValueError`で失敗する
- D丸括弧が正規表現のグループを形成するため、`Regex pattern did not match`で失敗する
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
1つの関数remove_stock(stock: dict[str, int], item: str, quantity: int) -> intと2つの例外クラスを持つinventory.pyを作成してください:
KeyErrorのサブクラスであり、itemがstock内に存在しない場合に送出され、属性としてアイテム名を保持するUnknownItemquantityが残りの在庫数よりも多い場合に送出され、availableとrequestedの属性を保持するOutOfStockquantityが正の数でない場合に、メッセージ内にその値を含めて送出される通常のValueError
この関数は残りの在庫数を返し、成功した場合にのみ辞書を変更します。
続いて、少なくとも6つのテストを含むtest_inventory.pyを作成してください:
- 正常な在庫の引き落とし —
pytest.raisesは使わず、単に関数を呼び出して結果をアサートする - 未知のアイテム。
KeyErrorとしてキャッチし、excinfo.typeでそれがUnknownItemであることを確認する - 数量がゼロ。
match=を使ってメッセージにその値が含まれていることを検証する — メッセージに括弧やドットが含まれている場合はre.escapeを使用する - 在庫の超過。
excinfo.value.availableとexcinfo.value.requestedをアサートする - 同じく在庫の超過。ブロックの後で辞書が変更されていないことをアサートする
- ちょうど残っている数量の引き落とし。例外を送出してはならない
作成できたら、コードを1つずつ3回壊してみて、それぞれの失敗を確認してください:数量ゼロのチェックを削除する(DID NOT RAISEを確認)、在庫チェックの>を>=に変更する、そして最後にアサートの1つをwithブロックの中、例外を送出する行の下に移動して偽の値を設定してみます。最初の2つは赤(失敗)になるはずです。3つ目は緑(パス)のままになります — そしてそれこそが、この章から覚えておくべき教訓です。
解答例
この章の他の部分と同様に、計画から始めます:1つの通常のケース、許可されるべき境界値(全量を取り出すこと)、そして各拒否についてクラス、メッセージまたは属性、残される状態のチェックです。
inventory.py:
class UnknownItem(KeyError):
def __init__(self, item: str):
self.item = item
super().__init__(item)
class OutOfStock(Exception):
def __init__(self, item: str, available: int, requested: int):
self.item = item
self.available = available
self.requested = requested
super().__init__(f"only {available} {item} left, {requested} requested")
def remove_stock(stock: dict[str, int], item: str, quantity: int) -> int:
if quantity <= 0:
raise ValueError(f"quantity must be positive (got {quantity})")
if item not in stock:
raise UnknownItem(item)
available = stock[item]
if quantity > available:
raise OutOfStock(item, available, quantity)
stock[item] = available - quantity
return stock[item]test_inventory.py:
import re
import pytest
from inventory import OutOfStock, UnknownItem, remove_stock
def test_removal_returns_what_is_left():
stock = {"pen": 10}
assert remove_stock(stock, "pen", 3) == 7
assert stock == {"pen": 7}
def test_unknown_item_is_a_key_error():
stock = {"pen": 10}
with pytest.raises(KeyError) as excinfo:
remove_stock(stock, "eraser", 1)
assert excinfo.type is UnknownItem
assert excinfo.value.item == "eraser"
def test_zero_quantity_names_the_value():
stock = {"pen": 10}
with pytest.raises(ValueError, match=re.escape("(got 0)")):
remove_stock(stock, "pen", 0)
def test_overdraw_reports_both_numbers():
stock = {"pen": 10}
with pytest.raises(OutOfStock) as excinfo:
remove_stock(stock, "pen", 11)
assert excinfo.value.available == 10
assert excinfo.value.requested == 11
def test_overdraw_changes_nothing():
stock = {"pen": 10}
with pytest.raises(OutOfStock):
remove_stock(stock, "pen", 11)
assert stock == {"pen": 10}
def test_removing_everything_is_allowed():
stock = {"pen": 10}
assert remove_stock(stock, "pen", 10) == 0
assert stock == {"pen": 0}pytest -v:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/inventory
collecting ... collected 6 items
test_inventory.py::test_removal_returns_what_is_left PASSED [ 16%]
test_inventory.py::test_unknown_item_is_a_key_error PASSED [ 33%]
test_inventory.py::test_zero_quantity_names_the_value PASSED [ 50%]
test_inventory.py::test_overdraw_reports_both_numbers PASSED [ 66%]
test_inventory.py::test_overdraw_changes_nothing PASSED [ 83%]
test_inventory.py::test_removing_everything_is_allowed PASSED [100%]
============================== 6 passed in 0.01s ===============================続いて、3つの破壊実験を行います。quantity <= 0のチェックを削除した場合、pytest -q --tb=no:
..F... [100%]
=========================== short test summary info ============================
FAILED test_inventory.py::test_zero_quantity_names_the_value - Failed: DID NO...
1 failed, 5 passed in 0.01s元に戻し、quantity > availableをquantity >= availableに変更した場合:
.....F [100%]
=========================== short test summary info ============================
FAILED test_inventory.py::test_removing_everything_is_allowed - inventory.Out...
1 failed, 5 passed in 0.01sそれも元に戻し、test_overdraw_changes_nothingの最後のアサートをブロックの中、例外を送出する行の下に移動して、assert stock == {"pen": 999}とした場合:
...... [100%]
6 passed in 0.01sそれぞれの判断の理由:
UnknownItemはKeyErrorを継承する。 アイテムが存在しないことは実際にキーが存在しないことであるため、KeyErrorをキャッチする既存のコードがそのまま機能し続けます。テストは公開された契約であるKeyErrorをキャッチし、その後excinfo.typeで正確なクラスを特定します。- 数量チェックを最初に行う。
stockの情報を必要としないため即座に失敗(fail-fast)させることができ、メッセージも呼び出し元が間違えた引数に関するものになります。 re.escape("(got 0)")を使用する。 この一部分にエラーを読む人が必要とする値が含まれており、re.escapeによって丸括弧がリテラルとして扱われます。文全体をマッチさせようとすると、誰かが表現を少し変えただけで壊れてしまいます。- メッセージのパースよりも属性を優先する。
availableとrequestedは直接比較できる数値です。これらを文字列から取り出そうとすると、データそのものではなくフォーマットのテストになってしまいます。 - 在庫超過に対して2つのテストを用意する。 1つは例外が何を伝えているかをチェックし、もう1つはコードがどんな状態を残したかをチェックします。片方が失敗したとき、そのテスト名を見ればどちらの約束が破られたかがすぐに分かります。
- 境界値テストが
>=のバグを検知した。 エラーのテストではどれも検知できませんでした:すべて依然として例外を送出していたからです。境界においてエラーが起きないことを期待するテストだけが、この問題に気づくことができました。 - 3つ目の破壊実験はグリーンのままだった。 これがこの章の前半で取り上げた「例外送出の後の行」の罠です:偽のアサートは一度も実行されませんでした。唯一の対策は習慣づけることです — ブロックの中には1つの呼び出しのみを置き、検証はブロックの後に置きます。