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を使うもっと色々できそうなので、それもいつか。
-
ApplicationRecord.connection.execute('ALTER TABLE shops AUTO_INCREMENT = 1')
をafterで実行することで解決できます。できますが、やりたくはないですね。↩