は、新しい機能が正しいかではなく、これまで動いていたものを壊していないかを見るテストです。地味ですが、変更を安全にするという目的に最も直接的に効きます。
設計の要点はひとつだけで、何を固定するかです。固定しすぎれば変更のたびに赤くなり、緩すぎれば壊れても気づけない。この回はその線の引き方を扱います。
1. 固定してよいものと、いけないもの
出力をそのまま比較すると楽ですが、たいてい失敗します。出力の中には変わってよい要素が混ざっているからです。ここを分離するのが最初の作業になります。
| 要素 | 固定する | 理由 |
|---|---|---|
| 金額・数量などの計算結果 | する | 変わったら仕様変更か不具合。必ず気づきたい |
| 状態・区分(成功/失敗、権限) | する | 判定の誤りは損害に直結する |
| 項目の有無(必須項目が消えた) | する | 欠落は下流を壊す |
| 生成日時・処理時刻 | しない | 毎回変わる。固定すると常に失敗する |
| 自動採番のID・UUID | しない | 実行のたび変わる |
| 項目の並び順 | 場合による | 順序に意味がある場合のみ固定する |
| 空白・改行などの整形 | しない | 本質ではなく、変更の理由になりにくい |
この切り分けを怠ると、「毎回落ちるので無視するテスト」が生まれます。無視されるテストは存在しないのと同じか、むしろ有害です。赤に慣れる文化を作ってしまうためです。これは が引き起こす問題とまったく同じ構造で、原因が違うだけで結果は同じです。
2. 変動要素を落としてから比較する
実装としては、比較の前に変動する要素を落とす処理を挟むのが定石です。正規化してから比較する、という形にします。
VOLATILE = {"generated_at", "request_id", "trace_id"}
def normalize(obj): """毎回変わる項目を落として、比較可能な形にする。""" if isinstance(obj, dict): return { k: normalize(v) for k, v in sorted(obj.items()) if k not in VOLATILE } if isinstance(obj, list): return [normalize(v) for v in obj] return obj
def test_invoice_output_is_stable(expected_invoice): actual = build_invoice(order_id=1) assert normalize(actual) == normalize(expected_invoice)`sorted` を挟んでいるのは、辞書の並び順の違いで落ちないようにするためです。順序に意味がない場合は順序を無視する、という判断を明示的にコードへ書いておくと、後から読む人にも意図が伝わります。
変動要素の除外リストが増え続けているなら、固定する対象の選び方が広すぎるサインです。出力全体を比べるのをやめ、壊れてはいけない項目だけを明示的に検証する方向へ切り替えたほうが、結果として保守が楽になります。
3. 仕様が分からないコードに網をかける
リグレッションが最も必要になるのは、仕様書がなく、書いた人もいない既存コードを改修するときです。何が正しいか分からないので、正しさのテストは書けません。ここで使えるのが です。
正しいかどうかを問わず、「今こう動く」という事実をそのまま記録します。改修でその挙動が変わったら検知できる、という一点に絞った使い方です。記録した挙動の中には、おそらく不具合も含まれています。それでも構いません。意図せず変わることを防ぐのが目的だからです。
import jsonimport pathlib
GOLDEN = pathlib.Path("tests/golden/legacy_fee.json")
# 代表的な入力を並べる(境界値を意識して選ぶ)CASES = [ {"amount": 0, "plan": "basic"}, {"amount": 999, "plan": "basic"}, {"amount": 1000, "plan": "basic"}, {"amount": 1000, "plan": "premium"}, {"amount": -1, "plan": "basic"},]
def test_legacy_fee_behavior_unchanged(): actual = {json.dumps(c, sort_keys=True): calc_fee(**c) for c in CASES}
if not GOLDEN.exists(): # 初回のみ記録する。中身は必ず人がレビューする GOLDEN.write_text(json.dumps(actual, ensure_ascii=False, indent=2)) raise AssertionError("基準を新規作成しました。内容を確認してコミットしてください")
expected = json.loads(GOLDEN.read_text()) assert actual == expected初回に例外を投げているのが要点です。黙って記録して緑にすると、誰も中身を見ません。一度失敗させて、人間がファイルを開く機会を作ります。ここを省略すると、次回の EP.6 で扱う「無確認で更新される」問題に直結します。記録されたファイルは と同じく中身が読まれて初めて意味を持つ種類の資産です。
レガシー改修の進め方そのものについては レガシー刷新の現場 で扱っています。網をかけてから手を入れる、という順序はあちらでも共通の前提です。
4. 入力の選び方が精度を決める
リグレッションの網の細かさは、どの入力を並べたかでほぼ決まります。代表的な値だけを並べると、境界の壊れを取りこぼします。
- 境界値 — 0、閾値のちょうど上下、最大値。条件分岐はここに集まる
- 異常値 — 負数、空文字、null、想定外の型。落ちるなら落ちることを固定する
- 過去に不具合が出た入力 — 一度壊れた場所は再び壊れる。必ず残す
- 実データから採取した値 — 想像で作った入力には現れない形が混ざっている
3番目が特に効きます。不具合を直したときに、その入力をリグレッションへ追加する運用にしておくと、網は使うほど細かくなります。逆に、直して終わりにしていると同じ場所が何度も壊れます。この積み上げは の数値には現れませんが、実効的な守りの厚さとしては最も効く部分です。
入力を人間が考えることに限界を感じたら、 という選択肢があります。「入力がどうであれ成り立つ性質」を書き、値は自動生成する手法です。境界値を掘り当てる力が強く、リグレッションの補完として相性がよい。
from hypothesis import given, strategies as st
@given(amount=st.integers(min_value=0, max_value=10_000_000))def test_fee_never_exceeds_amount(amount): """手数料が元本を超えることは、どんな入力でもあってはならない。""" assert calc_fee(amount=amount, plan="basic") <= amount5. 網をかける範囲を広げすぎない
リグレッションは安心感が大きいぶん、際限なく広げたくなるという副作用があります。全画面・全APIの出力を固定すれば確かに検知力は上がりますが、その先に待っているのは「仕様変更のたびに数百件を目視で承認する」作業です。
範囲を決める基準は EP.1 と同じで、壊れたときの損害です。加えてリグレッション固有の観点として、その出力の受け手が誰かを見ます。下流のシステムが機械的に読む出力(帳票、連携ファイル、API)は、人が見ないので壊れても気づけないため、網をかける価値が高い。
人が毎日見る画面は、壊れれば誰かが気づきます。一方、夜間に連携される CSV や 月次で出る帳票 は、壊れたまま数か月流れることがあります。同じ工数をかけるなら後者に厚く置くほうが効きます。
6. 何を守っているかを書き残す
最後に運用上の話をひとつ。リグレッションテストは、なぜそれを固定したのかが時間とともに失われます。特に「過去に不具合が出た入力」は、コメントがないと不自然な値の羅列にしか見えません。
結果として、後任者が「意味がなさそうだから」と削除し、同じ不具合が再発します。入力のそばに、いつ・何が起きたのかを1行残すだけで、この事故は防げます。テストコードは仕様書ではありませんが、判断の記録としては機能します。
リグレッションの設計は固定する対象の選択がすべて。変動要素は比較前に落とす。仕様不明のコードには 特性化テスト で網をかけ、初回は必ず人が中身を見る。入力は境界値と過去の不具合を優先する。次回は、この「記録して比較する」やり方が形骸化する典型を扱います。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。