エンジニアなら誰しも経験があるはずです。プロダクション環境で動いているコードなのに、テストコードが全くないエリアが存在するという状況。数ヶ月ぶりにそのコードを読み返してみると、ロジックは難解で、バグがあってもどこから手をつければいいのか見当もつかない。そんな「秘伝のタレ」化したレガシーコードです。
テストがない状態でプロダクションコードを触るのは、まさに綱渡りです。修正が他にどう影響するか確信が持てず、手動確認には限界があり、ミスは避けられません。
こうしたコードと向き合うのは、我々デベロッパーにとって日常茶飯事のタスクです。解決策として「characterization tests(仕様化テスト)」のような手法もありますが、今回は Kent Beck(TDDを広めた張本人ですね)が提唱した別のテクニック、TCR(Test-Commit-Revert) を紹介します。
TCRとは何か?
TCRは「Test, Commit, Revert」の略ですが、より正確に表現するなら "test && commit || revert" というシェルスクリプトのようなロジックのことです。
この手法は、レガシーコードをテストするためのワークフローを定義します。具体的には、ファイル保存をトリガーに以下のスクリプトを自動実行する環境を作ります。
- まず、テストしたいレガシーコードに対して空のユニットテストを作成する。
- アサーションを1つだけ追加して保存する。
- 自動でスクリプトが走る。
-
テスト成功: その変更を自動で
git commitする。 -
テスト失敗: その変更を自動で
git reset --hard(消去)して、直前の「Green」な状態に戻す。
-
テスト成功: その変更を自動で
テストが通れば、次のテストケースを追加します。もしテストが落ちれば、書いたばかりのコードは消えてなくなります。
TDDが「Red(失敗)させてから Green(成功)にする」サイクルなのに対し、TCRは「常に Green を維持する」ことに執着します。失敗したテストは即座に消去されるため、私たちは常に正しい状態から再出発せざるを得なくなります。
TCRの目的
このテクニックの最大の目的は、テストケースを一つずつ積み上げることで、コードへの理解を少しずつ深めていくことにあります。これによって自然とテストカバレッジが上がり、これまでは怖くてできなかったリファクタリングが可能になります。
TCRの利点は、全くテストがないコードだけでなく、部分的にテストがあるコードにも適用できる点です。失敗したらリバートされるだけなので、試行錯誤のコストが極端に低くなります。
どうやって導入するか?
Kent Beck が推奨しているのは、エディタでファイルを保存するたびにスクリプトを実行するアプローチです。
プロジェクト構成によりますが、基本的には以下のようなコマンドを実行できればOKです。
(rspec && git commit -am "WIP") || git reset --hard
Visual Studio Code を使っているなら、"runonsave" プラグインを使うのが手っ取り早いでしょう。設定ファイル(settings.json)は以下のようになります。
{
"folders": [{ "path": "." }],
"settings": {
"emeraldwalk.runonsave": {
"commands": [
{
"match": "*.rb",
"cmd": "cd ${workspaceRoot} && rspec && git commit -am WIP || git reset --hard"
}
]
}
}
}
「WIP」というコミットが大量に作られますが、後で Git の CLI や GitHub 上でマージする際に squash すれば問題ありません。
最終的にメインブランチには綺麗な一つのコミットとして残ります。
TCRで最初のテストを書いてみる
簡単な例で見ていきましょう。正常に動いていることは分かっているものの、テストがなく、これから修正を加えたい Worker クラスがあります。
# worker.rb
class Worker
def initialize(age, active_years, veteran)
@age = age
@active_years = active_years
@veteran = veteran
end
def can_retire?
return true if @age >= 67
return true if @active_years >= 30
return true if @age >= 60 && @active_years >= 25
return true if @veteran && @active_years > 25
false
end
end
最初のステップとして、テストファイルを作成します。can_retire? メソッドの最初の条件(67歳以上)からテストしていきましょう。
# specs/worker_spec.rb
require_relative './../worker'
describe Worker do
describe 'can_retire?' do
it "should return true if age is higher than 67" do
end
end
end
ここで一つコツがあります。TCRでは、保存してテストが落ちるとコードが消えてしまいます。そのため、まずはテストの「セットアップ(枠組み)」だけを書いて保存し、その後に実際のアサーション(expect)を1行ずつ追加していくのが安全です。
枠組みがコミットされたら、いよいよアサーションを追加します。
require_relative './../worker'
describe Worker do
describe 'can_retire?' do
it "should return true if age is higher than 67" do
expect(Worker.new(70, 10, false).can_retire?).to be_true ## この行を書いて保存する(失敗すれば消える)
end
end
end
保存して、この行が消えなければ成功です!テストがパスし、自動でコミットされました。
テストケースを積み上げる
最初のテストが通ったら、境界値や false になるケースをどんどん追加していきます。「it ブロックを書く -> 保存 -> expect を書く -> 保存」というリズムです。
# frozen_string_literal: true
require_relative './../worker'
describe Worker do
describe 'can_retire?' do
it 'should return true if age is higher than 67' do
expect(Worker.new(70, 10, false).can_retire?).to be true
end
it 'should return true if age is 67' do
expect(Worker.new(67, 10, false).can_retire?).to be true
end
it 'should return true if age is less than 67' do
expect(Worker.new(50, 10, false).can_retire?).to be false
end
it 'should return true if active years is higher than 30' do
expect(Worker.new(60, 31, false).can_retire?).to be true
end
it 'should return true if active years is 30' do
expect(Worker.new(60, 30, false).can_retire?).to be true
end
end
end
「十分カバーできた」と思えるまで、この作業を繰り返します。
最終的なテストコード
最終的な spec ファイルは以下のようになります。まだ網羅できるケースはありますが、TCRのプロセスを理解するには十分でしょう。
# frozen_string_literal: true
require_relative './../worker'
describe Worker do
describe 'can_retire?' do
it 'should return true if age is higher than 67' do
expect(Worker.new(70, 10, false).can_retire?).to be true
end
it 'should return true if age is 67' do
expect(Worker.new(67, 10, false).can_retire?).to be true
end
it 'should return true if age is less than 67' do
expect(Worker.new(50, 10, false).can_retire?).to be false
end
it 'should return true if active years is higher than 30' do
expect(Worker.new(60, 31, false).can_retire?).to be true
end
it 'should return true if active years is 30' do
expect(Worker.new(20, 30, false).can_retire?).to be true
end
it 'should return true if age is higher than 60 and active years is higher than 25' do
expect(Worker.new(60, 30, false).can_retire?).to be true
end
it 'should return true if age is higher than 60 and active years is higher than 25' do
expect(Worker.new(61, 30, false).can_retire?).to be true
end
it 'should return true if age is 60 and active years is higher than 25' do
expect(Worker.new(60, 30, false).can_retire?).to be true
end
it 'should return true if age is higher than 60 and active years is 25' do
expect(Worker.new(61, 25, false).can_retire?).to be true
end
it 'should return true if age is 60 and active years is 25' do
expect(Worker.new(60, 25, false).can_retire?).to be true
end
it 'should return true if is veteran and active years is higher than 25' do
expect(Worker.new(60, 25, false).can_retire?).to be true
end
end
end
リファクタリングへ
ここまでくると、元のコードの「マジックナンバー」が気になってくるはずです。テストと Worker クラスの両方で、これらを定数に括り出したり、ロジックをプライベートメソッドに分割したりできます。
これらは練習問題としておきますが、すでにテスト(防護柵)があるため、リファクタリング中にミスをしてもすぐに気づけます。もしミスをすれば TCR がコードを差し戻してくれるので、常に安全な地点からやり直せます。
まとめ
ぜひ、手元のプロジェクトで TCR を試してみてください。外部の CI サーバーも新しいライブラリへの依存も必要ありません。保存時にコマンドを叩く仕組みさえあれば、今すぐ始められます。
実際にやってみると、パズルを解くような「ゲーム性」があって意外と楽しいものです。また、「テストが落ちたらエディタからコードが消える」という強制力が、確実なステップを刻む習慣を身につけさせてくれます。
レガシーコードのジャングルを切り拓く際、この TCR という鉈(なた)が役に立つことを願っています。ここ数ヶ月、私も何度か使っていますが、実に快適なワークフローですよ。

