STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

Railsのテストコードで使われているNamed Routesを実行時に文字列に直した話

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ほどあったので、それを半自動的に書き換えられたのは大きな手数の削減になったと思います。「諦めたこと」にあるように手作業は残りましたが、それは認知コスト低めで実行できたので、まあよいトレードオフだったのではないかと。実装面ではメタプログラミングらしいメタプログラミングをしたので気分が良かったです。また、実行時のコード書き換えはポテンシャルのある技法だなと思いました。何か大量のコードを直さないといけない場合は是非この手法を試してみてください。