ビューのテストをしていて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.erbjpmobileを組み込んでからテストを走らせればこのテストは通るはずだ。
Finished in 0.05688 seconds 1 example, 0 failures
検証1 - よそのヘルパーを使う
ここまでは問題が起きないケースである。問題が起きるケースその一を再現してみる。まず、HelloHelperで定義しているhelloヘルパーをBazHelperに移動する。 app/helpers/hello_helper.rb:module HelloHelper endapp/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の機能を加えるものである。今回の再現で使ったテストが通るようになったということは、その機能が加えられているといえそうな気はする。テストではない通常の動作環境では、そもそもの現象が起きることはないはずで、その点ではこのコードの有無も影響しないはずである。……たぶん。
実際のアプリケーションでの動作確認はまだ行っている途中なので、実は違っていたというオチになるかもしれないが。