Rails
RSpec
テスト
rake

RailsでRakeタスクをシンプルかつ効果的にテストする手法

背景

※ この記事は DeNAその2 Advent Calendar 2018 の 12月8日の記事です。
軽い小ネタ記事ではありますが、楽しんでいただければ幸いです。

さて、RailsにおけるRakeタスクのテストで困ったことはあるでしょうか?

例えば、Rakeタスク テストで検索すると、以下のようなコードが良く見つかります。

# This code is NOT so good......
@rake = Rake::Application.new
Rake.application = @rake
Rake.application.rake_require 'tasks/foo'
Rake::Task.define_task(:environment)

しかし、このコードは様々な問題を持っています。(詳しくは触れませんが、落とし穴にハマった方も多いのではないでしょうか?)
では、一体どのようにテストを書けば良いのでしょうか?

この記事では、筆者が考える、RailsでRakeタスクをシンプルかつ効果的にテストする手法を紹介します。

TL; DR

rake_helper.rb 等にテスト全体に及ぼす設定を書きます。

require 'rake'
Rspec.configure do |config|
  config.before(:suite) do
    Rails.application.load_tasks # Load all the tasks just as Rails does (`load 'Rakefile'` is another simple way)
  end

  config.before(:each) do
    Rake.application.tasks.each(&:reenable) # Remove persistency between examples
  end
end

Rake.application 経由で呼び出したタスクを実行してください。

require 'rake_helper'

describe 'player:create' do
  subject(:task) { Rake.application['player:create'] }

  it 'creates player' do
    expect { task.invoke }.to change { Player.count }.from(0).to(1) # Focus on integration, not on unit nor on minute algorithm
  end 
end

とってもシンプルでわかりやすいでしょう?
この手法は、簡潔で分かりやすいだけではありません。実際のRakeタスクをほぼ正確に再現できています。
更に、テスト間の結合性を可能な限り減らしており、テストの追加や実行順序、内部ロジックの変更等に対しても非常に強くなっています。

大前提: Rakeタスクは薄いグルーコードにする

まず、大前提として、Rakeタスクは薄いグルーコードにしましょう。
細かいロジックを別のクラスやモジュールに記述し、Rakeタスク自体は、CLIとのI/Fであったり、実行フローの管理の責務のみをもたせましょう。

このようにすることで、ロジックの再利用が可能になります。また、どうしても手続き的な書き方になってしまうRakeファイルと異なり、よりオブジェクト指向であり、常日頃慣れているRubyの書き方でコードを書くことが可能になります。

更に、一般的なテストフレームワークは、Rubyのコードをテストするための便利なものが揃っているので、テストを楽にするという意味でも、このような分割をおすすめします。

Rakeタスクの読み込み

before(:suite) do
  Rails.application.load_tasks # Load all the tasks just as Rails does
end

グルーコードとしてのRakeタスクをテストしたい場合、我々が実現したいことは以下です。

可能な限り実際のRakeタスクと同じ手段で読み込みたい

これは、Modelに対してのController Spec, あるいは Request Spec的な考え方に近いでしょう。
では、rakeファイルが実行時に読み込まれる Rakefile のテンプレートを読んでみましょう。

# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.

require_relative 'config/application'

Rails.application.load_tasks

https://github.com/rails/rails/blob/c2f8df67f34e233ff3f7f058d492217c5ad3eff1/railties/lib/rails/generators/rails/app/templates/Rakefile.tt

ということで、素直に Rails.application.load_tasksbefore(:suite) で実行しましょう。好みによりますが、load 'Rakefile'でも良いでしょう。

Rails.application.load_tasks の中では、Railsが定義しているタスクを含め、全てのタスクが読み込まれます。

このシンプルな手法により、我々は以下を得ることが出来ます。

  • 正規の手順で、Rakeタスクを読み込み出来る
  • Railsが内部的に定義しているようなタスクも全て読み込み出来る
  • before(:suite) 時のオーバーヘッドはあるが、一度読み込んだものを再度読み込む必要が無いので全般的なパフォーマンスが向上する
  • Rake.application.rake_require を手動で実行する場合には、テスト間で読込済判定を避ける為の技を駆使しないといけないが、そのような心配事から解放される
  • Rake::Task.define_task(:environment) のようなモックを作る作業から解放される

Rakeタスクの再実行

Rakeタスクには複数の実行手法があります。それは invokeexecute です。
実行順序や依存関係を扱いやすくするために、invoke で呼ばれたタスクは、同一プロセス内においては、2回目実行されることはありません。
テストにおいては、テスト間の結合性をなくすということは非常に重要です。ということで、我々はテスト間に、全てのタスクが呼ばれた履歴を抹消することにします。

  config.before(:each) do
    Rake.application.tasks.each(&:reenable) # Remove persistency between examples
  end

Rakeタスクの参照方法

このようにして読み込んだ Rake タスクをどのように参照すればよいのでしょうか?
一度読み込まれたタスクは Rake.application で呼び出し可能な Rake::Application のインスタンスに登録されます。

これは、以下のように Rake::Application#[] で呼び出すことが可能です。

    # Find a matching task for +task_name+.
    def [](task_name, scopes=nil)
      task_name = task_name.to_s
      self.lookup(task_name, scopes) or
        enhance_with_matching_rule(task_name) or
        synthesize_file_task(task_name) or
        fail generate_message_for_undefined_task(task_name)
    end

https://github.com/ruby/rake/blob/ff4bb1e86096444e08b123037bf4907da3d568bf/lib/rake/task_manager.rb#L53-L60

task_name が空振りした時には例外が発生するので、テストとしてもより信頼がおけるものになるでしょう。

Rakeタスクのテスト方針

大前提として、Rakeタスクは薄いグルーコードになっているはずです。
そのため、細かいロジックを気にせず、処理の流れが問題ないことを確認する程度のテストをかけば十分でしょう。

require 'rake_helper'

describe 'player:create' do
  subject(:task) { Rake.application['player:create'] }

  it 'creates player' do
    expect { task.invoke }.to change { Player.count }.from(0).to(1) # Focus on integration, not on unit nor on minute algorithm
  end 
end

まとめ

ということで、Rakeタスクをシンプルかつ効果的にテストする手法を紹介しました。
セットアップ時にRakeタスクを一通り読み込む都合上、副作用の大きいRakeタスクのテストには向いていません。
しかし、それを上回るだけのシンプルさと、効率を併せ持つテスト手法だと、筆者は考えております。

いつもの開発で役に立てていただければ幸いです。
また、もっと良い手法等をご存知の方がいらっしゃたら、是非お声をかけていただければ幸いです。

参考

https://github.com/rails/rails
https://github.com/ruby/rake
https://robots.thoughtbot.com/test-rake-tasks-like-a-boss