はじめに
「テストは通った。でも、いざ落ちたときに何が起きたか分からない」——そんな経験はありませんか。
expected: 16301 but was: 16103 のような無機質な出力だけ見せられて、結局ソースコードを開き直す。
AI でテストを大量に生成できるようになった今、落ちたときに「何のテストか」を語れないテストが負債として目立つようになってきました。
この記事では、テスト失敗時に原因を語るための3つのレイヤー(アサーション・データ・本番ログ)を整理します。
TL;DR
- テストは通るためではなく、落ちて原因を語るために書く
- 失敗時に語らせるレイヤーは3つ:アサーションの語り口/テストデータの意図/本番ログ
- TDDサイクルを「fail → pass → refactor」ではなく「fail → report → pass → refactor」の4ステップで考える
1. テストが落ちたのに、原因が分からない
配送ステータス追跡 API のテストを書いていたとします。pytest を実行すると、こんな出力が返ってきます。
FAILED test_shipment.py::test_status_update
AssertionError: assert 'PENDING' == 'IN_TRANSIT'
これだけで原因に辿り着けるでしょうか。出力からは次のことが分かりません。
- どのテストの、どのアサーションが落ちたのか
-
PENDINGとIN_TRANSITのどちらが期待値で、どちらが実際の値か - どんなテストデータでこの結果が生まれたのか
ファイルを開いて該当行を探し、テストデータを読み直して、ようやく原因に気付く。
AI にテストを書かせる場合、量はすぐ揃う一方、書いた本人ですら「何のテストか」を再現できず、誰も原因を語れない状態に陥ります。
ここで視点を切り替えると、通ることではなく落ちたときに原因を語ることがテストの仕事で、落ちた瞬間に何を語るかがテストの価値の本体だと見えてきます。
では、失敗時に原因を語れるテストにするには、何を設計すればよいでしょうか。
2. テスト失敗を「3層のレポート」として設計する
「落ちて原因を語る」という視点を、設計の手がかりとして具体的にしてみます。
失敗時に語るべきものは1種類ではありません。
状況を再構築するために、3つの異なる種類の情報を別々の場所に書き込んでいくイメージです。
| 層 | 何を語るか | 主な技法 |
|---|---|---|
| アサーション層 | 「何が違ったか」(期待値と実際値の差) | アサーションメッセージ/matcher/自己説明的な値/トレーサーオブジェクト |
| データ層 | 「テストが何を表現していたか」(テストの意図) | テストデータビルダー |
| 本番ログ層 | 「本番で何が起きたか」(運用者向けの再構築情報) | 専用 collaborator への集約/呼び出し側起点の設計 |
以降のセクションでは、この3層を順に深掘りします。まずは最も基本のアサーション層から見ていきます。
3. アサーション層:失敗メッセージに「何が違ったか」を語らせる
アサーションの語り口は、失敗時に最初に目に入る情報源です。ここが無機質だと、診断のスタート地点で迷子になります。
3-1. 改善前:何が失敗したか分からないアサーション
配送ステータスのテストを、よくある書き方で書いてみます。
def test_shipment_status_update():
shipment = Shipment(
id="SHP-001",
sender_address="Tokyo",
recipient_address="Osaka",
carrier="YAMATO",
)
event = DeliveryEvent(shipment_id="SHP-001", event_type="PICKED_UP")
tracker = ShipmentTracker()
tracker.handle(shipment, event)
assert shipment.status == "IN_TRANSIT"
assert shipment.last_event_type == "PICKED_UP"
assert shipment.carrier == "YAMATO"
このテストが落ちたとき、pytest の出力はこうなります。
AssertionError: assert 'PENDING' == 'IN_TRANSIT'
PENDING と IN_TRANSIT がどの assertion から来た値なのか、出力だけでは判断できません。
3つの assertion のうちのどれが落ちたかは、行番号を頼りにソースを開かないと分かりません。
3-2. 失敗メッセージを設計する4つの技法
失敗メッセージを語らせるには、4つの技法があります。
| 技法 | 何を語る | いつ使う |
|---|---|---|
| アサーションメッセージ | どの値の比較か | 同じ型の値を複数 assertion している |
| matcher | 期待値と実際値の構造的な差 | 比較対象が複雑(リスト・辞書・カスタムオブジェクト) |
| 自己説明的な値 | 値そのものの役割 | テスト用に作った値が無味な数値・文字列 |
| トレーサーオブジェクト | 名前を持つダミーの役割 | 値を引き回すだけの collaborator がある |
3-3. 改善後:失敗レポートだけで原因が分かるテスト
4つの技法を適用して、先ほどのテストを書き直してみます。
def test_shipment_status_update():
sender = "a sender address"
recipient = "a recipient address"
shipment = Shipment(
id="a shipment id",
sender_address=sender,
recipient_address=recipient,
carrier="a carrier name",
)
event = DeliveryEvent(
shipment_id="a shipment id",
event_type="PICKED_UP",
)
tracker = ShipmentTracker()
tracker.handle(shipment, event)
assert shipment.status == "IN_TRANSIT", "status after PICKED_UP"
assert shipment.last_event_type == "PICKED_UP", "last event type recorded"
assert shipment.recipient_address == recipient, "recipient address unchanged"
このテストが落ちると、出力はこうなります。
AssertionError: status after PICKED_UP
assert 'PENDING' == 'IN_TRANSIT'
「PICKED_UP イベント処理後のステータスが期待通りになっていない」と即座に読み取れます。
さらに、テストの値が "a recipient address" のように役割名を語っているのも効きます。
引数を取り違えて宛先に送信元を渡した場合、失敗メッセージは次の形になり、原因を直接示してくれます。
expected: 'a recipient address' but was: 'a sender address'
4技法の前提として、小さくフォーカスされたテストに良い名前を付けることが最も基本です。
test_shipment_status_update_to_in_transit_when_picked_up のような名前自体が失敗レポートの一部になります。
テスト名 → アサーションメッセージ → matcher の差分 → 自己説明的な値の順で、失敗時の情報密度が積み上がっていきます。
4. データ層:テストデータに「何を表現しているか」を語らせる
アサーションが語っていても、テストの準備(Arrange)部分がノイズだらけだと「このテストが何を確かめているか」が読み取れません。
失敗を語るのに、アサーションだけでは不十分です。
4-1. ノイズだらけのテスト準備
配送ドメインで、宛先のポストコードが欠落していた場合のテストを書いてみます。
def test_reject_shipment_when_postcode_is_missing():
address = Address(
line1="1-1-1 Shibuya",
line2="",
city="Tokyo",
prefecture="Tokyo",
postcode=None,
country="JP",
)
shipment = Shipment(
id="SHP-001",
sender_address=Address(
line1="2-2-2 Umeda",
line2="",
city="Osaka",
prefecture="Osaka",
postcode="530-0001",
country="JP",
),
recipient_address=address,
carrier="YAMATO",
items=[Item("book", 1)],
weight_kg=1.0,
)
result = ShipmentValidator().validate(shipment)
assert result.is_rejected
このテストの本質は「ポストコードがない宛先への配送は拒否される」ことだけです。ところが準備部分はノイズに埋もれています。問題が3つあります。
- 何の値が本質的に重要か分からない(
postcode=Noneがテストの主役だが、他の値と同じ重みで並んでいる) - コンストラクタが変わるとあちこちのテストが壊れる
- 引数の位置間違いに気付きにくい(
recipient_addressにsender_addressを渡してしまっても気付かない)
4-2. Object Mother:最初に思いつく解決策とその限界
最初に思いつく解決策は、テスト用オブジェクトを作るファクトリメソッドを集めたクラスを用意することです。
これは Object Mother パターンと呼ばれます。
class ExampleShipments:
@staticmethod
def to_tokyo() -> Shipment: ...
@staticmethod
def without_postcode() -> Shipment: ...
@staticmethod
def without_postcode_to_tokyo_via_yamato() -> Shipment: ...
呼び出し側はすっきりします。
shipment = ExampleShipments.without_postcode()
ただし限界があります。
バリエーションが増えるたびにファクトリメソッドが増殖します。
without_postcode_to_tokyo_via_yamato_with_heavy_items() のような長大なメソッド名が並び、組み合わせ爆発で結局メンテ不能になります。
4-3. テストデータビルダー:バリエーションに強い「意図のあるデータ」
ここで登場するのがテストデータビルダーです。
テスト用のオブジェクトをチェイン記法で組み立てる小さなビルダークラスを用意します。
デフォルト値で「どうでもいい値」を隠し、with_xxx() 系のメソッドで「このテストで本質的に重要な値」だけを上書きする発想です。
class ShipmentBuilder:
def __init__(self) -> None:
self._id = "a shipment id"
self._sender = AddressBuilder().build()
self._recipient = AddressBuilder().build()
self._carrier = "a carrier name"
self._items = [Item("an item", 1)]
self._weight_kg = 1.0
def to(self, recipient: "AddressBuilder") -> "ShipmentBuilder":
self._recipient = recipient.build()
return self
def build(self) -> Shipment:
return Shipment(
id=self._id,
sender_address=self._sender,
recipient_address=self._recipient,
carrier=self._carrier,
items=self._items,
weight_kg=self._weight_kg,
)
def a_shipment() -> ShipmentBuilder:
return ShipmentBuilder()
def an_address() -> AddressBuilder:
return AddressBuilder()
これを使うと、先ほどのテストはこう書けます。
def test_reject_shipment_when_postcode_is_missing():
shipment = (
a_shipment()
.to(an_address().without_postcode())
.build()
)
result = ShipmentValidator().validate(shipment)
assert result.is_rejected, "shipment without postcode should be rejected"
「ポストコードがない宛先への配送」というテストの意図が、コードにそのまま現れています。キーポイントは3つです。
- デフォルト値で「どうでもいい値」を隠す
-
with_xxx()で「このテストで重要な値」だけを表に出す - コンストラクタが変わってもビルダーだけ直せばよい
4-4. Object Mother との対比
Object Mother とテストデータビルダーを並べると、向き不向きがはっきりします。
| 観点 | Object Mother | テストデータビルダー |
|---|---|---|
| バリエーション対応 | バリエーションごとにメソッド追加が必要 | デフォルト+部分上書きで柔軟に表現できる |
| 可読性 | 名前で意図は伝わるが、内部の値は隠れる | 上書きした値だけが表に出るので意図が読める |
| メンテナンス | バリエーション組み合わせの爆発に弱い | コンストラクタ変更時もビルダー1箇所で済む |
ビルダーは応用技法で表現力をさらに上げられます。
他のビルダーを引数に取る形(a_shipment().to(an_address().without_postcode()) のような combine)、ドメインに合わせたファクトリメソッド、状態をコピーして派生させる but() メソッドなどがあります。
深入りはしませんが、ビルダーが育っていく方向性として頭に置いておくと良いです。
4-5. データが語ること = テストの意図
データ層が語るべきは「このテストが何を表現しているか」です。
テストは実行されるスクリプトであると同時に、半年後の自分や他の人が読む読み物でもあります。
長いコンストラクタ呼び出しは読み物として情報密度が低く、ビルダーが表現する「ポストコードのない宛先」のような形は、意図がそのまま読み取れる形になります。
5. 本番ログ層:本番でも失敗を語る仕組みを「機能」として設計する
テストが落ちて原因を語っても、本番で起きた失敗の原因が分からなければ意味がありません。本番ログも「失敗を語る装置」として、同じ思想で設計対象に含めます。
5-1. ログが散らばっているコード
配送ステータス更新ハンドラに、ログがベタ書きされている例を見てみます。
import logging
logger = logging.getLogger(__name__)
class ShipmentTracker:
def handle(self, shipment: Shipment, event: DeliveryEvent) -> None:
try:
parsed = self._translate(event)
except ValueError as e:
logger.error(f"failed to parse event: {event} error={e}")
shipment.mark_failed()
return
if parsed.event_type not in VALID_TYPES:
logger.error(f"unknown event type: {parsed.event_type}")
shipment.mark_failed()
return
shipment.apply(parsed)
logger.info(f"applied event {parsed.event_type} to {shipment.id}")
問題は3つあります。
- ログのフォーマットが場当たり的になっており、
logger.error(...)の引数の組み立て方が箇所ごとに違う - 運用者にとって何が重要な情報か(配送ID、イベント種別、例外)が読み取りにくい
- ログだけ後から変えたくても本処理に手を入れることになる
5-2. ログは「あとから足す機能」ではなく「最初から機能」
本番ログは外部インタフェースです。運用者(呼び出し側)の要求で設計するべきものであり、本処理の片隅に「ついでに書いておく」ものではありません。
配送ドメインで言えば、オペレータが知りたいのは「どの配送 ID で、どのイベントを受け取り、何が原因で失敗したか」です。
この粒度で語れる形にしておけば、本番で問い合わせが来たときに状況を再構築できます。
5-3. ログを専用の collaborator に集約する
本処理クラスとログ出力を分離します。
「失敗を報告する」という役割の collaborator を1つ作り、本処理はその collaborator にだけ依存します。
コードで書くと、本処理は「失敗を報告する」とだけ知っていればよく、「どう記録するか」は知りません。
class FailureReporter:
def cannot_translate_event(
self,
shipment_id: str,
raw_event: DeliveryEvent,
cause: Exception,
) -> None:
logger.error(
"cannot translate event: shipment_id=%s event=%s cause=%s",
shipment_id, raw_event, cause,
)
class ShipmentTracker:
def __init__(self, failure_reporter: FailureReporter) -> None:
self._failure_reporter = failure_reporter
def handle(self, shipment: Shipment, event: DeliveryEvent) -> None:
try:
parsed = self._translate(event)
except ValueError as cause:
self._failure_reporter.cannot_translate_event(
shipment.id, event, cause,
)
shipment.mark_failed()
return
shipment.apply(parsed)
この構造には実利があります。
- ログのフォーマットが
FailureReporterの1箇所にまとまる - 本処理のテストでは
FailureReporterのモックを渡すだけでよく、ログ文字列の検証に振り回されない - ログのフォーマット変更が本処理に影響しない
5-4. ロギングも診断容易性の一部
本番ログも「失敗を語る」という同じ思想の延長線上にあります。
テスト失敗時の語り口を設計したのと同じように、本番ログも設計の対象です。
3層を貫いているのは「診断容易性は後付けではなく、最初から設計する」という1つの原則です。
6. 4ステップのTDDサイクル:fail → report → pass → refactor
3層を「日々の開発リズム」に組み込むには、TDDサイクルにステップを1つ足すだけで済みます。
一般的な TDD サイクルは「fail → pass → refactor」の3ステップです。ここに「report」を1ステップ挟みます。
肝は report ステップで失敗レポートを観察する習慣を作ることです。
これが、診断容易性を後付けにしない最小の仕掛けになります。
AI にテストを書かせる場面でも、report ステップだけは人間が読んで判断する価値が残ります。
テストが大量に増える時代ほど、1つ1つのテストが落ちたときに何を語れるかが効いてきます。
おわりに
テストは通すためではなく、落ちて原因を語るために書く。
この視点に立つと、アサーション・テストデータ・本番ログのすべてが「語り口を設計する対象」に見えてきます。
TDDサイクルに report ステップが自然に組み込まれ、テストは「合否を出す装置」から「シグナルを発する装置」に変わります。
自分自身、「テストが何を語っているか」を意識して書き始めてから、過去に書いたテストへの見方が変わりました。
落ちたテストの前で固まる時間が減っただけでなく、書いている最中のテストの設計判断にも、語り口という基準が1本通った感覚があります。