STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

RSpecのテストコードを実行時に書き換えて実行速度を改善した話

CTOの藤村です。つい最近まで STORES ブランドアプリ のチームでRailsを書いていました。

STORES ブランドアプリ のRailsリポジトリではdatabase_cleanerを(strategy = truncationで)使ってテスト中のデータベースをリセットしており、このことがテストコードの品質、速度などで重荷となっていました。

これを、テスト実行時にテストコード自体を書き換えて改善する仕組みを作り、先日無事Transactional Testへの移行が完了しました。ということで気分がとてもよいので、どうやったか共有させてください。

課題

STORES ブランドアプリのRailsのテストコードは速度に課題がありました。

テストデータを片付ける仕組みとして、 Railsエンジニアにはお馴染みのdatabase_cleanerというGemを使っていました。database_cleanerには実行するstrategyが2つあります。一つが truncation 、teardownなどの節目で各テーブルをTRUNCATEするやつ、もう一つが transaction、テストコードをトランザクション内で実行しROLLBACKするやつです。後者のほうが高速です。

STORES ブランドアプリでは全面的にtruncationを使っていて、これがテストコード実行が遅くなる原因となっていました。

直したいけど手動は辛い

当然、これらの課題は改善していきたいです。まずは transaction でも通るテストは transaction で実行する、というところから改善していきます。

全部 strategy = :transaction でやるとどうなる

最初に、全部 strategy = :transaction にしてテストを実行してみました。結果、値がハードコードされている箇所を中心にセットアップしたデータが想定と違うものになってしまい、半分くらい(たしか)のテストケースが落ちるようになってしまいました1。まあそうですよね。

地道に直すことを試みる

ということで、地道に直すことを試みます。

下記の設定を追加して、Example/Example Groupにuse_transactional_fixtures: true をつけると strategy = :transaction でテストされる仕組みを導入しました。

RSpec.configure do |config|
  # ...
  config.around(:each) do |example|
    strategy =
      if example.metadata[:use_transactional_fixtures] || config.use_transactional_fixtures
        :transaction
      else
        :truncation
      end

    DatabaseCleaner.strategy = strategy
    DatabaseCleaner.clean_with strategy

    DatabaseCleaner.start

    example.run
  ensure
    DatabaseCleaner.clean
  end
  # ...
end             

やってみると(やらなくても想像がつきそうなものですが)、一個一個 use_transactional_fixtures をつけたり外したりして実行するのが大変面倒です。

自動でやるぞ!

やっているうちに、strategy = :transaction でテストが通るなら、 use_transactional_fixtures: trueを自動でつけてほしいな!と思いました。

下記のことができればオッケーなはず。

  • strategy = :transaction でExampleを実行
  • 通ったら it "foo, bar" do -> it "foo, bar", use_transactional_fixtures: true do に書き換える

RSpecにはReporterという仕組みがある

まずは「通ったら」をトリガーになにかをする方法を見つけたいです。おそらく仕組みがあるだろうなと思い調べていたら、RSpec::Core::Reporterというのがありました。

あまりドキュメントやサンプルコードがないですが勘でいじくってみます。結果、これを使うとテスト実行中のさまざまなイベントをトリガーに任意のコードを実行できることがわかりました。たとえばExample/Example Groupが完了したときに何かをするには下記のようなコードを追加すればよいです。

class SomeListener
  def example_group_finished
    # do something
  end
end

RSpec.configure do |config|
  config.reporter.register_listener SomeListener.new, :example_group_finished
end

テストが通ったらテストコードを書き換える

仕組みは見つけたので、Reporterを使って strategy = :transaction でテストが通ったらExampleにuse_transactional_fixtures: true をつけるようにします。

Example Groupのuse_transactional_fixtures付け替え君を作り、 環境変数 TRANSACTIONAL_FIXTURES_REWRITE_CHALLENGE を設定してテストを実行したときはこれを仕込んで、it "..." do を書き換えるようにしました。

# 付け替え君
class SetUseTransactionalFixturesFlagToExampleGroup
  def example_group_finished(group_notification)
    group = group_notification.group

    # なんと、たまにExampleがないExample Groupがある
    return if group.examples.none?

    # 通ったかな?
    use_transactional_fixtures = group.examples.all? {|e| e.execution_result.status == :passed } 

    # Exampleのあるファイルを読む
    content = File.read(group.file_path)
    
    # 中身を更新
    content = content.each_line.map.with_index(1) {|line, i|
      # `line_number` が `it "..." do` の行
      if i == group.metadata[:line_number].to_i
        # 正規表現でがんばって書き換える
        line
          .sub(/, use_transactional_fixtures: (false|true)+/, '')
          .sub(/^(.*) do(\s{1,})\Z/, "\\1, use_transactional_fixtures: #{use_transactional_fixtures} do\\2")
      else
        line
      end
    }.join

    # ファイルに書く
    File.write(group.file_path, content)
  end
end         
RSpec.configure do |config|
  # 環境変数があった時だけListenerを仕込む
  if ENV.key?('TRANSACTIONAL_FIXTURES_REWRITE_CHALLENGE')
    config.reporter.register_listener SetUseTransactionalFixturesFlagToExampleGroup.new, :example_group_finished
  end       
end

下記のように実行すると、 strategy = :transaction で通ったテストに use_transactional_fixtures: true がつきます。

$ TRANSACTIONAL_FIXTURES_REWRITE_CHALLENGE=1 bin/rspec spec/models/shop_spec.rb
# ...
$ git diff
diff --git a/spec/models/shop_card_spec.rb b/spec/models/shop_card_spec.rb
index 11b9f297a..c85a770de 100644
--- a/spec/models/shop_card_spec.rb
+++ b/spec/models/shop_card_spec.rb
@@ -3,7 +3,7 @@
 require "rails_helper"

 describe ShopCard do
-  it "has valid factory" do
+  it "has valid factory", use_transactional_fixtures: true do
     expect(build(:shop_card)).to be_valid
   end

これをすべてのテストコードのあるファイルで実行すれば「transactionで通るテストはtransactionで実行する」状態になるはず。

やりきった!

まずはディレクトリごとに上記をかけて、テストコード全体を「transactionで通るテストはtransactionで実行する」ようにする、その後、通らないテストを地道に直す、というのをやった結果、ついにすべてのテストケースでstrategy = :transaction で通るようになりました。

速くなった?

速くなりました。改めて手元でstrategyを切り替えながら実行してみると、そこそこ長いUserモデルのSpecでざっくり倍くらいの速度改善が得られたようです。

truncation で実行:

$ docker-compose exec app bin/rspec spec/models/user_spec.rb

Randomized with seed 4607

(中略)

Finished in 4 minutes 29.9 seconds (files took 9.27 seconds to load)
151 examples, 0 failures

transaction で実行:

$ docker-compose exec app bin/rspec spec/models/user_spec.rb

Randomized with seed 4607

(中略)

Finished in 2 minutes 10.3 seconds (files took 9.9 seconds to load)
151 examples, 0 failures

まとめと今後

テストが速くなって嬉しいです。また、今回やったような「実行時に実行結果をもとにコードを改善する」という、自己修復するコードとでも言うようなコンセプトは面白いなと思いました。強力な諸刃の剣ではありますが、その分可能性を秘めているという感触があります。機会があればまたやってみたいです。

また、RubyVM::AbstractSyntaxTreeを使うもっと色々できそうなので、それもいつか。


  1. ApplicationRecord.connection.execute('ALTER TABLE shops AUTO_INCREMENT = 1')をafterで実行することで解決できます。できますが、やりたくはないですね。