Ruby の load によるカバレッジリセット問題と rake テストでの対処法
ツクリンクでエンジニアをしている oieioi です。よろしくおねがいします。
rake のテストのカバレッジがうまく収集できないことがある
rake は Ruby 版の make 的なタスク実行ツールで、アプリケーションを Ruby で書いているときバッチ処理も Ruby で書きたいな、ということでみなさん使われていると思います。バッチ処理はデータの整合性をとったりで重要な処理を書くことも多いのでテストも書いておきたいですね。テストを書いたらカバレッジも収集したくなります。
ということで SimpleCov を組み合わせて rake タスクのテストのカバレッジを測定してたんですが、見直していたら確かに実行したはずのコードのカバレッジが収集されていない。なんで?と思い調べたら rake のテスト設定と coverage の挙動に噛み合っていない部分があったので、その調査内容を共有します。
先にまとめ
-
Kernel#loadを同じファイルに対して複数回使うと、その時点までのそのファイルの coverage の収集状況がリセットされるっぽい -
rake はデフォルトで
Kernel#loadを使って読み込みを行うため、テストごとにRake::Application#load_rakefileを呼ぶとカバレッジが失われる - 解決策:
Rake::Application#load_rakefileはテスト実行のはじめに一度だけ呼び、各テストでの初期化はRake::Task#reenableでタスクを再実行可能にする
前提: こんなアプリケーションを想定します
ディレクトリ構成はこんな感じ。こちらのリポジトリで試せます。
.
├── Gemfile
├── Rakefile
├── lib
│ └── tasks
│ ├── bar.rake
│ └── foo.rake
└── spec
├── bar_spec.rb
├── foo_spec.rb
└── spec_helper.rb
source 'https://rubygems.org'
gem 'rake'
gem 'rspec', '~> 3.12'
gem 'simplecov', require: false
gem 'activesupport'
# 絶対パスで load しないとカバレッジが収集されないみたい
Dir.glob(File.expand_path('lib/tasks/*.rake', __dir__)).each do |file|
load File.expand_path(file)
end
# # アプリケーションとしては動くけどカバレッジが収集されなかった
# Dir.glob('lib/tasks/*.rake').each do |file|
# load file
# end
Rakefile での load での読み込みが相対パスだとカバレッジの収集がうまくいきませんでした。Kernel#load のドキュメントに load は絶対パスで指定すること、とあるのでそのへんに仕様があるのかな?
なお、特定のディレクトリからファイルをロードしたい場合、 load 'filename' とするのは不適切です。必ず絶対パスを使ってください。
lib/tasks/foo.rake と lib/tasks/bar.rake がテスト対象です。
問題: Rake::Application#load_rakefile を呼ぶとその時点での rake のカバレッジがリセットされる
はじめは以下のようにテストごとに Rake.application.load_rakefile と clear を呼んで rake タスクの初期化をしていました。これはテストとしては動くが、カバレッジがちゃんと収集されない。
require 'simplecov'
SimpleCov.start do
add_filter 'vendor'
add_filter 'spec'
add_filter 'Rakefile'
track_files "{app,lib}/**/*.{rb,rake}"
end
require 'rake'
RSpec.configure do |config|
config.before(:suite) do
Rake.application = Rake::Application.new
Rake.application.init("testapp")
end
config.before(:each) do
Rake.application.load_rakefile
end
config.after(:each) do
Rake.application.clear
end
end
この状態でテストを動かすと、 lib/tasks/bar.rake -> lib/tasks/foo.rake の順でテストが実行されたとき、 先に実行された lib/tasks/bar.rake のカバレッジが正確に取れません。確かに実行された行がカバーされていないようにレポートされます。どうやら Rake.application.load_rakefile でカバレッジが破棄されるようです。
破棄の様子をプリントデバッグして確かめてみましょう。その時点でのカバレッジ収集状況をチェックするため、 SimpleCov が内部で依存している Coverage モジュールの result を呼びます。Coverage.result は stop: false を与えないと呼ばれたところで収集を打ち切るので注意。もしくは peek_resultでもOK
こんな感じでデバッグログを仕込んで
before do
puts "================= before load_rakefile =================="
result_before = Coverage
.result(clear: false, stop: false)
.select { |file, coverage| file.include?('lib/tasks/bar.rake') }
pp result_before
Rake.application.load_rakefile
puts "================= after load_rakefile =================="
result_after = Coverage
.result(clear: false, stop: false)
.select { |file, coverage| file.include?('lib/tasks/bar.rake') }
pp result_after
end
実行するとやはり Rake.application.load_rakefile の前後でカバレッジが失われていました。
$ rspec
# bar.rake のテスト実施ログ
# (省略)
# foo.rake のテスト実施ログ
foo.rake
foo:setup
================= before load_rakefile ==================
{"/Users/dev/repos/rake_and_simplecov/lib/tasks/bar.rake"=>{:lines=>[1, nil, 1, 1, 1, 1, 1, 1, 3, nil, nil, nil]}
================= after load_rakefile ==================
{"/Users/dev/repos/rake_and_simplecov/lib/tasks/bar.rake"=>{:lines=>[1, nil, 1, 1, 1, 0, 0, 0, 0, nil, nil, nil]}
タスクが実行できること
load_rakefile の前後で lines のカバーしたはずの 1 が 0 になっていることがわかると思います。(1 のまま残っている行は load_rakefile 時に評価された行です。たとえば以下のような bar.rake があるとき、 task :run に渡されたブロック以外は load_rakefile 時に実行されるのでその時点でカバーされます。)
require 'logger'
namespace :bar do
desc 'Bar task'
task :run do
logger = Logger.new($stdout)
logger.info "Running bar:run"
[1,2,3].each do |i|
logger.info "i: #{i}"
end
end
end
対処: Rake::Task#reenable で初期化する
なんで毎回 load_rakefile を呼んでいたのかというと、 rake は同じプロセス内で同じタスクの処理を1度しか呼ばないチェックを持っているからです。テスト中に何度も呼ばれるタスクにおいてはこのフラグを意図的に下ろしてやらないと、確かに実行されているタスクなのに処理自体が呼ばれず、テストが通らなくなります。
rake がどうやってこれをチェックしてるか見てみましょう。以下は Rake::Task#invoke のコードです。これを見ると、205行目で @already_invoked フラグをチェックし、何もせずに212行目で return していることがわかります。@already_invoked が false のときは219行目で execute が行われています。
このフラグを初期化するために reenable というメソッドが用意されています。
ただし、reenable は invoke が実行する事前タスクたち(prerequisites) のフラグは下ろさないため、テストのためにはそれら事前タスクも reenable する必要があります。
つまり以下を行うように spec_helper.rb を整えてみましょう。
-
load_rakefileは一度きりにする - テスト対象のタスクとその事前タスクたち全てを
reenableする
# let(:task_name) を各テストで定義させたかったので `included` を使って実装した
module TaskExampleGroup
extend ActiveSupport::Concern
included do
let(:task) { Rake.application[task_name] }
before do
unless defined?(task_name)
raise "task_name を定義してください 'let(:task_name) { \"namespace:task_name\" }' in your test."
end
# 再実行可能化
task.reenable
# 事前タスクも reenable してフラグをオフにする必要がある
task.prerequisite_tasks.each(&:reenable)
end
end
end
RSpec.configure do |config|
config.before(:suite) do
Rake.application = Rake::Application.new
Rake.application.init("testapp")
# load_rakefile は一度きり
Rake.application.load_rakefile
end
config.include TaskExampleGroup
end
これで rake タスクのカバレッジが意図通り収集できるようになりました。
ここまでの挙動を見るに load されたときにファイルごとのカバレッジ収集状況をリセットするような感じです。 require は一度読み込んだファイルはもう読み込まないのでこれは起きないと思います。 Rails を使っているとこのあたりにかなり無頓着になっちゃいますね。
rake でこの問題が顕在化したのは rake のデフォルトのローダーが load を使っているからとは言えるかも。
みなさんも rake テストのカバレッジを見直してみてください。この現象と並行テストが絡んでいると、カバレッジが実行ごとに揺れたりしてかなり不思議なことになります。
ポイント: coverage は load を行うとその時点までのカバレッジ収集状況を破棄するっぽい
ここまでの調査により、重要なポイントは「coverage は load を行うとその時点までのカバレッジ収集状況を破棄するっぽい」ということがわかってきました。 rake の問題というよりは load の使い方により起こるっぽい。rake を使わずに実験してみましょう。たとえば以下のような場合、load ごとにそれまでのカバレッジが失われます。これを require にするとカバレッジは保持されました。
# app/app.rb
class App
def self.run
"hello, I am App"
end
end
# app/sub.rb
class Sub
def self.run
"hello, I am Sub"
end
end
# spec/spec_helper.rb
RSpec.configure do |config|
config.before(:each) do
Dir.glob(File.expand_path('../../app/**/*.rb', __FILE__)).sort.each do |file|
# require なら Coverage の破棄は起きない
# require File.expand_path(file)
load File.expand_path(file)
end
end
end
# spec/各specファイル
Rspec.describe 'App' do
describe '.run' do
it 'App.run が実行できること' do
expect(App.run).to eq('hello, I am App')
end
end
end
Rspec.describe 'Sub', type: :app do
describe '.run' do
it 'Sub.run が実行できること' do
expect(Sub.run).to eq('hello, I am Sub')
end
end
end
この挙動は load した時に収集用の Hash を同じファイル名で初期化してるんだろうなと思って coverage のソースコードをあたってみたんですがC言語だったので宿題にします。
ruby/ext/coverage/coverage.c - GitHub
参考記事リンク集
-
simplecov-ruby/simplecov - GitHub
- coverage をラップして様々に便利にしてくれるツール
-
coverage - Ruby リファレンスマニュアル
- Ruby 組み込みのカバレッジ収集モジュール
-
ruby/ext/coverage/coverage.c - GitHub
- coverage の実装。C言語
-
rake - Ruby リファレンスマニュアル
- Ruby の タスク実行ツール
-
ruby/rake - GitHub
- rake のソースコード
-
Kernel#load - Ruby リファレンスマニュアル
-
loadとrequireの違いについて
-
-
RailsでRakeタスクをシンプルかつ効果的にテストする手法
- お世話になった記事
-
rake taskでループ内にあるinvokeが実行されない
- お世話になった記事
Discussion