assertと失敗レポートの読み方
pytestが通常のassertの内側を読み取る仕組み、失敗レポートを行ごとに読み解く方法、そしてレポートから最も多くの情報を得るためにどの比較演算子を書くべきかを学びます。
どうやって学びますか?
解決する問題
以下は、check.pyというファイルに書かれた純粋なPythonによるチェックです:
def full_name(first, last):
return f"{first} {last}".title()
assert full_name("ada", "lovelace") == "Ada Lovelace "Traceback (most recent call last):
File "/home/you/shop/check.py", line 5, in <module>
assert full_name("ada", "lovelace") == "Ada Lovelace "
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorPythonは主張が偽(false)だったことしか教えてくれず、それ以外は何も分かりません。full_nameは実際に何を返したのでしょうか?それを知るには、printを追加してもう一度実行し、確認しなければなりません。
まったく同じ行をテストファイルtest_names.pyに記述し、pytest -qを実行してみます:
from names import full_name
def test_full_name():
assert full_name("ada", "lovelace") == "Ada Lovelace "F [100%]
=================================== FAILURES ===================================
________________________________ test_full_name ________________________________
def test_full_name():
> assert full_name("ada", "lovelace") == "Ada Lovelace "
E AssertionError: assert 'Ada Lovelace' == 'Ada Lovelace '
E
E - Ada Lovelace
E ? -
E + Ada Lovelace
test_names.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_names.py::test_full_name - AssertionError: assert 'Ada Lovelace' ...
1 failed in 0.01s今度は両方の値が表示され、異なっている1文字の下にマーカーが示されています。期待値の文字列の末尾にあるスペースです。関数側は正しく、テスト側が間違っていました。
失敗したテストの有用性は、それが何を教えてくれるかによって決まります。この章では、pytestが教えてくれるすべての情報を読み解く方法と、pytestに価値あるレポートを出力させるようなアサートの書き方について学びます。
この章を終える頃には
- アサーションの書き換えとは何か、そしてそれがどこに適用され、どこに適用されないかを説明できるようになる
- 失敗レポートを行ごとに読み解けるようになる:
>、E、where、file:line、および簡潔な要約(short summary) ==、in、is None、is True、そして素のassert xの使い分けができるようになる- リスト、辞書、文字列のdiffを読み解き、
-vvを使って完全なdiffを要求できるようになる - アサートにメッセージを追加し、常に真になってしまうタプルの罠を回避できるようになる
--tb=short、--tb=line、--tb=no、および-lを使ってレポートの表示形式を調整できるようになる
前提条件: pytestのインストールと初めてのテスト。
テストを書く前に
assertとは、明文化された約束(契約)です。それを1つ打ち込む前に、答えておくべき3つの問いがあります。そして、そのいずれにもpytestは関係していません。
具体的に何が約束されているのか? この章の最後に作成するsummarise(lines)関数を取り上げます。これは"pen, 12, 15.0"のような請求書の明細行を読み取ります。その契約を1文で表すと、「空行をスキップし、項目の件数、指定された順序での名前のリスト、および小数第2位までに丸めた合計を含む辞書を返す」となります。後で書くすべてのアサートは、この1文のそれぞれの節に相当します。もしこの文を口にできなければ、まだテストを書く準備ができていません — コードがたまたま行っている適当な動作をテストすることになってしまうからです。
何を用意しておく必要があるか? この章で必要なものはごくわずかです:第1章で作ったpytestがインストールされた仮想環境(python -m pytest --versionで確認可能)、そしてpytestを実行する場所からテスト対象のモジュールがインポートできること — ここではinvoice.pyがtest_invoice.pyの隣に置かれており、そのフォルダからpytestを実行します。追加のファイルも、ネットワークも、フィクスチャも必要ありません。
どのケースをテストし、それぞれ何を期待するか? コードを実行する前に、期待値を手計算で求めておきます。12 × 15.0 + 2 × 850.0は1880.0です。この数値は算術計算から導き出されたものであり、summariseが出力した結果から取ってきたものではありません。もし代わりに出力をテストにそのままコピーしてしまうと、そのテストはバグも含めた今日の振る舞いを記録してしまい、それ以降そのバグを守り続けることになります。
| ケース | 入力 | 期待値 | 記述するアサート | | --- | --- | --- | --- | | 1行、名前の前後に空白あり | " pen , 12, 15.0" | {"name": "pen", "qty": 12, "price": 15.0} | 辞書全体に対する== | | 空行がスキップされる | ["pen, 12, 15.0", "", "bag, 2, 850.0"] | 件数が2 | == | | 集約全体 | 同じ3行 | {"count": 2, "names": ["pen", "bag"], "total": 1880.0} | 辞書全体に対する== | | 存在する項目の検索 | "pen" | 1件のレコード(Noneではない) | is not None | | 存在しない項目の検索 | 空リスト、"pen" | None | is None | | 合計が妥当である | 同じ3行 | 0より大きい | メッセージ付きの> |
最後の列こそが、この章で扱うテーマです。それぞれの約束に対して、それを最も正確に表現し、失敗したときに最も有用なレポートを出力してくれる比較演算子を選びます。
テストすべきでないこと。 str.splitが文字列を分割すること、roundが丸め処理を行うこと、int("12")が12になること — これらはPython自体がすでにテストしています。pytestの出力の正確な文言も、あなたがテストすべきものではありません。"pen, twelve, 15.0"のような不正な行は計画表に含めるべきですが、エラーが送出されることをアサートするにはpytest.raisesが必要であり、これは第4章で扱います。行の計画は今メモしておき、テストは第4章で書くことにしましょう。
pytestがassertの内側を見る仕組み
純粋なPythonは、assertが失敗した瞬間にその値を捨ててしまいます。pytestがそれらの値を保持できるのは、テストファイルを実行する前に書き換えているからです。
pytestはテストモジュールをインポートする際、すべてのassert文を見つけ出し、条件を評価する前に各計算途中の値 — full_name(...)の結果や、右辺の文字列など — を変数に保存するコードへと書き換えます。そしてチェックが失敗したとき、それらの保存された値がレポートとして生成されます。これがアサーションの書き換え(assertion rewriting)であり、pytestにassertEqualやassertInが不要な理由です。通常の==を使った通常のassertで十分なのです。
この機能をオフにしてみて、それが何をしてくれているのかを確認してみましょう。同じテストに対してpytest -q --assert=plainを実行します:
def test_full_name():
> assert full_name("ada", "lovelace") == "Ada Lovelace "
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E AssertionError
test_names.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_names.py::test_full_name - AssertionError素の1単語だけに戻ってしまいました。書き換えが適用されるのは、テストファイル(test_*.py、*_test.py)およびconftest.pyです。それ以外のモジュールには適用されません。この違いは、ヘルパー関数の中にassertを記述した途端に重要になります — それがどのような挙動になるかは「問題が起きたとき」で詳しく説明します。
失敗レポートを行ごとに読み解く
小さなモジュールcart.pyを用意します:
def subtotal(items):
return sum(price * qty for price, qty in items)
def total(items, discount):
return subtotal(items) - discountそしてtest_cart.pyです。ここでは2つ目のテストが間違った数値を期待しています:
from cart import subtotal, total
def test_subtotal():
assert subtotal([(15.0, 2), (60.0, 1)]) == 90.0
def test_total_with_discount():
items = [(15.0, 2), (60.0, 1)]
assert total(items, 10) == 70.0オプションなしでpytestを実行します:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 2 items
test_cart.py .F [100%]
=================================== FAILURES ===================================
___________________________ test_total_with_discount ___________________________
def test_total_with_discount():
items = [(15.0, 2), (60.0, 1)]
> assert total(items, 10) == 70.0
E assert 80.0 == 70.0
E + where 80.0 = total([(15.0, 2), (60.0, 1)], 10)
test_cart.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_total_with_discount - assert 80.0 == 70.0
========================= 1 failed, 1 passed in 0.01s ==========================上から順に読み解いていきましょう:
test_cart.py .F— テストごとに順番に1文字ずつ表示されます。.はパス、Fは失敗です。____ test_total_with_discount ____— 1つの失敗の見出しです。失敗した各テストに個別のセクションが割り当てられます。- ソースコードの行 — 失敗した行までのテスト本文です。
>— 例外を送出した正確な行です。この行より上のすべての行は何の問題もなく実行されました。E assert 80.0 == 70.0— 各式をその評価値に置き換えたアサートの再表示です。左辺は80.0、右辺は70.0でした。E + where 80.0 = total(...)— 値がどこから来たのかを示します。関数呼び出しの場合、引数が埋め込まれた状態で呼び出しが表示されます。ネストした呼び出しがある場合は、インデントが深くなったwhere行が複数続きます。test_cart.py:10: AssertionError— エディタで直接ジャンプできるファイル名と行番号、および例外の種類です。short test summary info— 失敗ごとに1行、FAILED path::test_name - first line of the errorと表示されます。失敗が多数ある場合は、まずこの一覧を読みます。- 最後の行 — テスト件数の内訳と所要時間です。
なぜこの順序で読むのでしょうか?それは、各行が調査の範囲を段階的に絞り込んでくれるからです。要約リストはどのテストが失敗したかを教え、>行はどの主張が破綻したかを教え、E行はどちらの辺が食い違っているかを教え、where行はどの呼び出しがその値を生成したかを教えてくれます。コードを開くのはそのあとです — しかも、最初から正しい関数を開くことができます。
最初のE行は、AssertionError: assert ...と表示されることもあれば、単にassert ...と表示されることもあります。どちらも同じ失敗です。値にクォート文字が含まれている場合にのみ、先頭に接頭辞が残ります。
最もよく書く比較
assertの後には任意の式を置くことができます。そしてpytestは一般的な式について分かりやすい説明を出力します。users.pyを見てみましょう:
def find_user(users, email):
for user in users:
if user["email"] == email:
return user
return None
def active_emails(users):
return [u["email"] for u in users if u["active"]]4つのテストで、4種類の比較を行います:
from users import active_emails, find_user
ADA = {"email": "[email protected]", "active": True}
ALAN = {"email": "[email protected]", "active": False}
def test_equal():
assert active_emails([ADA, ALAN]) == ["[email protected]", "[email protected]"]
def test_in():
assert "[email protected]" in active_emails([ADA, ALAN])
def test_is_not_none():
assert find_user([ADA], "[email protected]") is not None
def test_greater():
assert len(active_emails([ADA, ALAN])) > 14つともすべて失敗します。1回のpytest -qの実行から、それぞれの>行とE行を抜粋したものが以下です:
> assert active_emails([ADA, ALAN]) == ["[email protected]", "[email protected]"]
E AssertionError: assert ['[email protected]'] == ['ada@[email protected]']
E
E Right contains one more item: '[email protected]'
E Use -v to get more diff
> assert "[email protected]" in active_emails([ADA, ALAN])
E AssertionError: assert '[email protected]' in ['[email protected]']
E + where ['[email protected]'] = active_emails([{'email': '[email protected]', 'active': True}, {'email': '[email protected]', 'active': False}])
> assert find_user([ADA], "[email protected]") is not None
E AssertionError: assert None is not None
E + where None = find_user([{'email': '[email protected]', 'active': True}], '[email protected]')
> assert len(active_emails([ADA, ALAN])) > 1
E AssertionError: assert 1 > 1
E + where 1 = len(['[email protected]'])
E + where ['[email protected]'] = active_emails([{'email': '[email protected]', 'active': True}, {'email': '[email protected]', 'active': False}])それぞれが異なる情報を伝えています:
==は値を比較します。リストの場合、pytestはそれらがどのように異なるかも教えてくれます — 右辺に項目が1つ多く含まれています(active_emailsがAlanを除外したのは正しく、このテストの期待値が間違っていました)。inは要素の所属関係をチェックし、コンテナ全体を表示してくれるため、代わりに何が入っていたかを確認できます。is None/is not Noneは、唯一のNoneオブジェクトとの同一性をチェックします。検索が大文字と小文字を区別するため、find_userはNoneを返しました。<、>、<=、>=は書かれた通りに機能します。2つのwhere行が表示され、1はlen(...)から得られ、そのリストはactive_emails(...)から得られたことが分かります。
どの書き方でも失敗させることができるのに、なぜ書き方の選択が重要なのでしょうか?それは、レポートがあなたが記述した式をもとに構築されるからです。「Alanはリストに含まれているか?」を検証する間違った書き方がこちらです:
def test_count():
emails = ["[email protected]"]
assert emails.count("[email protected]") > 0> assert emails.count("[email protected]") > 0
E AssertionError: assert 0 > 0
E + where 0 = <built-in method count of list object at 0x7d9189cec640>('[email protected]')
E + where <built-in method count of list object at 0x7d9189cec640> = ['[email protected]'].count見出しにはassert 0 > 0と表示され、本当に気になっているリストは2つ目のwhereの末尾に埋もれてしまいます。正しい書き方であるassert "[email protected]" in emailsを使えば、見出しに直接そのリストが表示されます。頭の中にあった文の通りにアサートを書くことで、pytestもまったく同じ言葉で失敗を説明してくれます。
真偽性(Truthiness):assert x は assert x == True ではない
assert xは、xがtruthy(真とみなされる値)である限り常にパスします — つまり、False、None、0、""、および空のコンテナ以外のあらゆる値です。多くの場合、これはまさに求めている挙動ですが、時にはバグを覆い隠してしまうことがあります。
rules.pyには2つの関数があります。overdueは正しいコードですが、is_adultにはバグがあります — 真偽値(boolean)の代わりに文字列を返してしまっています:
def overdue(invoices):
return [i for i in invoices if i["days"] > 30]
def is_adult(age):
if age >= 18:
return "yes"
return "no"from rules import is_adult, overdue
def test_finds_overdue():
assert overdue([{"id": 1, "days": 12}, {"id": 2, "days": 30}])
def test_child_is_not_adult():
assert not is_adult(12)
def test_adult_truthy():
assert is_adult(40)
def test_adult_is_true():
assert is_adult(40) is Truepytest -qを実行し、3つの失敗をそれぞれのE行に絞り込んだ結果です:
FF.F [100%]
E AssertionError: assert []
E + where [] = overdue([{'id': 1, 'days': 12}, {'id': 2, 'days': 30}])
E AssertionError: assert not 'no'
E + where 'no' = is_adult(12)
E AssertionError: assert 'yes' is True
E + where 'yes' = is_adult(40)
=========================== short test summary info ============================
FAILED test_rules.py::test_finds_overdue - AssertionError: assert []
FAILED test_rules.py::test_child_is_not_adult - AssertionError: assert not 'no'
FAILED test_rules.py::test_adult_is_true - AssertionError: assert 'yes' is True
3 failed, 1 passed in 0.01s最初の失敗:素のassert some_listであっても、有用なレポートが得られます。assert []は、リストの内容が間違っているのではなく空で返ってきたことを示しています。30日は30日より大きくはないため、コード側は正しく、テスト側のデータが間違っていたことが分かります — たった1行からこれが読み取れます。
次に、FF.Fの中のドット(.)に注目してください。test_adult_truthyはパスしました。is_adultは文字列"yes"を返し、これはtruthyであるため、assert is_adult(40)はバグのある関数によって満たされてしまいました。"no"も同様にtruthyであるため、assert not is_adult(12)はたまたまバグを捉えることができましたが — それは偶然そのケースをテストしたからにすぎません。
では、assert x == Trueと書くべきでしょうか?これは"yes"を検出できますが、別の落とし穴があります。Pythonでは1 == Trueであり、0 == Falseなのです。stock.pyを見てみましょう:
def in_stock(qty):
return qty and qty > 0from stock import in_stock
def test_zero_equals_false():
assert in_stock(0) == False
def test_zero_is_false():
assert in_stock(0) is False.F [100%]
> assert in_stock(0) is False
E assert 0 is False
E + where 0 = in_stock(0)
1 failed, 1 passed in 0.01sqty and qty > 0は、qtyが0のときにFalseではなく0を返します。==を使ったテストはパスしてしまいましたが、isを使ったテストはこれを見逃しませんでした。ここから導かれるルールは次の通りです:
assert x/assert not x: 任意のtruthyまたはfalsyな値を受け入れる場合に使います — 「リストが空ではない」「エラーメッセージが存在しない」など。assert x is True/assert x is False: 関数が本物のboolを返す契約になっている場合に使います。assert x == True: ほぼ使うことはありません。is Trueよりも厳密さに欠け、素のassert xよりも情報量が少なく、リンター(linter)からも警告されます。
値が大きい場合:diff
短い値であれば、assert 80.0 == 70.0だけで全容が分かります。しかし、40項目のリストや3行のメール文面の場合、pytestは両辺を比較して異なっている部分だけを表示してくれます。
def test_list():
got = ["pen", "notebook", "bag", "eraser"]
assert got == ["pen", "notebook", "ruler", "eraser"]
def test_dict():
got = {"id": 7, "name": "Ada", "role": "admin", "active": True}
assert got == {"id": 7, "name": "Ada", "role": "editor", "active": True}
def test_multiline_string():
got = "Dear Ada,\nYour order 7 has shipped.\nThanks!"
assert got == "Dear Ada,\nYour order 7 has been shipped.\nThanks!"3つの失敗のE行がこちらです:
E AssertionError: assert ['pen', 'note...ag', 'eraser'] == ['pen', 'note...er', 'eraser']
E
E At index 2 diff: 'bag' != 'ruler'
E Use -v to get more diff
E AssertionError: assert {'id': 7, 'na...active': True} == {'id': 7, 'na...active': True}
E
E Omitting 3 identical items, use -vv to show
E Differing items:
E {'role': 'admin'} != {'role': 'editor'}
E Use -v to get more diff
E AssertionError: assert 'Dear Ada,\nY...ped.\nThanks!' == 'Dear Ada,\nY...ped.\nThanks!'
E
E Dear Ada,
E - Your order 7 has been shipped.
E ? -----
E + Your order 7 has shipped.
E Thanks!枠内に収めるために最初のE行は...で省略されていますが、その下の行に詳細が示されています:
- リスト — 最初に食い違ったインデックス、またはどちらの側に余分な項目があるかを表示します。
- 辞書 — 値が異なっているキーを表示します。一致しているキーは出力されず、件数のみカウントされます。
- 文字列 — 行ごとのdiffを表示します。先頭に2つの空白がある行は、両辺で一致している行です。
+は==の左辺、-は右辺を示します。その下の?行は、正確にどの文字が異なっているかを指し示します。
したがって、常にassert actual == expected(実際の値 == 期待値)の順序で書いていれば、+はコードが生成した値であり、-は求めていた値になります。テストごとにこの順序を入れ替えてしまうと、毎回どちらがどちらなのかを立ち止まって考えなければならなくなります。順序を一貫させておけば、その手間は一切不要になります。
1行の長い文字列でも同様に処理され、一致している部分はスキップされます:
def test_receipt_line():
got = "2026-10-07 | order 1042 | 3 items | paid by card | ships to Dhaka | total 2420.00"
assert got == "2026-10-07 | order 1042 | 3 items | paid by card | ships to Dhaka | total 2402.00"E AssertionError: assert '2026-10-07 |...total 2420.00' == '2026-10-07 |...total 2402.00'
E
E Skipping 66 identical leading characters in diff, use -v to show
E - | total 2402.00
E ? -
E + | total 2420.00
E ? +80文字の行の末尾で数字が2つ入れ替わっていたバグを、自動で見つけ出してくれました。
-v と -vv:diff全体を要求する
Use -v to get more diffやuse -vv to showというヒントに注目してください。デフォルトではpytestは長い説明を切り詰めて表示し、-vを付けるごとに詳細度(verbosity)が上がります。辞書のテストをpytest -vvで実行した結果です:
E AssertionError: assert {'id': 7, 'name': 'Ada', 'role': 'admin', 'active': True} == {'id': 7, 'name': 'Ada', 'role': 'editor', 'active': True}
E
E Common items:
E {'active': True, 'id': 7, 'name': 'Ada'}
E Differing items:
E {'role': 'admin'} != {'role': 'editor'}
E
E Full diff:
E {
E 'id': 7,
E 'name': 'Ada',
E - 'role': 'editor',
E ? ^ ^^^
E + 'role': 'admin',
E ? ^ + ^
E 'active': True,
E }何も省略されていません。両方の辞書全体、共通の項目、そして1キーごとに1行のdiffが表示されています。なぜ常に-vvを使わないのでしょうか?それは、200件のレコードがあるリストの場合、完全なdiffは200行にも及び、デフォルトの要約 — 「インデックス2において」 — こそが本当に必要だった情報だからです。まずはデフォルトで実行し、要約だけでは不十分な特定のテストに対して-vvを追加するようにしましょう。
独自のメッセージ:assert cond, "msg"
カンマの後ろに、assertはメッセージを受け取ることができます。pytestはそのメッセージを自身の説明の上に表示します:
def test_everything_in_stock():
stock = {"pen": 12, "bag": 0}
for name, qty in stock.items():
assert qty > 0, f"{name} is out of stock"> assert qty > 0, f"{name} is out of stock"
E AssertionError: bag is out of stock
E assert 0 > 0メッセージがなければassert 0 > 0としか表示されず、どの商品がゼロだったのかを自分で調べなければなりません。値だけではどのケースが失敗したのか、あるいはなぜそのルールが存在するのかが分からない場合に、メッセージはその真価を発揮します。誤った使い方は、pytestがすでに表示している内容を繰り返すことです。"expected 70.0 but got 80.0"と書いても、assert 80.0 == 70.0に対して何の新しい情報も付け加えられません。
タプルの罠
メッセージを追加して行が長くなると、全体を丸括弧で囲みたくなるかもしれません:
def test_total():
total = 80.0
assert (total == 70.0, "total is wrong"). [100%]
=============================== warnings summary ===============================
test_trap.py:3
/home/you/shop/test_trap.py:3: PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses?
assert (total == 70.0, "total is wrong")
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
1 passed, 1 warning in 0.01stotalは80.0であるにもかかわらず、テストはパスしました。丸括弧の中にカンマがあると2要素のタプルが作られ、空でないタプルは常にtruthy(真とみなされる)になります — つまりassertは比較演算ではなくタプルそのものをチェックしてしまっているのです。pytestは警告を出してくれますが、大量のテスト実行結果の末尾に出る警告は見落とされがちです。外側の丸括弧を削除してください:
def test_total():
total = 80.0
assert total == 70.0, "total is wrong"> assert total == 70.0, "total is wrong"
E AssertionError: total is wrong
E assert 80.0 == 70.0もし行が長すぎる場合は、メッセージの部分だけを丸括弧で囲みます:
def test_total():
total = 80.0
assert total == 70.0, (
"total is wrong after applying the loyalty discount"
)振る舞いごとに1つのアサート
フィールドを1つずつ順番にチェックしていくテストは、最初に失敗したフィールドで停止してしまいます。orders.pyには2つのバグがあります — 名前の空白が除去されておらず、数量が文字列のままになっています:
def parse_order(line):
order_id, customer, qty, price = line.split(",")
return {"id": int(order_id), "customer": customer, "qty": qty, "price": float(price)}間違ったやり方と、それに続く正しいやり方です:
from orders import parse_order
def test_parse_order_fields():
order = parse_order("1042, Ada ,3,15.0")
assert order["id"] == 1042
assert order["customer"] == "Ada"
assert order["qty"] == 3
assert order["price"] == 15.0
def test_parse_order():
order = parse_order("1042, Ada ,3,15.0")
assert order == {"id": 1042, "customer": "Ada", "qty": 3, "price": 15.0}> assert order["customer"] == "Ada"
E AssertionError: assert ' Ada ' == 'Ada'
E
E - Ada
E + Ada
E ? + +
> assert order == {"id": 1042, "customer": "Ada", "qty": 3, "price": 15.0}
E AssertionError: assert {'id': 1042, ...'price': 15.0} == {'id': 1042, ...'price': 15.0}
E
E Omitting 2 identical items, use -vv to show
E Differing items:
E {'customer': ' Ada '} != {'customer': 'Ada'}
E {'qty': '3'} != {'qty': 3}
E Use -v to get more diff1つ目のテストはバグを1件だけ報告します。それを修正して再実行すると、ようやく2つ目のバグに遭遇します。2つ目のテストは結果全体を一度に比較するため、両方のバグを同時に報告してくれます。
ここでの指針は、「テストごとに1つのassert」ではなく、「テストごとに1つの振る舞い」です。「1行を解析するとこのレコードが生成される」というのは1つの振る舞いであるため、値全体の比較を1回行うのが理想的です。複数のアサートであっても、それらが全体として1つの振る舞いを記述しているなら問題ありません。避けるべきなのは、1つのテストの中で解析をチェックし、次に空行のスキップをチェックし、さらに合計の計算までチェックすることです — これらは3つの異なる振る舞いであり、最初の失敗が残りの2つを覆い隠してしまい、テスト名も何が壊れたのかを正確に表せなくなってしまいます。
レポートの表示量:--tb と -l
見出しとfile:line(ファイル名と行番号)の間の部分はトレースバック(traceback)と呼ばれ、--tbオプションでその表示量を調整できます。cart.pyのテストをpytest -q --tb=shortで実行した結果です:
___________________________ test_total_with_discount ___________________________
test_cart.py:10: in test_total_with_discount
assert total(items, 10) == 70.0
E assert 80.0 == 70.0
E + where 80.0 = total([(15.0, 2), (60.0, 1)], 10)関数全体ではなく、失敗した行のみが表示されます。--tb=lineを指定すると、失敗ごとに1行の場所情報にまとめられます:
E assert 80.0 == 70.0
+ where 80.0 = total([(15.0, 2), (60.0, 1)], 10)
/home/you/shop/test_cart.py:10: assert 80.0 == 70.0そして--tb=noを指定すると、要約のみが残ります:
.F [100%]
=========================== short test summary info ============================
FAILED test_cart.py::test_total_with_discount - assert 80.0 == 70.0
1 failed, 1 passed in 0.01sデフォルトの--tb=autoは、この章でこれまで見てきた詳細な形式です。大規模なテスト実行では、--tb=noまたは--tb=lineを使って何が失敗したかを確認し、その後に対象のテスト1件をデフォルト設定で再実行してなぜ失敗したかを調査します。
逆に情報を増やしたい場合は、-l(--showlocals)を付けることで、失敗した関数のローカル変数が表示に追加されます。pytest -q -lを実行した結果です:
> assert total(items, 10) == 70.0
E assert 80.0 == 70.0
E + where 80.0 = total([(15.0, 2), (60.0, 1)], 10)
items = [(15.0, 2), (60.0, 1)]
test_cart.py:10: AssertionErrorループや複数の中間変数を含むテストでは、この追加ブロックこそが失敗の原因を解き明かす鍵になることがよくあります — 巻末の解答例でもその効果を確認できます。
完全な例
invoice.pyは"pen, 12, 15.0"のような行を読み取ります:
def parse_line(line):
name, qty, price = line.split(",")
return {"name": name.strip(), "qty": int(qty), "price": float(price)}
def summarise(lines):
items = [parse_line(line) for line in lines if line.strip()]
total = sum(item["qty"] * item["price"] for item in items)
return {
"count": len(items),
"names": [item["name"] for item in items],
"total": round(total, 2),
}
def find_item(items, name):
for item in items:
if item["name"] == name:
return item
return Nonetest_invoice.pyは「テストを書く前に」で立てた計画をそのままコードにしたものであり、表の1行が1つのテストに対応し、それぞれ表の最後の列で指定した比較演算子を使って書かれています:
from invoice import find_item, parse_line, summarise
LINES = ["pen, 12, 15.0", "", "bag, 2, 850.0"]
def test_parse_line_strips_and_converts():
assert parse_line(" pen , 12, 15.0") == {"name": "pen", "qty": 12, "price": 15.0}
def test_blank_lines_are_skipped():
assert summarise(LINES)["count"] == 2
def test_summary():
assert summarise(LINES) == {"count": 2, "names": ["pen", "bag"], "total": 1880.0}
def test_find_item_present():
items = [parse_line("pen, 12, 15.0")]
assert find_item(items, "pen") is not None
def test_find_item_missing():
assert find_item([], "pen") is None
def test_total_is_positive():
total = summarise(LINES)["total"]
assert total > 0, f"total should be positive, got {total}"pytest -qを実行します:
...... [100%]
6 passed in 0.01sここで誰かがsummariseを「整理」して、名前がソートされて返るように変更したとします。1行を"names": sorted(item["name"] for item in items),に変更しました。pytest -q --tb=shortを実行します:
..F... [100%]
=================================== FAILURES ===================================
_________________________________ test_summary _________________________________
test_invoice.py:15: in test_summary
assert summarise(LINES) == {"count": 2, "names": ["pen", "bag"], "total": 1880.0}
E AssertionError: assert {'count': 2, ...otal': 1880.0} == {'count': 2, ...otal': 1880.0}
E
E Omitting 2 identical items, use -vv to show
E Differing items:
E {'names': ['bag', 'pen']} != {'names': ['pen', 'bag']}
E Use -v to get more diff
=========================== short test summary info ============================
FAILED test_invoice.py::test_summary - AssertionError: assert {'count': 2, .....
1 failed, 5 passed in 0.01sこの章で学んだ通りに読み解いてみましょう。6つのテストのうち1つ、15行目のtest_summaryが失敗しました。最初のE行は省略されているため、その下の詳細を確認します:2つのキーは一致しており、namesが異なっています — コード側(左辺)の['bag', 'pen']に対して、テスト側(右辺)は['pen', 'bag']となっています。順序が変わっただけで、それ以外の変化はありません。これがバグなのか意図的な仕様変更なのかは、請求書の要件として判断すべき事柄であり、コードの中を手探りで探し回る必要はありません。
どのテストが失敗しなかったのかにも注目してください。件数、検索、合計のテストは引き続きパスしているため、レポートはこの変更が辞書の中のたった1つのキーに限定されていることを突き止めてくれています。これこそが「テストごとに1つの振る舞い」を徹底するメリットです。パスと失敗のパターン自体が、そのまま診断結果になるのです。
問題が起きたとき
ヘルパーモジュールから値の表示がないE AssertionErrorが出る 書き換えの対象はテストファイルとconftest.pyのみです。例えばchecks.pyのようなモジュールの内部にあるassertは、素のAssertionErrorを出力します。インポートされる前に、conftest.pyの先頭でそのモジュールを登録してください:pytest.register_assert_rewrite("checks")。これでレポートに通常通りassert 0 > 0が表示されるようになります。
PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses? assert (condition, "message")のように記述してしまっています。これはタプルであり、テストが失敗することは決してありません。外側の丸括弧を削除してください。行が長い場合はメッセージのみを丸括弧で囲みます。
AssertionError: assert None == ... または + where None = ... 呼び出した関数がNoneを返しました。通常はreturnが記述されていない実行パスがあるか、あるいはインプレース(破壊的)に変更を行って何も返さない関数である場合です — list.sort()はその典型例です。
Use -v to get more diff / ...Full output truncated (8 lines hidden), use '-vv' to show エラーではありません — pytestが説明を省略しただけです。そのテスト1件を-vvを付けて再実行してください。
明らかに値が間違っているのにテストがパスしてしまう xが"no"のような空でない文字列である場合のassert x、xが1である場合のassert x == True、あるいはタプルの罠を疑ってください。一度も失敗したことのないテストは、正しく機能していることがまだ証明されていません — コードをわざと壊してみて、テストがレッド(失敗)になることを一度確認してください。
ステップ 4 / 6 — 予想する
理解度をチェック
テストが失敗します。pytestは最初のE行の下に、どの説明行を出力しますか?
def test_names():
got = ["pen", "bag"]
assert got == ["pen", "bag", "ink"]- Aassert 2 == 3
- BRight contains one more item: 'ink'
- CAt index 2 diff: 'ink' != None
- DLeft contains one more item: 'ink'
totalは80.0であるにもかかわらず、このテストはパスします。なぜですか?
def test_total():
total = 80.0
assert (total == 70.0, "total is wrong")- Aメッセージ文字列が条件式の代わりになるから
- B`80.0 == 70.0`は浮動小数点数(float)に対して真になるから
- C丸括弧によって2要素のタプルが作られ、空でないタプルは常に真とみなされるから
- Dpytestはメッセージ付きのアサートをスキップするから
常にassert actual == expectedと書くものとします。文字列のdiffにおいて、+で始まる行と-で始まる行はそれぞれ何を表していますか?
- A`+`は期待値、`-`はコードが出力した値を表す
- Bどちらの値がより長いかによって異なる
- C`+`はコードが出力した値(左辺)、`-`は期待値(右辺)を表す
- D`+`は両方の値で追加された文字、`-`は両方の値で削除された文字を表す
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
4つの関数を持つgrades.pyを作成してください:
letter(score)— 80以上は"A"、65以上は"B"、50以上は"C"、それ以外は"F"passed(score)— 本物のboolであり、50以上でTruereport(scores)— 名前と点数の辞書を受け取り、名前と評定文字の辞書を返すbest(scores)— 最高得点の名前を返す。辞書が空の場合はNoneを返す
まず、この章で行ったようにテスト計画を表として作成します:ケース、入力、期待値、および使用するアサートです。次に、少なくとも6つのテストを含むtest_grades.pyを作成します。次のそれぞれを少なくとも1回使用してください:辞書全体に対する==、in、is None、is Trueまたはis False、>=のような比較、そして失敗したケースを明記したメッセージ付きのアサート。
次に、わざとコードを壊してみて、それぞれのレポートを読み解いてみましょう:
passedが真偽値の代わりにscore >= 50 and scoreを返すように変更します。どのテストが気付きますか?"B"の境界値を65から66に変更します。どのテストが気付き、どのテストが気付かないでしょうか — それはなぜですか?- アサートの1つを、メッセージごと丸括弧で囲んでみます。警告を探してください。
- 2の変更を残した状態で、ファイルを
--tb=lineで実行し、次に失敗したテストを-lで実行します。
それぞれの変更について、何が間違っていたのかを教えてくれたレポートの最初の行を書き出してみてください。
解答例
まずは計画からです。評定コードのバグは境界値で発生しやすいため、各境界の両側の値 — 80と79、65と64、50と49 — をそれぞれ用意します:
| ケース | 入力 | 期待値 | アサート | | --- | --- | --- | --- | | 各境界の両側 | 80, 79, 65, 64, 50, 49 | A, B, B, C, C, F | 点数を指定したメッセージ付きの== | | passedは本物のbool | 50, 49 | True, False | is True, is False | | レポート全体 | 4人の生徒 | 評定文字の辞書全体 | 辞書に対する== | | 不合格者が存在する | 同じ4人 | 評定文字の中に"F"が含まれる | in | | bestが真に最高得点である | 同じ4人 | 勝者の得点を上回る得点がない | メッセージ付きの>= | | 順位付けする対象がいない | {} | None | is None |
grades.py:
def letter(score):
if score >= 80:
return "A"
if score >= 65:
return "B"
if score >= 50:
return "C"
return "F"
def passed(score):
return score >= 50
def report(scores):
return {name: letter(score) for name, score in scores.items()}
def best(scores):
if not scores:
return None
return max(scores, key=scores.get)test_grades.py:
from grades import best, letter, passed, report
SCORES = {"ada": 91, "alan": 64, "grace": 50, "linus": 49}
def test_letter_boundaries():
cases = [(80, "A"), (79, "B"), (65, "B"), (64, "C"), (50, "C"), (49, "F")]
for score, expected in cases:
assert letter(score) == expected, f"letter({score})"
def test_passed_returns_a_real_bool():
assert passed(50) is True
assert passed(49) is False
def test_report():
assert report(SCORES) == {"ada": "A", "alan": "C", "grace": "C", "linus": "F"}
def test_a_failing_letter_appears():
assert "F" in report(SCORES).values()
def test_best_has_the_highest_score():
top = best(SCORES)
for name, score in SCORES.items():
assert SCORES[top] >= score, f"{name} scored more than {top}"
def test_best_of_nobody():
assert best({}) is Nonepytest -q:
...... [100%]
6 passed in 0.01s変更1 — passedがscore >= 50 and scoreを返す場合:
.F.... [100%]
> assert passed(50) is True
E assert 50 is True
E + where 50 = passed(50)
FAILED test_grades.py::test_passed_returns_a_real_bool - assert 50 is True
1 failed, 5 passed in 0.01s50はtruthyであるため、assert passed(50)では通過してしまったはずです。is Trueがこれを捉え、assert 50 is Trueは真偽値であるべきだった値を具体的に名指ししています。
変更2 — "B"の境界を66に移動し、--tb=lineで実行した場合:
F..... [100%]
=================================== FAILURES ===================================
E AssertionError: letter(65)
assert 'C' == 'B'
- B
+ C
/home/you/grades/test_grades.py:9: AssertionError: letter(65)
=========================== short test summary info ============================
FAILED test_grades.py::test_letter_boundaries - AssertionError: letter(65)
1 failed, 5 passed in 0.01s境界値のテストだけが失敗に気付きました。SCORESの中にちょうど65点の生徒がいないため、test_reportはパスしたままです — テストは投入した値しかカバーできません。計画においてすべての境界の両側をリストアップしたのはそのためです。メッセージletter(65)が先頭に表示されていますが、これは必須の情報です。ループは同じ行で6つのケースを実行するため、メッセージがなければ評定が間違っていることは分かっても、どの点数の評定なのかが分かりません。+ Cはコードが返した値であり、- Bはテストが期待していた値です。
同じ失敗をpytest -q -l test_grades.py::test_letter_boundariesで実行すると、末尾にループの変数が表示されます:
cases = [(80, 'A'), (79, 'B'), (65, 'B'), (64, 'C'), (50, 'C'), (49, 'F')]
expected = 'B'
score = 65変更3 — 32行目でassert (best({}) is None, "no scores, no best")とした場合:
...... [100%]
/home/you/grades/test_grades.py:32: PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses?
6 passed, 1 warning in 0.01s6件すべてパスしました — しかもそのうちの1つは、bestが何を返そうとも、二度と失敗することはありません。警告だけが唯一の兆候です。
何が間違っていたのかを教えてくれた最初の行:assert 50 is True、AssertionError: letter(65)、そしてassertion is always true。これからのテスト失敗において、最初に探すべきなのはまさにこのような行です。