ビューのテストをしていてjpmobile 1.0.4ではまった話

とあるコードにjpmobileを組み込んでみることにした。Gemfileを更新してbundle install。まずはテストが通ることを確認しておこうとrake specしたところ、盛大にコケた。jpmobileを外せば、もと通り、すべてのテストにパスする状態に戻る。

結果をよくみてみるとspec/viewsでばかりエラーが起きていることがわかる。だが、すべてのテストでエラーが起きているわけでもない。さらによくみてみると、個々のエラーにはあまり違いがない。どうやら(なぜだか)ヘルパーメソッドを見失っているようだ。どうしてこんなことに?

  1) hello/world.html.erb
     Failure/Error: render
     ActionView::Template::Error:
       undefined method `hello' for #<#<class:0x7f4523d972a8>:0x7f4523d950c0>
     # ./app/views/hello/world.html.erb:1:in `_app_views_hello_world_html_erb___308868950_69967465444700_0'
     # ./spec/views/hello/world.html.erb_spec.rb:7
作っているアプリケーションで妙なことをしていただろうか。だがscaffoldしたのとそう変わりないコードでも現象が発生している。さて困ったなと、しばしあれやこれやしていたところ、現象が起きている部分についていくつかのことに気付いた。 どうやら以下の二つのケースでヘルパーメソッドを見失っているようだ。
  • 他のヘルパーファイルに書かれたメソッドを使っている(FooControllerのビューからBarHelperを使う)
  • ビューファイルが階層化されている(Foo::BarControllerに対するapp/views/foo/bar/baz.html.erb)
そこでこれらをもとに再現コードを作ってみることにした。

検証0 - 準備

rails new -Tでスケルトンを作り、RSpecの準備をする。そしてrails g controller hellow worldとしておく。コントローラのコードは空のままでよい。そして、以下のビューとヘルパーを作る。 app/views/hello/world.html.erb:
<span id="hello"><%= hello :world %></span>

<span id="is-mobile"><%=
  if request.respond_to?(:mobile?)
    request.mobile?
  else
    'unknown'
  end
%></span>

app/helpers/hello_helper.rb:

module HelloHelper
  def hello(msg)
    msg.to_s
  end
end
次にテストを書く。 spec/views/hello/world.html.erb_spec.rb:
require 'spec_helper'

describe "hello/world.html.erb" do
  it '' do
    controller.request.env['HTTP_USER_AGENT'] = 'DoCoMo'

    render

    assert_select 'span#hello', :text => 'world'
    assert_select 'span#is-mobile', :text => 'true'
  end
end
まずはjpmobileを組み込まない状態でテストを走らせる。するとこのテストは失敗する。というのはjpmobileを組み込んでいないためにrequest.mobile?を使用できないからだ。これは正しい失敗である。
Failures:

  1) hello/world.html.erb 
     Failure/Error: assert_select 'span#is-mobile', :text => 'true'
     Test::Unit::AssertionFailedError:
       <"true"> expected but was
       <"unknown">.
       <false> is not true.
     # (eval):2:in `send'
     # (eval):2:in `assert'
     # ./spec/views/hello/world.html.erb_spec.rb:10

Finished in 0.02612 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/views/hello/world.html.erb_spec.rb:4 # hello/world.html.erb 
jpmobileを組み込んでからテストを走らせればこのテストは通るはずだ。
Finished in 0.05688 seconds
1 example, 0 failures

検証1 - よそのヘルパーを使う

ここまでは問題が起きないケースである。問題が起きるケースその一を再現してみる。まず、HelloHelperで定義しているhelloヘルパーをBazHelperに移動する。 app/helpers/hello_helper.rb:
module HelloHelper
end
app/helpers/baz_helper.rb:
module BazHelper
  def hello(msg)
    msg.to_s
  end
end
この移動は動作に影響を与えないはずだ(コントーラで使用するヘルパーを指定していなければ)。まず、jpmobileのない状態で試す。すると、最初に試したのと同じエラーになるのを確認できる。
  1) hello/world.html.erb 
     Failure/Error: assert_select 'span#is-mobile', :text => 'true'
     Test::Unit::AssertionFailedError:
       <"true"> expected but was
       <"unknown">.
       <false> is not true.
次にjpmobileを組み込でテストを走らせる。
  1) hello/world.html.erb 
     Failure/Error: render
     ActionView::Template::Error:
       undefined method `hello' for #<#<Class:0x7f5fb6c3a880>:0x7f5fb6c380f8>
     # ./app/views/hello/world.html.erb:1:in `_app_views_hello_world_html_erb___308868950_70024532432920_0'
     # ./spec/views/hello/world.html.erb_spec.rb:7
おっと。 ではHelloHelperにBazHelperをincludeするとどうなるだろうか。 app/helpers/hello_helper.rb:
module HelloHelper
  include BazHelper
end
今度はテストが通ることを確認できる。
Finished in 0.05754 seconds
1 example, 0 failures

検証2 - 階層化コントローラ(正確にはそのケースのビュー)

では、次。もう一つのケースの再現をしてみる。ここまでで作ったapp/views/hello/world.html.erbおよびapp/helpers/hello_helper.rbと同じような内容で以下を作成する。 app/views/foo/bar/world.html.erb:
<span id="hello"><%= hello2 :world %></span>

<span id="is-mobile"><%=
  if request.respond_to?(:mobile?)
    request.mobile?
  else
    'unknown'
  end
%></span>

app/helpers/foo/bar_helper.rb:

module HelloHelper
  def hello2(msg)
    msg.to_s
  end
end

先のコードも残しているので、ヘルパーメソッド名だけ変えておいた。今度もjpmobileを組み込まずに実行することから始める。

Failures:

  1) foo/bar/world.html.erb 
     Failure/Error: assert_select 'span#is-mobile', :text => 'true'
     Test::Unit::AssertionFailedError:
       <"true"> expected but was
       <"unknown">.
       <false> is not true.
     # (eval):2:in `send'
     # (eval):2:in `assert'
     # ./spec/views/foo/bar/world.html.erb_spec.rb:10

Finished in 0.02634 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/views/foo/bar/world.html.erb_spec.rb:4 # foo/bar/world.html.erb 

やはり、正しくテストに失敗することを確認できる。次にjpmobileを組み込んでもう一度テストを走らせる。

Failures:

  1) foo/bar/world.html.erb 
     Failure/Error: render
     ActionView::Template::Error:
       undefined method `hello2' for #<#<class:0x7f031f457698>:0x7f031f455730>
     # ./app/views/foo/bar/world.html.erb:1:in `_app_views_foo_bar_world_html_erb___723250535_69825693119060_0'
     # ./spec/views/foo/bar/world.html.erb_spec.rb:7

Finished in 0.05721 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/views/foo/bar/world.html.erb_spec.rb:4 # foo/bar/world.html.erb

再び同じ結果となった。ただし、このケースではヘルパーメッソドの移動をしていない。それこそscaffoldしただけのコードであってもこの現象が発生する。

謎解き

……は、実はできていない。そこまで追い込めていない。

が、どうやらライブラリのロードのタイミングに関係しているようだということはわかった。これは、以下の内容のjpmobile-1.0.4/lib/jpmobile/requestwithmobile.rbを空にしてしまうことにより、現象が発生しなくなることから言える。

require 'action_controller/test_case'
ActionController::TestRequest.send :include, Jpmobile::RequestWithMobileTesting

もちろん、単にこのファイルを空にするだけでは、テスト中にjpmobileの機能を使えなくなってしまう。jpmobileを組み込んでいるのにも関わらず、前述の「正しい失敗」になる。それでは意味がない。

たしかなことは言えないがもともとの想定よりも早いタイミングでactioncontroller/testcaseがロードされるのが要因のように思える。そうであるならば、同ファイルがロードされる本来のタイミングの後でJpmobile::RequestWithMobileTestingをincludeしてやればよいのではないだろうか。

そこでチャレンジしてみた。(ここまででじょじょに本題からズレていってしまっている。)

小まわり回避

まず、問題(?)のファイルを読み込ませないようにするにはどうすればよいか。jpmobileのコードに手を入れるのも一つの方法である。だが、このファイルの読み込みは普通のrequireで行われている(jpmobile-1.0.4/lib/jpmobile/rails.rb)。それなら同名のファイルをロードパスのより先頭に近い場所に置いてやればよい。

次にincludeをどう実現するか。あくまでActionController:TestRequestが自然に定義された後でincludeするのでなければならない。フックを使ってできないだろうか。

この二点から、次の内容のファイルをlib以下に作成した。

lib/jpmobile/hooktestrequest.rb:

module TonbiHook
  def inherited(subcls)
    super
    subcls.send :include, Jpmobile::RequestWithMobileTesting
  end
end

class ActionDispatch::TestRequest
  extend TonbiHook
end

ActionController::TestRequestがActionDispatch::TestRequestを継承しているため、inheritedにより、その出現のタイミングを捕まえることができる。そのタイミングでincludeを実行している。効果はどうかというと、このコードがあることによりjpmobileを組み込んでいてもメソッド紛失は起きなくなることを確認できた。

Finished in 0.06083 seconds
2 examples, 0 failures

これにより解決としてよいか。さて、どうだろう。

jpmobile/hooktestrequest.rbにより実行されるコードはテスト用のクラスにjpmobileの機能を加えるものである。今回の再現で使ったテストが通るようになったということは、その機能が加えられているといえそうな気はする。テストではない通常の動作環境では、そもそもの現象が起きることはないはずで、その点ではこのコードの有無も影響しないはずである。……たぶん。

実際のアプリケーションでの動作確認はまだ行っている途中なので、実は違っていたというオチになるかもしれないが。