レガシーは嘘をつかない
レガシーコードにはドキュメントがない。あったとしても3年前のものだ。テストはないか、あっても壊れたまま skip 処理されている。コメントはコードと矛盾している。原作者は退職し、知っている人が残したのは「触ると壊れる」という一言だけだ。
それでもそのコードは今この瞬間も動いている。決済を処理し、ログインを受け付け、注文を通す。
ドキュメントは嘘をつく。コメントも嘘をつく。人の記憶はもっとひどく嘘をつく。嘘をつかない唯一のものは、実際に流れているトラフィックだ。
ならば仕様はどこに探せばいいのか。Wikiではない。Confluenceでもない。nginx access logだ。
鶏と卵
レガシーをリファクタリングするには安全網が必要だ。何かを変えたとき、挙動が変わったかどうかを即座に知らなければならない。その安全網こそがテストだ。
ところがレガシーにはテストがない。テストを書くにはコードが何をするのか知らなければならない。コードが何をするのか知るには読まなければならない。読んでみるとテストもドキュメントもない。
鶏が先か卵が先か。マイケル・フェザーズが Working Effectively with Legacy Code で名付けた古典的な膠着だ。彼はその答えとして characterization test(特性化テスト)を提示した — コードが 正しく 何をすべきかではなく、現在 何をしているかをそのまま剥製にするテストだ。正しいか間違っているかは後の問題。まず今の挙動を固定しなければ手をつけられない。
フェザーズの時代には、これを人が手で書いた。関数を呼んでみて、出てきた値をそのまま expected に書き込む。退屈で、遅く、だから誰も最後までやらなかった。
しかしAPIレベルでは、その「関数を呼んでみた結果」がすでにどこかに蓄積されている。毎日、何万件も。ログファイルの中に。
一ヶ月分のログが仕様だ
一ヶ月間収集すれば、レガシーAPIの現在の挙動をほぼ全て捕捉できる。
nginx access log (1ヶ月):
エンドポイント · HTTP method · status code · timing
呼び出し頻度 → 優先順位
エラーパターン (401, 422, 500 …)
request/response body (ミドルウェアまたは reverse proxy でキャプチャ):
正常リクエスト/レスポンスのペア → 通過すべき挙動
エラーリクエスト/レスポンスのペア → 壊れてはいけないエッジケース
この二つの流れを合わせると、Hurl統合テストへと直訳される。Hurlは、HTTPリクエストと期待レスポンスをそのまま平文で書き下すフォーマットだ。トラフィックの一対 — 「このリクエストにこのレスポンスが出た」 — がそのままHurlの1ブロックになる。
# POST /api/orders — 呼び出し頻度 #3、一日1万2千件
POST https://api.example.com/orders
Content-Type: application/json
{ "sku": "A-1024", "qty": 2 }
HTTP 201
[Asserts]
jsonpath "$.order_id" exists
jsonpath "$.status" == "pending"
jsonpath "$.total" == 49800
このテストは「注文APIが どう動くべきか」を知らない。ただ「今 このように 動いている」を知っている。それで十分だ。リファクタリングがこのレスポンスを変えた瞬間、赤信号が灯る。
ログから自動的に導かれるもの:
- どのエンドポイントが実際に使われているか → 一ヶ月間に0回しか呼ばれなかったエンドポイントは死んだコードだ。リファクタリング前の削除候補。
- 正常レスポンスのパターン → 基本の回帰テスト。
- エラーパターン → 人が想像できない本物のエッジケース。実際のユーザーが作り出した422と500だ。
- 呼び出し頻度 → テストの優先順位。一日1万2千件のものから固める。
最後の項目が重要だ。人がテストを書くときは、自分が覚えているハッピーパスから書く。トラフィックにはそんなバイアスがない。実際に負荷を受ける経路こそが優先順位だ。
二重の安全網
このアプローチは単独で使うものではなく、レガシーをagent-operableへ引き上げるラチェットパイプラインの一つの層だ。
nginx log (1ヶ月) → Hurl 自動生成 → レガシーAPIの現在の挙動を剥製化
↓
tsma → 関数単位の安全網 (unit)
↓
filefunc → コード構造の整理 (一つの概念に一つのファイル)
↓
リファクタリング → HurlがAPI挙動の保存を検証 (integration)
核心は、安全網が二重であることだ。
- tsma = 関数単位の安全網。内部ロジックが変わったかどうかを捉える。だが関数シグネチャが同じでも、エンドポイント全体の振る舞いは変わりうる。
- Hurl from traffic = API単位の安全網。外から見た契約が保存されているかを捉える。内部をどう作り変えようと、外から入って外へ出ていくものさえ同じなら通過だ。
リファクタリングは定義上「外部の挙動を保存しながら内部構造を変える作業」だ。ならば保存されるべき「外部の挙動」の定義が、どこかに剥製化されていなければならない。tsmaが内側の境界を、Hurlが外側の境界を捉える。二重が共にあって初めて、エージェントに「思う存分作り変えろ、どこが壊れるかは機械が見る」と言える。
おもねることのできない判定官
これはSymbolic Feedback Loopの本質と正確にかみ合う。
エージェントに「リファクタリングはうまくいった?」と尋ねると「はい、きれいに整理しました」と答える。意見を与えればおもねる。だがHurlを走らせれば POST /orders → expected 201, got 500 が出る。数字とステータスコードはおもねることができない。感情がないからだ。
トラフィックから抜き出したHurlテストは、人の判断が介入していない仕様だ。誰かが「こう動くべきだと思う」ではなく、「観測の結果こう動いた」である。主張ではなく測定だ。だからリファクタリングの正否を、人ではなく機械が判定できる。LLMは判断者ではなく実行者であり、判定は決定論的なツールが行う。
前提はただ一つ、よく記録されたログ
この方法が成立するには、たった一つが必要だ。よく記録された一ヶ月分のログ。
ここで「よく記録された」が全てだ。access logだけでは足りない。それはエンドポイントとstatus codeとtimingは与えるが、剥製化すべき核心 — request body と response body のペア — は与えない。POST /orders → 201 だけを知っていても、「この入力にこの出力が出た」を再現できない。機能を固定するには、入ったものと出たものを両方とも持っていなければならない。
だから本当の問いは「テストをどう書くか」ではなく「私のログは仕様になるほどよく書かれているか」だ。
- request/response body が残っているか、それともstatus codeだけが残っているか。
- エラーレスポンスも一緒に残っているか。422と500のbodyこそ、人が想像できないエッジケースだ。
- ログが構造化されていて、機械がリクエスト・レスポンスをペアで束ねられるか。
これが揃っているなら、あなたはすでに一ヶ月間、仕様を書いてきたことになる。別途テストを書く必要はない。ログパイプラインが代わりに書いていたのだから。揃っていないなら、今ミドルウェアを一枚挟んで一ヶ月だけ点けておけばいい。一ヶ月後、あなたの手にはレガシーの現在の挙動がまるごと入ってくる。
なぜ一日ではなく一ヶ月なのか。一日分はハッピーパスしか捉えない。一ヶ月分は、月末バッチ、精算直前のトラフィック急増、稀にしか呼ばれない管理者エンドポイント、深夜3時に一度だけ回るcronまで — システムのロングテールを捉える。仕様は平均ではなく分布だ。
ログをHurlに翻訳して機能を固定する
ログが揃ったら、残りは機械的だ。一ヶ月分のrequest/responseペアをツールに入れ、各ペアをHurlブロックへ翻訳する。そうして溢れ出た数百個のHurlファイルがそのままcharacterizationスイート — レガシーの現在の挙動をまるごと剥製化した安全網だ。コードは一行も読んでいない。流れていったトラフィックだけを読んだ。
ここでよく立ち止まる一点を、あらかじめ押さえておこう。「ログに個人情報・決済・トークンが入っているが、それをテストとして剥製化していいのか?」
いい。正確には、剥製化する必要がない。この方法論は本来、値を必要としないからだ。 characterization testが固定するのは値ではなく挙動だ。
HTTP 201
[Asserts]
jsonpath "$.order_id" exists
jsonpath "$.total" == 49800
ここで仕様として重要なのは 49800 という数字ではなく、「total フィールドが整数として存在し、与えられた入力に対してこう計算される」という構造だ。値をマスキングしたり合成データに差し替えても、仕様の価値はほとんど減らない。capture → マスキング → Hurl生成、このパイプライン全体があなたのインフラ内で回る。raw ログはどこにも出ていく必要がない。残るのは値が隠された仕様、構造だけが保存された契約だけだ。ログを外に出さなくていいのはセキュリティ上の譲歩ではなく、このアプローチの本質だ — そもそも挙動だけ剥製化すればいいのだから。
生成されたHurlをステージングに一度走らせてみれば、その場で通過/失敗が分かれる。緑信号が全て灯ったなら、いよいよリファクタリングを始められる。エージェントに思う存分作り変えろと言い、どこが壊れるかはHurlが見る。
コードなしで架ける梯子
だからこのアプローチの本当の価値は「テストを速く書く」ではない。本当の価値はこれだ。
- コードを読まなくても始められる — 原作者は去り、ドキュメントもないが、流れていったトラフィックだけで安全網が張られる。コードを理解する 前に 手をつける資格を先に得る。
- 成果物が即座に検証可能 — 生成されたHurlをステージングに走らせれば、その場でpass/failが出る。「うまくいくだろう」ではなく「今327個中327個が通過する」だ。
- データが塀を越えない — captureからHurl生成まで、全て自分のインフラ内で完結する。規制産業であるほど、何も外に出さずに始められることが決定的だ。
レガシー現代化の最初の一鍬は、たいてい「現在の挙動が何なのか誰も知らない」という崖で止まる。トラフィック → Hurl は、その崖に梯子を架ける。梯子を架けるのにコードは要らない。流れていったトラフィックがあればいい — しかもそのトラフィックさえ塀の内側に置いたままで。
流れはすでに仕様を書いていた
私たちは仕様を別途書こうと苦労する。OpenAPIを手で書き、Wikiに挙動を記述し、それらがコードと食い違うとドリフトと呼んで嘆く。
しかし生きているシステムは、毎瞬間、自分の仕様を自ら書いていた。リクエストが一つ入りレスポンスが一つ出るたびに、それは「私はこういうシステムだ」という一行の自己記述だ。ログファイルは、その自伝が一ヶ月間積み重なったものだ。
私たちが読まなかっただけだ。
レガシーはドキュメントがないのではない。ドキュメントがaccess logの中にあり、形式が人には読みにくいだけだ。Hurlに翻訳すれば、それは走らせられる仕様、機械が判定する契約になる。
ドキュメントは嘘をつく。トラフィックは嘘をつかない。
出典 / 根拠
核心概念・ツール
- Michael Feathers. Working Effectively with Legacy Code. Prentice Hall, 2004. — characterization test 概念の出典。「コードが正しく何をすべきかではなく、現在何をしているかを固定する。」
- Hurl プロジェクト (hurl.dev) — 平文HTTPリクエスト/レスポンステストフォーマット。yongolの10個のSSOTのうち一つとして統合されている。
- tsma 527個の関数による実証 — 関数単位のラチェット (tsma)。
トラフィック・実行からテストを抜き出す (carving / record-replay)
- Elbaum, Chin, Dwyer, Jorde (2009). “Carving and Replaying Differential Unit Test Cases from System Test Cases.” IEEE TSE 35(1). — システム実行をrecordしておきユニット単位でreplayするdifferential unit testの学術的基盤。
- Meta エンジニアリングチーム (2024). “Observation-based Unit Test Generation at Meta.” FSE 2024, arXiv:2402.06111. — アプリ実行の観測値からテストをcarving。CIで960万回実行、5,702個の欠陥を検出。「観測こそがテスト」の産業規模の実証。
現在の挙動を剥製化する (snapshot / golden master)
- Fujita, Kashiwa, Lin, Iida (2023). “An Empirical Study on the Use of Snapshot Testing.” ICSME 2023. — snapshot(= golden master/characterization)テストの採用に関する実証。「正しさではなく現在の出力を固定して変更を検知する。」
リファクタリング安全網
- Kim, Zimmermann, Nagappan (2014). “An Empirical Study of Refactoring Challenges and Benefits at Microsoft.” IEEE TSE 40(7). — 振る舞いの保存を保証するテストがなければ、リファクタリングはコストでありリスクだという実証。
- Yoo, Harman (2012). “Regression Testing Minimization, Selection and Prioritization: A Survey.” STVR 22(2). — 回帰テスト = 「変更が既存の振る舞いを損なわないという確信」の標準的定義。
実際の使用分布が優先順位だ
- John D. Musa (1993). “Operational Profiles in Software-Reliability Engineering.” IEEE Software 10(2). — 使用頻度順にテストを配分すれば、スケジュール上中断しても、最も多く使われる機能が最も多く検証される。「ハッピーパスのバイアスではなくトラフィック分布」の古典的基盤。
機械が判定すべき理由 (LLMは判断者ではなく実行者)
Huang, Chen, Mishra, et al. (2024). “Large Language Models Cannot Self-Correct Reasoning Yet.” ICLR 2024, arXiv:2310.01798. — 外部フィードバックなしにはLLMが自分の推論を矯正できない。決定論的な外部検証器が必要な理由。
Sharma, Tong, et al. (2024). “Towards Understanding Sycophancy in Language Models.” ICLR 2024, arXiv:2310.13548. — RLHFが同調を学習させ、LLMの自己判定の信頼性を崩す。
カバー画像: AI生成 (Google Gemini)
合わせて読みたい
- Michael Feathers, “Characterization Testing” — 用語の創始者による記事。「ソフトウェアがプロダクションに入った瞬間、それ自体が仕様になる(it becomes its own specification)。」この記事のタイトルとほぼ同じテーゼ。
- Hurl 公式チュートリアル, “Your First Hurl File” —
GET / HTTP 200から--testモードまで。平文の一行がそのままテストだということを、直接手に取ってみる入門。 - GitHub Engineering, “Scientist: Measure Twice, Cut Once” — レガシー(control)と新規(candidate)コードをプロダクションで同時実行して結果を比較するライブラリ。「実際の挙動だけが本物の仕様。」
- Twitter Diffy (InfoQ 要約) — 同じリクエストを新/旧サービスに送り、レスポンスの差だけを回帰として捉えるプロキシ。「テストを書かずに挙動を剥製化する」の古典的先例。
- GoReplay — ネットワークインターフェースからライブHTTPトラフィックをキャプチャしてステージングへリプレイするツール。「プロダクショントラフィックをテスト入力に」の代表的実装。
- Nicolas Carlo, “Characterization vs Approval Tests” — 事実上同じ技法である三つの用語を整理し、出力から機密情報をスクラブする「Printer」の役割を強調。
- Pact — consumer-driven contract testing。トラフィック剥製化と対比される「明示的な契約」アプローチ。二つの方式を並べて見ると均衡が取れる。
関連記事
- Hurlがドリフトを防ぐ — HTTP契約を平文で宣言しCIに固定する方法。この記事が「トラフィック → Hurl」なら、あちらは「Hurlでドリフトを固定する」。
- tsma — レガシーコードの回帰防御線 — 二重の安全網の内側の境界(関数単位)。Hurlが外側の境界なら、tsmaが内側。
- Agent Operable Codebase — レガシーをエージェントが作業可能なコードへ引き上げる3段階パイプライン。
- コーディングエージェントはなぜ動き、なぜ崩れるのか — Symbolic Feedback Loopの構造。
- 制約は契約だ — 検証可能で強制可能な契約としてのテスト。
- 崩壊したバイブコーディングを立て直す方法 — characterization testingでレガシーを診断→固定→修理→抽出→転換する実践講義。