
テストのないコードをどうリファクタリングするか?
10万行のレガシーコードを引き継いだ。テストがない。リファクタリングしたいが、触れば何が壊れるかわからない。テストを書くにはコードを理解する必要があり、コードを理解するにはドキュメントが必要だが、ドキュメントもない。
誰も触らない。さらに腐っていく。
世界中のすべてのレガシーコードがこのデッドロックに陥っている。Fortune 500企業のIT予算の60〜80%がレガシーの維持に費やされている。開発者の時間の42%が技術的負債の処理に消えている。
LLMがテストを代わりに書いてくれるとしたら?
LLMにテストを任せると生じる問題
LLMに「この関数のテストを書いて」と言えば、何かは出てくる。問題は3つある。
第一に、どこから始めるべきかわからない。 関数が527個あるとき、1番から順番に? 最も重要なものから? 基準がない。
第二に、テストの質を検証できない。 LLMが書いたテストがpassした。しかしそのテストは本当に関数の動作を検証しているのか、それとも呼び出すだけでassertのない空っぽなのか? 人が一つずつ読まなければわからない。
第三に、フィードバックがなければLLMのテストは60〜70%で止まる。 「この関数をテストして」だけでは分岐coverage 100%に到達できない。どの分岐が抜けているか伝えて初めて、残りを埋められる。
LLMがテストを書けないのではない。LLMに何を書くべきか、どれだけうまく書けたかを伝える構造がないことが問題なのだ。
tsma:コマンド一つで回る テストレール
tsmaは、プロジェクトのすべての関数をインデックスし、テストの有無を検知し、coverageを計測し、LLMエージェントに正確なフィードバックを返すCLIツールだ。
エージェントが知るべきコマンドは一つだけ。
$ tsma next
このコマンド一つがループ全体を駆動する:
$ tsma next # テストのない次の関数を表示する
→ テストを書く
$ tsma next # 新しいテストを検知し、実行し、coverageを計測する
→ 100%? PASS、次の関数へ
→ <100%? 未カバー分岐をライン番号とともに表示する
$ tsma next # 修正されたテストを再計測する
→ 改善してもしなくても、DONEとして次へ
「All functions complete!」が出るまで繰り返す。
527個の関数で検証した
実際のGoプロジェクト(527個の関数)にtsmaを適用した。
| 結果 | 件数 | 割合 |
|---|---|---|
| PASS(100%分岐coverage) | 246 | 46.7% |
| DONE(best-effort) | 281 | 53.3% |
| TODO(未処理) | 0 | 0% |
246個の関数が分岐coverage 100%に到達した。残りの281個は100%に届かなかったが、可能な範囲までテストが書かれた。
なぜ100%に到達できない関数があるのか?
100%に到達する関数と到達しない関数
関数が100%分岐coverageに到達できるかは、依存性をどう受け取るかにかかっている。
インターフェース(mockable)— 100%達成可能:
type Handler struct {
svc AuthSvc // interface — mockに差し替え可能
}
テストでmockを注入すれば、すべてのパスを制御できる:
svc := mocks.NewMockAuthSvc(ctrl)
svc.EXPECT().Login(...).Return(result, nil) // 成功パス
svc.EXPECT().Login(...).Return(nil, err) // 失敗パス
具象型(not mockable)— 100%不可能:
type Handler struct {
svc *service.SMSImportService // structポインタ — 差し替え不可
}
実際の実装がDB、外部APIなどの内部依存性を持って動く。特定のエラーを発生させたり、特定の結果を返させたりできない。その結果に依存する分岐はユニットテストでは到達できない。
tsmaの対応: 未カバー分岐のフィードバック後にもう一度試みる。それでも到達できなければDONEとして受容する。これはツールの限界ではなく、コードのテスト可能性を反映している。インターフェースを導入すれば(DI)100%が可能になるが、それは元のコードを修正する作業だ。
フィードバックがLLMのテストを劇的に変える
tsmaの核心的価値は、インデックスでもcoverage計測でもない。未カバー分岐をライン番号で正確に伝えることだ。
フィードバックなし:
"ListContracts関数のテストを書いて"
→ LLMがhappy pathだけテスト
→ coverage 60〜70%
フィードバックあり:
"ListContracts関数のテストを書いて"
→ coverage 65%(11/17)
→ UNCOVERED:
line 41 — if params.Status != nil
line 44 — if params.BuildingId != nil
line 70 — if err != nil (CountSummary)
→ LLMがまさにその分岐をカバーするテストを追加
→ coverage 100%
同じLLMだ。違いはフィードバックの有無だけ。ライン番号3行が60%と100%を分ける。
エージェントが落ちても進捗は保存される
LLMエージェントは落ちる。トークン上限、ネットワークエラー、セッション切断。527個の関数を1セッションで全部処理することはできない。
tsmaは進捗を.tsma/session.jsonに永続保存する。
$ tsma status
527 functions
PASS: 246 (46.7%)
DONE: 281 (53.3%)
TODO: 0 (0.0%)
エージェントが200番目の関数で落ちたら? 新しいエージェントがtsma nextを実行すれば201番目から再開する。session.jsonがチェックポイントだ。
複数のエージェントが交代で作業しても衝突はない。関数単位でアトミックだ。
セッションはキャッシュ、ソースファイルが真実
tsmaの設計原則の一つ:sessionはキャッシュであり、ソースファイルがsource of truthだ。
テストファイルを削除すれば、session.jsonにPASSと記録されていても、その関数はTODOに戻る。セッションが現実と乖離しない。
原則:
session.jsonが"PASS"と言っても
テストファイルがなければ → TODO
ソースファイルが変わっていれば → 再計測対象
LLMエージェントへの指示
エージェントに必要な指示は6行だ:
1. tsma nextを実行
2. TODOなら — 関数を読んでテスト作成
3. テスト失敗なら — エラーを読んでテスト修正
4. 未カバー分岐が表示されたら — その分岐をカバーするテスト追加
5. PASS/DONEなら — 次の関数が自動で表示される
6. "All functions complete!"が出るまで繰り返す
エージェントが知るべきコマンドはtsma next一つだけ。残りはCLIが制約する。
列車と線路
バイブコーディングは列車だ。速い。しかし線路がなければ脱線する。
AIコーディングツールはすべて列車をもっと速くすることに集中している。より大きなモデル、よりスマートなエージェント、より良いプロンプト。しかし列車が速くなるほど、脱線の被害も大きくなる。
tsmaは線路だ。LLMがテストを生成し(Neural)、CLIが「ここまで」を定義する(Symbolic Constraint)。LLMの創造性はそのままに、結果の質は機械が強制する。
| 従来 | tsma | |
|---|---|---|
| テスト作成 | 人(遅い)またはLLM(無秩序) | LLMが作成、CLIが検証 |
| どこから? | 人が判断 | CLIが順序決定 |
| 品質確認 | 人がレビュー | CLIがcoverage計測 |
| フィードバック | なし | 未カバー分岐のライン番号 |
| 進捗追跡 | なし | session.json自動 |
LLMは自由に生成する。しかしtsma nextという線路の上でだけ走る。
言語サポート
| 言語 | インデクサ | テストランナー | Coverage |
|---|---|---|---|
| Go | go/ast | go test | go test -coverprofile |
| TypeScript | regex | npx vitest / npx jest | c8 / istanbul |
| Python | regex | pytest | coverage.py |
GoはASTパーサーで正確な関数抽出。TypeScriptとPythonは正規表現ベース。
生成ファイル(*_gen.go、*.pb.go)、テストファイル、vendor/node_modulesはインデックスから自動除外される。
インストールと実行
make install
cd your-legacy-project
tsma next
それだけだ。
MIT License. github.com/park-jun-woo/tsma