この連載では、仕様書もテストもないまま動き続けているを、壊さずに読み解いて手を入れていく現場の手順を書いている。EP1では全体像を掴む段取りを整理した(レガシー再生 EP.1 コード考古学の6ステップ)。EP3の今回は、その安全網の中心になる(特性化テスト)を、手作業ではなく本番の入出力から自動で採取するやり方に絞って掘り下げる。
リファクタや置き換えに着手する前に、まずやることは1つだ。「今このコードが何を返しているか」を証拠として固定する。あるべき仕様ではなく、実際の挙動をそのまま正解にする。だからこそ手で期待値を書くのは筋が悪い。仕様を知らない人間が期待値を手打ちすれば、その人の思い込みが混ざる。混ざった時点で、それはもう「現状の固定」ではない。本番が今出している値を機械的に拾ってきて、それを正解とみなす。これが自動採取の考え方である。
なぜ手書きではなく自動採取なのか
が積み上がった関数は、入力の組み合わせが多く、分岐も深い。1つ2つの代表ケースを手で書いても、実際に本番を流れているエッジケースはまず網羅できない。祝日判定、マイナス在庫、全角スペース入りの顧客名、桁あふれ直前の金額。こういう「誰も仕様として書いていないが現に処理されている入力」こそが、リファクタで壊れる場所だ。人間の想像力ではなく、本番トラフィックそのものにケースを列挙させる。
自動採取の全体像はシンプルで、順序が大事だ。①対象関数の入口・出口で入出力をサンプリングする、②採取したデータを匿名化(PIIマスキング)する、③マスク済みデータをゴールデンマスターとして保存する、④再実行して差分を検知する、⑤時刻・乱数・外部呼び出しなどの非決定性を固定する、⑥CIに組み込む。ここで②を①の直後に置くのが肝で、生データをディスクに落とす経路を作らないことが後述の事故防止に効いてくる。
- 1対象の関数・エンドポイントの入口と出口で引数と戻り値を捕捉する
- 2捕捉したデータをその場でマスキングする(生データは保存しない)
- 3マスク済み入出力をゴールデンマスターとして永続化する
- 4リファクタ後に同じ入力で再実行し、出力の差分を検知する
- 5時刻・乱数・外部APIなどの非決定要因を注入・記録で固定する
- 6CIで差分ゼロをゲートにし、意図した差分だけ承認して更新する
①②③ 採取・匿名化・保存を1本のパイプラインにする
採取はデコレータやプロキシで対象関数をラップして行う。ポイントは、記録するコールバックの中で必ずマスキングを通してから書き出すこと。マスキング関数を通らない生の引数がシリアライズされる経路を、コード上に存在させない。下は補助で当たりを付けつつ人が仕上げた、Pythonの採取ラッパの骨格だ。外部APIやDBアクセス部分は各現場の実装に差し替える前提で `# 仮実装` と明示している。
import json, functools, hashlibfrom pathlib import Path
GOLDEN_DIR = Path("tests/golden")PII_KEYS = {"name", "phone", "address", "customer_name", "email"}
def mask(value): """PIIをマスクする。ここを通さない生データは絶対に保存しない。""" if isinstance(value, dict): return {k: ("***" if k in PII_KEYS else mask(v)) for k, v in value.items()} if isinstance(value, list): return [mask(v) for v in value] if isinstance(value, str) and looks_like_email(value): return "email:" + hashlib.sha256(value.encode()).hexdigest()[:12] # 復元不可 return value
def capture(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): result = fn(*args, **kwargs) record = { "args": mask(list(args)), # 保存前に必ずマスク "kwargs": mask(kwargs), "result": mask(result), } key = hashlib.sha256( json.dumps(record["args"], sort_keys=True, ensure_ascii=False).encode() ).hexdigest()[:16] GOLDEN_DIR.mkdir(parents=True, exist_ok=True) path = GOLDEN_DIR / f"{fn.__name__}.{key}.json" if not path.exists(): # 同一入力は1件だけ保存(重複爆発を防ぐ) path.write_text(json.dumps(record, ensure_ascii=False, indent=2)) return result return wrapper
def looks_like_email(s): # 仮実装:本番では厳密な検出器に差し替える return isinstance(s, str) and "@" in s and "." in s本番に採取ラッパを入れるときは、全リクエストではなく低い比率(例:0.5〜1%)で回す。全件保存すると同一入力のファイルが爆発し、レイテンシにも影響する。上のコードは入力ハッシュをキーにして同一入力を1件に畳んでいるが、それでも比率制御は入口側で必ず入れること。採取は一時的なイベントであって、恒久機能ではない。
④ ゴールデンマスターと差分検知
採取が終われば、あとは「同じ入力を今のコードに食わせて、保存済みの出力と一致するか」を見るだけだ。リファクタ前後で差分がゼロなら挙動は保たれている。差分が出たら、それが意図した変更なのか、事故なのかを人が判断する。テスト自体はこれだけ短い。
import json, pytestfrom pathlib import Pathfrom myapp.pricing import calculate_price # 対象関数from myapp.capture import mask
GOLDEN = list(Path("tests/golden").glob("calculate_price.*.json"))
@pytest.mark.parametrize("path", GOLDEN, ids=lambda p: p.stem)def test_characterization(path): record = json.loads(path.read_text()) got = calculate_price(*record["args"], **record["kwargs"]) # 保存済み出力を「正解」として突き合わせる(仕様ではなく現状の固定) assert mask(got) == record["result"], f"挙動が変化: {path.name}"この形はで旧実装を新実装に少しずつ置き換えるときにも効く。新旧両方に同じゴールデン入力を流し、出力が一致する範囲だけをルーティングで切り替えていけば、置き換えの安全性を1ケース単位で確認できる。ゴールデンマスターは移行の合否判定にそのまま転用できる資産になる。
⑤ 非決定性をどう固定するか
特性化テストが現場で嫌われる最大の理由は、非決定性で毎回落ちることだ。今日と明日で結果が変わる要素が関数の中にあると、差分検知は「毎回差分あり」になって誰も見なくなる。潰すべき非決定要因は主に3種類ある。時刻、乱数、そして外部呼び出し(API・DB・現在庫など)だ。それぞれ固定の仕方が違う。
| 非決定要因 | 採取時のやり方 | 再実行時のやり方 |
|---|---|---|
| 時刻・日付 | `now()` の戻り値を記録に含める | クロックを注入し、記録した時刻を渡す |
| 乱数・UUID | 使用した seed / 生成値を記録 | 同じ seed を固定注入して再現 |
| 外部API・DB | リクエストと応答をペアで記録 | 記録した応答を再生(VCR的な記録再生) |
時刻はコード内で直接 `now()` を呼ばず、クロックを引数やDIで注入する形に直す。これは特性化のためだけでなく、コードの品質改善そのものだ。外部呼び出しはVCR系ライブラリの発想で、初回に本物の応答を記録し、以降は記録を再生する。ここで注意したいのは、外部応答の記録にもPIIが混ざるという点。応答の記録にも採取と同じマスキングを必ず通す。
時刻や乱数をグローバルに握っているコードは、テストしにくいだけでなく後から読む人にも危険だ。特性化テストを採取する過程で、これらを注入可能な口に付け替えていく。安全網を張る作業が、そのまま構造改善の第一歩になる。急がば回れで、ここは手を抜かない方が後が楽になる。
⑥ CIへの組み込みと運用
ゴールデンマスターはコードと一緒にリポジトリにコミットし、CIで差分ゼロをマージのゲートにする。差分が出たPRは自動で止まる。意図した挙動変更のときは、レビューでその差分を承認し、ゴールデンを更新するコミットを別に切る。ここを「テストが落ちたから期待値を上書きして緑にする」で流すと安全網の意味が消えるので、ゴールデン更新は必ずレビュー対象にするルールにしておく。
どの関数から採取すべきか、どのキーがPIIか、といった当たり付けはに下書きさせると速い。ただしマスキング漏れの最終確認と、差分が「意図した変更か事故か」の判断は必ず人がやる。LLMは列挙とパターン抽出が得意で、責任判断は不得意だ。役割を分けて使う。
落とし穴3つ
- 現状のバグごと固定してしまう:特性化テストは現状を凍結するので、既存バグも正解になる。これは想定通り。まず固定→リファクタで不変を確認→バグ修正は意図的な差分承認として別コミット、の順を崩さない。
- 採取データにPIIが混じる:マスキングを通らない経路が1つでもあると事故になる。素通しの保存パスを物理的に無くし、外部API応答の記録にも同じマスクをかける。採取データの保管期間と削除も決めておく。
- 非決定性で毎回落ちる:時刻・乱数・外部呼び出しを固定しないと差分検知が形骸化する。落ちるテストは「無視されるテスト」になり、安全網ごと捨てられる。注入と記録再生で先に潰しておく。
特性化テストは華やかな作業ではないが、仕様の失われたコードに触るなら最初に張るべき網だ。手で期待値を書くのではなく本番の挙動を機械的に採取し、匿名化と非決定性の固定を最初から設計に織り込む。これができて初めて、置き換えやリファクタを安心して進められる。
この連載の実案件としての進め方や、旧システムからの資産抽出・移行の支援についてはレガシー刷新・資産化支援にまとめている。連載の入口はレガシー再生 EP.1 コード考古学の6ステップから。続編は読者リアクションに応じて随時追加していく。採取パイプラインの具体、あるいはでの段階移行の詳細など、掘ってほしいテーマがあればリアクションで教えてほしい。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。