CTOの藤村です。最近はぜんぜんRailsを書いていません。ふとSuggestion: Use string literals instead of named routes or URL helpers in tests · Issue #328 · rubocop/rails-style-guideというIssueを見て、2年ほど前にやったことを思い出したので、今更ながらブログを書くことにしました。
テストコードはNamed Routesを使うべきではない?
Railsでは routes.rb
で定義されたアクションへのパスやURLを出力するヘルパーが用意されています。posts_path
なようなメソッドたちですね。Named Routesと呼ばれるこれを使うことで、アプリケーションに存在しないURLを指定することを防げます。
アプリケーション側ではNamed Routesを使うべきですが、テストコードではどうでしょうか。少なくともRequest SpecやSystem Specといった現実を模倣するテストでは、実際に使われる状況に近い文字列リテラルを使うことが望ましいと考えられます(参考: Suggestion: Use string literals instead of named routes or URL helpers in tests · Issue #328 · rubocop/rails-style-guide)。具体的には post_path
よりも "/posts"
が望ましいです。これはまあコンセンサスと言ってもよいのではないでしょうか。
使われている。改善したい
しかし、既存のコードでRequest SpecでNamed Routesが使われているテストコードがそこそこの量ありました。書き換えたい、けど手でやるのは非常に面倒です。そこで私は思いつきました。かつてRSpecのテストコードを実行時に書き換えて実行速度を改善した話という記事に書いた、テストコード実行中にコードを書き換える手法がここにも適用できるのではないかと。
具体的にはこういうDiffを作れたら成功です。
diff --git a/spec/requests/api/user_spec.rb b/spec/requests/api/user_spec.rb index 47c6e51b20..b71216ac79 100644 --- a/spec/requests/api/user_spec.rb +++ b/spec/requests/api/user_spec.rb @@ -61,7 +61,7 @@ describe 'User APIs' do subject do post( - api_user_path, + "/api/user", params: request_params, headers: nil, )
ちょっとした試行錯誤の末これが実現できたので、ご紹介します。
着想
Named Routesの正体は Rails.application.routes.named_routes
にあるメソッドたちです。テストコードの中でこれらのメソッド(例えば posts_path
)が呼ばれたときに、呼び出している箇所のソースコードをその結果の値(例えば "/posts"
)に自動的に書き換えればよいはずです。これはNamed Routesのすべてのメソッドを動的にモンキーパッチすればできそうです。
できた
コード全体はこうなりました。 Rails.application.routes.named_routes.helper_names.each
あたりのNamed Routes全件モンキーパッチが肝なので、追って解説します。
module RewriteNamedRouteToStringLiteral def self.included(base) base.before :all, type: :request do # Named Routesを全部モンキーパッチ Rails.application.routes.named_routes.helper_names.each do |method_name| eval <<~RUBY def integration_session.#{method_name}(*args) path, loc, = caller[2].split(":") name = "#{method_name}" r = super p [path, loc, "#{method_name}", r] replace_line path, loc.to_i, name, r r end RUBY end # ファイル書き換え用のヘルパーメソッド def integration_session.replace_line(path, loc, from, to) content = File.read(path) new_content = content.each_line.with_index.map { |line, i| if i + 1 == loc line.gsub( / #{from}(\([a-z0-9_ ,:{}.]*\)){0,}/, %( "#{to}") ) else line end }.join('') File.write(path, new_content) end end end end
実行方法は以下のように環境変数が設定されているときにモンキーパッチがトリガーされるようにしました。
if ENV.key?('REWRITE_NAMED_ROUTE_TO_STRING_LITERAL') config.include RewriteNamedRouteToStringLiteral, type: :request end
モンキーパッチの詳細
モンキーパッチ部分のコードを細かめに解説します。
当初の想定とは違うことがありました。Request SpecではNamed Routesは integration_session
に生えているようです。ということで eval
を使って integration_session
に生えているNamed Routesメソッドたちを書き換えていきます。
Rails.application.routes.named_routes.helper_names.each do |method_name| eval <<~RUBY def integration_session.#{method_name}(*args)
Named Routeの書き換えのために必要は材料は4つ。1) 呼び出しファイルパス 2) 呼び出し行数 3) Named Routeのメソッド名 4) Named Routeの実際の値 です。これらを手に入れます。
file_path, loc, = caller[2].split(":") # 1), 2) route_name = "#{method_name}" # 3) value = super # 4)
まずは 1) と 2)、これは caller
で取れるので簡単です。次の 3) は工夫が必要です。eval
元で定義されたメソッド名の値を、eval
先で使えるようにする必要があります。私が取った方法は「文字列リテラルにする」でした。あとから気がついたんですが __method__
でもいけますね。こんな複雑なことする必要なかった。最後の 4) は super
で取れます。
あとは replace_line
にファイルパス、行数、メソッド名、実際の値を渡すと、その場所のメソッド呼び出しが実際の値に書き換わります。
replace_line file_path, loc.to_i, route_name, value value end
諦めたこと
post_path(post_id)
の場合はどうするのでしょうか?これは諦めました。 "/posts/43"
などのようにテスト実行時の値が含まれたリテラルに書き換わってしまいます。がんばれば "/posts/#{post_id}"
にできたかもしれませんが、そこまでやる気は起こらず…。実際の修正作業では、書き換え結果をそのままコミットするわけではなく、変更を目で確認します。その時にdiffを見ながら変数を文字列リテラルに埋め込んでいく作業はそこまで脳のコストを使わないと感じたので、この不完全な問題解決でも、まあ役には立つと判断しました。
所感とまとめ
この対応を試みた時点でRequest SpecでNamed Routesを使っている箇所は600ほどあったので、それを半自動的に書き換えられたのは大きな手数の削減になったと思います。「諦めたこと」にあるように手作業は残りましたが、それは認知コスト低めで実行できたので、まあよいトレードオフだったのではないかと。実装面ではメタプログラミングらしいメタプログラミングをしたので気分が良かったです。また、実行時のコード書き換えはポテンシャルのある技法だなと思いました。何か大量のコードを直さないといけない場合は是非この手法を試してみてください。