Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Test-Commit-Revert(TCR):Rubyでレガシーコードにテストを追加するための実践ワークフロー

これは誰にでも起こりうることです。ソフトウェアプロジェクトが成長していくと、包括的なテストスイートを持たないまま本番環境にコードがデプロイされてしまうことがあります。数ヶ月後に同じコードを見返すと、理解するのが難しくなっているかもしれません。さらに悪いことに、バグが見つかっても、どこから手をつければいいのか分からないという状況も起こり得ます。

テストのないコードを修正することは大きな挑戦です。変更によって何かを壊してしまうかもしれないと確信が持てず、すべてを手動で確認するのは、良くてもミスが起きやすく、通常は不可能です。

このようなコードへの対応は、開発者が日常的に行う最も一般的なタスクの一つであり、長年にわたり多くの手法がこの問題に焦点を当ててきました。以前の記事で紹介した「キャラクタライゼーションテスト(characterization tests)」もその一つです。

今回は、キャラクタライゼーションテストをベースにしたもう一つのテクニックを紹介します。それはKent Beck氏が考案したもので、同氏は何年も前にTDD(テスト駆動開発)を現代のプログラミングの世界に導入した人物としても知られています。

TCRとは?

TCRは「test, commit, revert(テスト・コミット・リバート)」の略ですが、「test && commit || revert」と表現する方がより正確です。その理由を見ていきましょう。

このテクニックは、レガシーコードをテストするためのワークフローを示しています。プロジェクトのファイルを保存するたびにテストを実行するスクリプトを使用します。プロセスは以下の通りです。

  • まず、テストしたいレガシーコードの部分に対して空のユニットテストを作成します。
  • 次に、アサーションを1つ追加してテストファイルを保存します。
  • スクリプトが設定されているため、テストは自動的に実行されます。成功すれば変更がコミットされ、失敗すれば変更は削除(リバート)され、再挑戦することになります。

テストが通ったら、新しいテストケースを追加していきます。

要するに、TCRとは、TDDのように最初に失敗するテスト(赤)を書いてからパスさせる(緑)のではなく、コードを常に「グリーン」の状態に保つことを目的とした手法なのです。もし失敗するテストを書けば、その変更は消え去り、再び「グリーン」の状態に戻されます。

目的

このテクニックの主な目標は、テストケースを追加するたびにコードへの理解を少しずつ深めていくことです。これにより自然とテストカバレッジが向上し、それがなければ不可能だった多くのリファクタリングへの道が開かれます。

TCRの利点の一つは、さまざまなシナリオで活用できることです。テストが一切ないコードにも、部分的にテストされているコードにも使えます。テストが通らなければ、単に変更をリバートしてもう一度試せばいいだけです。

どのように使うのか?

Kent Beck氏は、複数の記事や動画(記事末尾にリンクあり)の中で、プロジェクト内の特定のファイルが保存されたタイミングで実行されるスクリプトを使うのが良いアプローチだと示しています。

具体的な方法は、テスト対象のプロジェクトに大きく依存します。以下のようなスクリプト、つまりエディタのプラグインでファイルを保存するたびに実行されるコマンドは、良い出発点になるでしょう。

(rspec && git commit -am "WIP") || git reset --hard

Visual Studio Codeを使用している場合、保存のたびにコマンドを実行できる「runonsave」というプラグインが便利です。上記のコマンドや、プロジェクトに合わせた類似のコマンドを組み込むことができます。この場合、設定ファイル全体は次のようになります。

{
  "folders": [{ "path": "." }],
  "settings": {
    "emeraldwalk.runonsave": {
      "commands": [
        {
          "match": "*.rb",
          "cmd": "cd ${workspaceRoot} && rspec && git commit -am WIP || git reset --hard"
        }
      ]
    }
  }
}

後から、Gitを直接コマンドラインで操作するか、GitHubを使っている場合はPRをマージする際に、コミットをsquash(圧縮)できることを忘れないでください。

Test-Commit-Revert(TCR):Rubyでレガシーコードにテストを追加するための実践ワークフロー

これにより、作業中のブランチで行ったすべてのコミットが、メインブランチでは1つのコミットとしてまとめられます。GitHubのこちらの図がよく分かりやすいでしょう。

Test-Commit-Revert(TCR):Rubyでレガシーコードにテストを追加するための実践ワークフロー

TCRで最初のテストを書いてみよう

シンプルな例を使って、このテクニックを説明します。正常に動作していることは分かっているものの、修正が必要なクラスがあるとしましょう。

もちろん、変更を加えてそのままデプロイすることもできます。しかし、変更の過程で何も壊さないことを確信したいものです。それは常に良い考えです。

# 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?メソッドの最初の条件行は次のようになっています。

  def can_retire?
    return true if @age >= 67
    ...
    ...
  end

そこで、まずこのケースからテストしてみましょう。

# 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で作業する際、保存するたびにテストが通らなければ最新の変更は消えてしまいます。したがって、アサーションを含む行を実際に書いて保存する前に、テストの「セットアップ」となるコードをできるだけ多く書いておくのが賢明です。

上記のファイルをそのまま保存しておき、その後でテストの行を追加します。

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になるケースも考慮しながら、さらなるテストケースを追加していきます。しばらく作業すると、次のようなテストが完成します。

# 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

どのケースでも、まずitブロックを書いて保存し、その後にexpect(...)によるアサーションを追加します。

当然ながら、テストはいくらでも追加できますが、ある程度網羅できたと確信できたら、あまり多く追加しすぎないのが合理的です。

まだカバーできていないケースがいくつか残っているので、完全性のために追加しておきましょう。

最終的なテスト

以下が最終的な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クラスの両方に、定数として抽出すべき「マジックナンバー」が多数存在しています。

また、公開メソッドであるcan_retire?の各ケースごとに、プライベートメソッドを作成することもできます。

これら2つのリファクタリング候補は、皆さんへの演習課題として残しておきます。ただし、今やテストがあるので、もし途中で間違いを犯しても、テストが教えてくれるでしょう。

まとめ

ぜひ自分のプロジェクトでTCRを試してみてください。外部サーバーでの凝ったCI環境も、新しいライブラリへの依存も必要なく、非常に手軽な実験です。必要なのは、特定のファイルを保存するたびにコマンドを実行できる仕組みだけです。

さらに、テストを追加する際に「ゲームのような」体験が得られるのも楽しく魅力的な点です。加えて、失敗するテストがエディタから自動的に消えるという規律により、リポジトリにpushするテストが必ずパスしていることが保証され、追加の安全ネットとなります。

レガシーコードに取り組む際に、この新しいテクニックが役立つことを願っています。筆者自身、ここ数ヶ月で何度も活用しましたが、いつも快適な経験でした。

参考リソース

  • 入門として最適な動画
  • Kent Beck氏によるVS CodeでのTCR活用法
  • 保存時にスクリプトを実行するVS Codeプラグイン

  1. Rackとは?Ruby開発者のための基本仕組みと自作ミドルウェア入門

    RailsやSinatraなど、あらゆるRuby製Webフレームワークの裏側では、何が起こっているのでしょうか? その答えがRackです。フレームワークとサーバーをつなぐ、まさに要となる存在です。 では、Rackとは一体何なのでしょうか? Rackは、Webフレームワーク(Railsなど)とアプリケーションサーバー(Pumaなど)の間に位置する「層」です。 両者が正しくやり取りできるようにする、糊(グルー)のような役割を担っています。 なぜRackを使うのか? Rackを使う最大の理由は、異なるフレームワークとサーバーを自由に組み合わせられるようにするためです。 それぞれが差し替え可能なプラ

  2. Rubyでの静的分析入門!parser gemでメソッド定義を抽出する方法

    ソースコードを解析して、すべてのメソッドがどこで定義され、どんな引数を受け取るのかを把握したいと思ったことはありませんか? どうすれば実現できるのでしょうか? 最初に思いつくのは、正規表現(regexp)を書くことかもしれません。 しかし、もっと良い方法があるとしたらどうでしょう? 答えは「あります」! 静的解析(Static Analysis)とは、ソースコードそのものから情報を抽出するためのテクニックです。 これは、ソースコードをトークンへと変換する(パースする)ことで実現されます。 それでは早速見ていきましょう! parser gemを使う Rubyには標準ライブラリとしてRipper