🦷

Ruby の load によるカバレッジリセット問題と rake テストでの対処法

に公開

ツクリンクでエンジニアをしている oieioi です。よろしくおねがいします。

rake のテストのカバレッジがうまく収集できないことがある

rake は Ruby 版の make 的なタスク実行ツールで、アプリケーションを Ruby で書いているときバッチ処理も Ruby で書きたいな、ということでみなさん使われていると思います。バッチ処理はデータの整合性をとったりで重要な処理を書くことも多いのでテストも書いておきたいですね。テストを書いたらカバレッジも収集したくなります。

ということで SimpleCov を組み合わせて rake タスクのテストのカバレッジを測定してたんですが、見直していたら確かに実行したはずのコードのカバレッジが収集されていない。なんで?と思い調べたら rake のテスト設定と coverage の挙動に噛み合っていない部分があったので、その調査内容を共有します。

先にまとめ

前提: こんなアプリケーションを想定します

ディレクトリ構成はこんな感じ。こちらのリポジトリで試せます。

.
├── Gemfile
├── Rakefile
├── lib
│   └── tasks
│       ├── bar.rake
│       └── foo.rake
└── spec
    ├── bar_spec.rb
    ├── foo_spec.rb
    └── spec_helper.rb
Gemfile
source 'https://rubygems.org'

gem 'rake'
gem 'rspec', '~> 3.12'
gem 'simplecov', require: false
gem 'activesupport'
Rakefile
# 絶対パスで 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.rakelib/tasks/bar.rake がテスト対象です。

問題: Rake::Application#load_rakefile を呼ぶとその時点での rake のカバレッジがリセットされる

はじめは以下のようにテストごとに Rake.application.load_rakefileclear を呼んで rake タスクの初期化をしていました。これはテストとしては動くが、カバレッジがちゃんと収集されない。

spec_helper.rb
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.resultstop: false を与えないと呼ばれたところで収集を打ち切るので注意。もしくは peek_resultでもOK

こんな感じでデバッグログを仕込んで

spec_helper.rb
    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 のカバーしたはずの 10 になっていることがわかると思います。(1 のまま残っている行は load_rakefile 時に評価された行です。たとえば以下のような bar.rake があるとき、 task :run に渡されたブロック以外は load_rakefile 時に実行されるのでその時点でカバーされます。)

lib/tasks/bar.rake
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_invokedfalse のときは219行目で execute が行われています。

https://github.com/ruby/rake/blob/v13.3.1/lib/rake/task.rb#L185-L227

このフラグを初期化するために reenable というメソッドが用意されています。

https://github.com/ruby/rake/blob/v13.3.1/lib/rake/task.rb#L145-L150

ただし、reenableinvoke が実行する事前タスクたち(prerequisites) のフラグは下ろさないため、テストのためにはそれら事前タスクも reenable する必要があります。

つまり以下を行うように spec_helper.rb を整えてみましょう。

  • load_rakefile は一度きりにする
  • テスト対象のタスクとその事前タスクたち全てを reenable する
spec_helper.rb
# 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 を使っているからとは言えるかも。

https://github.com/ruby/rake/blob/v13.3.1/lib/rake/rake_module.rb#L27-L30

みなさんも rake テストのカバレッジを見直してみてください。この現象と並行テストが絡んでいると、カバレッジが実行ごとに揺れたりしてかなり不思議なことになります。

ポイント: coverage は load を行うとその時点までのカバレッジ収集状況を破棄するっぽい

ここまでの調査により、重要なポイントは「coverage は load を行うとその時点までのカバレッジ収集状況を破棄するっぽい」ということがわかってきました。 rake の問題というよりは load の使い方により起こるっぽい。rake を使わずに実験してみましょう。たとえば以下のような場合、load ごとにそれまでのカバレッジが失われます。これを require にするとカバレッジは保持されました。

rake以外の例
# 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

参考記事リンク集

Discussion