Rails 4 + gonでときおりcontrollerのテストが失敗する問題への対応

こんにちは、Misoca開発チームのmzpです。 先週は友人の家に泊り込んでWWDCのライブストリームを見ていました。

MisocaではRailsJavaScriptでの値の共有にgonを利用していますが、ときおりcontrollerのテストが失敗するという奇妙な現象に遭遇しました。今日は、その話について書きたいと思います。

要約

テストケースごとに Gon.clear を呼べば解決する。

もうちょっと長い要約

  • gonはrequest store gemを使って値を保存する
  • controllerのテスト内では、request storeは予期した動作をしない
  • 明示的に Gon.clear を呼べば回避できる

gonとは

gonはRailsJavaScriptで値を共有するためのgemです。

具体的には、Rails

gon.user_role = "admin"

とのようにすると、JavaScript

gon.user_role // =>  "admin"

のように設定した値を取得できます。

どのように実現しているか

gonを利用するには、Viewのどこか(通常は app/views/layouts/application.html.erb)に以下のようなコードを書く必要があります。 ここで、gon オブジェクトへの値の設定を行なっています。

<%= Gon::Base.render_data %>

viewとcontrollerの間での値の共有は、RequestStoreというgemで実現されています。 これはグローバル変数のように、どこからでもアクセスできる領域を実現するためのgemです。

利用例:

def index
  RequestStore.store[:foo] ||= 0
  RequestStore.store[:foo] += 1

  render :text => RequestStore.store[:foo]
end

gonはこのrequest storeに値を保持することで、controllerとviewの間でデータのやりとりをしています。

RequestStoreの仕組み

RequestStoreは Thread#[] を利用したスレッドローカルなデータとほぼ同じです。

module RequestStore
  def self.store
    Thread.current[:request_store] ||= {}
  end

  def self.clear!
    Thread.current[:request_store] = {}
  end
 
  # snip

ただし、同一スレッド間別リクエストで値が保持されないようにRackのミドルウェアにてデータのクリアをしています。

module RequestStore
  class Middleware
    def initialize(app)
      @app = app
    end

    def call(env)
      @app.call(env)
    ensure
      RequestStore.clear!
    end
  end
end

なにが問題になるか

controllerのテストはRackミドルウェアを経由していないため、予期したタイミングでデータがクリアされません。

そのため、以下のようなコードはproduction/development環境で動作する限りは問題ないですが、controllerのテストは失敗します。

class FooController < ApplicationController
  def foo
    # gonに不明なデータが残っていた場合は失敗させる
    fail "oops" if gon.some_data

    gon.some_data = 1
    render text: 'ok'
  end

  def bar
    # gonに不明なデータが残っていた場合は失敗させる
    fail "oops" if gon.some_data

    gon.some_data = 2
    render text: 'ok'
  end
end
require 'test_helper'

class FooControllerTest < ActionController::TestCase
  test "should get foo" do
    get :foo
    assert_response :success
  end

  test "should get zar" do
    get :bar
    assert_response :success
  end
end
# それぞれはテストを通る
$ TESTOPTS="-n /foo/" bundle exec rake test
Run options: -n /foo/ --seed 49313

# Running tests:

.

Finished tests in 0.019805s, 50.4923 tests/s, 50.4923 assertions/s.

1 tests, 1 assertions, 0 failures, 0 errors, 0 skips

$ TESTOPTS="-n /bar/" bundle exec rake test
Run options: -n /bar/ --seed 7168

# Running tests:

.

Finished tests in 0.018819s, 53.1378 tests/s, 53.1378 assertions/s.

1 tests, 1 assertions, 0 failures, 0 errors, 0 skips

# まとめて実行すると失敗する
$ bundle exec rake test
Run options: --seed 38769

# Running tests:

..

Finished tests in 0.020623s, 96.9791 tests/s, 96.9791 assertions/s.

2 tests, 2 assertions, 0 failures, 0 errors, 0 skips

この例は比較的わかりやすいですが、実際はgonを使っているアクションと使っていないアクションがまざっていたり、テストケースの実行順をランダムにしていたりするので、たまに失敗する一見不可解な事象が発生します。

解決策

明示的に Gon.clear をすれば問題を回避できます。

require 'test_helper'

class FooControllerTest < ActionController::TestCase
  # snip
  def teardown
    Gon.clear
  end
end

これで、テストケースの実行順に依存して成功したり、失敗したりするようなことはなくなりました。