valid,invalid

関心を持てる事柄について

壊れたルーティングを検出する route_mechanic gem と、その内部実装の話

壊れたルーティングの検出、routing specを自動化するroute_mechanic gem を作って公開しました。この gem の紹介と内部実装の話を書きます。

rubygems.org

背景

Rails 開発者のうちの N% は、Rails application のルーティングを検証するために以下のようなコードを書いたことがあるかもしれません。

Rails が提供する assertions を使うなら:

assert_routing({ path: 'photos', method: :post }, { controller: 'photos', action: 'create' })

rspec-rails なら:

expect(:get => "/articles/2012/11/when-to-use-routing-specs").to route_to(
  :controller => "articles",
  :month => "2012-11",
  :slug => "when-to-use-routing-specs"
)

はい。

検証したいことはわかるが、ルーティングを増やすたびに似たようなコードをほぼコピペで足していく作業は苦痛でやりたくない。似たようなコードをほぼコピペしているなら自動化できるのでは?ということで試みた顛末が本稿です。

※ そもそも routing specs は必要か?という論点については後述します。

RouteMechanic

できたものがroute_mechanic gem です。

github.com

gem を足したあとにテストを 1 つ書いておけば、RouteMechanic がぶっ壊れたルーティングを検出します。

gem を install し、RouteMechanic が提供する have_valid_routes matcher / assertion を使ったテストを 1 つ足すだけです。

RSpec の例

RSpec.describe 'Rails.application', type: :routing do
  it "fails if application does not have valid routes" do
    expect(Rails.application).to have_valid_routes
  end
end

miniTest の例

class RoutingTest < Minitest::Test
  include ::RouteMechanic::Testing::Methods

  def test_that_application_has_correct_routes
    assert_all_routes
  end
end

どんな風に検出するか?

上述の RSpec の例で、テスト失敗時には以下のようなエラーメッセージを出力します。

  0) Rail.application fails if application does not have valid routes
     Failure/Error: expect(Rails.application.routes).to have_valid_routes

       [Route Mechanic]
         No route matches to the controllers and action methods below
           UsersController#unknown
         No controller and action matches to the routes below
           GET    /users/:user_id/friends(.:format) users#friends
           GET    /users(.:format)                  users#index
           DELETE /users/:id(.:format)              users#destroy
     # ./spec/rspec/matchers_spec.rb:8:in `block (2 levels) in <top (required)>'

1 examples, 1 failure, 0 passed

RouteMechanic は 2 種類の「壊れたルーティング」を検出します。

1. Missing routes

controller と action method (controller 内の public method) があるが、routes.rbに対応する設定が記述されていないもの

2. Missing action methods

routes.rbで宣言しているが、対応する action method が存在しないもの

もし Missing routes と Missing action methods を個別に検証したければ以下の matchers, assertions を使うこともできます。

RSpec.describe 'Rails.application', type: :routing do
  it "fails if application has unused action methods" do
    expect(Rails.application).to have_no_unused_actions
  end

  it "fails if application has unused routes" do
    expect(Rails.application).to have_no_unused_routes
  end
end
class RoutingTest < Minitest::Test
  include ::RouteMechanic::Testing::Methods

  def test_that_application_has_no_unused_actions
    assert_no_unused_actions
  end

  def test_that_application_has_no_unused_routes
    assert_no_unused_routes
  end
end

routing spec は必要か?

合理的な質問です。大部分の Rails 開発者は request spec を書いていて、routing spec を書いたり自動化することに何の価値があるのか疑問に感じているのではと推察します。

個人的にも request spec や system test のようなレイヤでルーティングのテストをカバーできているのであれば routing spec は不要と考えています。

RSpec Rails のサイトでも以下のように述べられています。

Simple apps with nothing but standard RESTful routes won't get much value from routing specs, but they can provide significant value when used to specify customized routes, like vanity links, slugs, etc.

https://relishapp.com/rspec/rspec-rails/docs/routing-specs

RouteMechanic が有用なシーン

routing spec 自体について概ね不要との見解を持ちつつも、RouteMechanic が有用なシーンをいくつか思いつくことができます。

1. プロジェクトが古くなってきて、どのルートが生きていてどのルートが死んでいるのか誰にもわからなくなってきたとき

RouteMechanic でデッドコードを検出することができます。

2. アプリケーションに十分な request spec (それどころか controller spec も) がないとき

ルーティングが有効であることを確認するためのテストを増やすための良い出発点になります。個別にテストを足すのに比べたら RouteMechanic の導入のほうが容易です。

3. routes.rb の大きなリファクタリングをしようとしているとき

すべての request / controller specs を実行するのには時間がかかるので、routing のみをテストできれば小さいサイクルでの検証が可能になります。

Rails6.1 でroutes.rbをファイル分割できるようになったので、リファクタリングの機会はありそうです。*1

4. 何かの圧力で routing spec を書かざるを得ないとき

そういうこともあるでしょう

内部実装の話: どうやって「壊れた routes」を検出するか

RouteMechanic がどうやって Missing routes と Missing action methods を検出しているか?について、余談です。

あらためて言葉の定義を再掲します。

1. Missing routes

controller と action method (controller 内の public method) があるが、routes.rbに対応する設定が記述されていないもの

2. Missing action methods

routes.rbで宣言しているが、対応する action method が存在しないもの

基本的には「controller で定義している action methods の一覧」と「routes で定義されたルーティングの一覧」を比較しているだけなので、両者をどのように得ているかを説明します。

controllers, action methods の一覧

まずApplicationController.descendantsにより、ApplicationControllerを継承している class の配列を得ます。

:memo: ただしRAILS_ENV=testでは eager_load がデフォルトではfalseになっているのでテストの実行順序によってはApplicationController.descendantsは空の配列になってしまいますので、eager_load ぽいことを事前にやっておきます。

def eager_load_controllers
  load_path = "#{Rails.root.join('app/controllers')}"
  Dir.glob("#{load_path}/**/*.rb").sort.each do |file|
    require_dependency file
  end
end

controller class が得られれば action methods はかんたんに得られます。

controllers.each do |controller|
  controller.action_methods.each |action_method|
  end
end

routes で定義されたルーティングの一覧

まずRails.application.routesActionDispatch::Journey::Route (個別の route の情報を持っているオブジェクト) の配列を得ます。routes が読み込まれていなければ reload しておきます。

load_path = "#{Rails.root.join('config/routes.rb')}"
Rails.application.routes_reloader.paths << load_path unless Rails.application.routes_reloader.paths.include? load_path
Rails.application.reload_routes!

しかしながらこのままでは internal なものや、Rails がデフォルトで追加しているルーティングも含まれてしまいます。あくまでroutes.rbで定義されたものを対象にしたいのでこれらを除外しておきます。

def target_routes
  Rails.application.routes.routes.reject do |journey_route|
    # Skip internals, endpoints that Rails adds by default
    # Also Engines should be skipped since Engine's tests should be done in Engine
    wrapper = RouteWrapper.new(journey_route)
    wrapper.internal? || !wrapper.defaults[:controller] || !wrapper.defaults[:action] || wrapper.path.start_with?('/rails/')
  end
end

これで routes で定義されたルーティングの一覧が得られました。

比較処理

両者の一覧の比較処理は特筆すべきところなく、それぞれを iterate してもう一方に存在しないルーティングをエラーとして記録していくだけです。

def collect_unused_actions_errors(report_error)
  @controllers.each do |controller|
    controller_path = controller.controller_path
    controller.action_methods.each do |action_method|
      journey_routes = @routes.select do |route|
        route.defaults[:controller].to_sym == controller_path.to_sym && route.defaults[:action].to_sym == action_method.to_sym
      end

      if journey_routes.empty?
        @unused_actions_errors << { controller: controller, action: action_method } if report_error
      else
        wrappers = journey_routes.map { |r| RouteWrapper.new(r) }
        @controller_routes.concat(wrappers)
      end
    end
  end
end

def collect_unused_routes_errors(report_error)
  @routes.each do |journey_route|
    wrapper = RouteWrapper.new journey_route
    @config_routes << wrapper

    matched_controller_exist = @controller_routes.any? do |w|
      wrapper.controller == w.controller && wrapper.action == w.action && wrapper.path == w.path
    end

    @unused_routes_errors << wrapper if !matched_controller_exist && report_error
  end
end

念の為assert_generates

上述の処理で Missing routes も Missing action methods も検出できるのですが、さらに、テスト対象のルーティングが valid なものかどうかをassert_generatesで検証しています。

assert_generatesRails が提供する assertion で、与えた options で期待した URL が生成されるかを検証するものです。

# こんな定義がroutes.rbにあれば
get '/photos/:id', to: 'photos#show'

# こんな感じで検証する
assert_generates '/photos/1', { controller: 'photos', action: 'show', id: '1' }

ルーティングの制約を満たす valid な options を自動生成する、というのが地味に厄介なところでした。

上述の:idのような例であればてきとうに'1'とか与えておけばよいのですが、現実世界のroutes.rbには以下のように正規表現で制約を設けていたりします。

例えば以下の例では:locale'en''ja'でなければならないし、:idは大文字のアルファベット+ 5 桁の数字でなければいけません。

scope ':locale', locale: /en|ja/ do
  get '/photos/:id', to: 'photos#show', constraints: { id: /[A-Z]\d{5}/ }
end

正規表現からマッチする文字列を生成する… 想像しただけでけっこう骨が折れそうだったのですがさすが Ruby のエコシステム、すでに要求を満たす gem が存在しました。

regexp-examples を使うことで事なきを得ました。以下のコードにより、assert_generatesテストの自動生成ができました。

# @param [RouteMechanic::Testing::RouteWrapper] wrapper
# @raise [Minitest::Assertion]
def assert_routes(wrapper)
  required_parts = wrapper.required_parts.reduce({}) do |memo, required_part|
    dummy = if wrapper.requirements[required_part].is_a?(Regexp)
              wrapper.requirements[required_part].examples.last
            else
              '1'
            end
    memo.merge({ required_part => dummy }) # Set pseudo params to meets requirements
  end

  base_option = { controller: wrapper.controller, action: wrapper.action }
  url = routes.url_helpers.url_for(
    base_option.merge({ only_path: true }).merge(required_parts))
  expected_options = base_option.merge(required_parts)

  assert_generates(url, expected_options)
end

先行事例

作成前に検討した先行事例たちです。

いずれも内部実装は参考にさせてもらいつつ、ユースケースを絞り込むのにも役立たせてもらいました。

traceroute

  • やりたいことに最も近かったが、rake task ではなく RSpec や minitest で検証したかった

regressor

テストを自動生成する gem です。

  • controller 起点なので Missing action methods を検出できない
  • RAILS_ENV=test rails generate regressor:controllerを毎回 routes を足すたびに実行しないといけない
  • shoulda-matchers, fakerに依存している
  • gem 自体に routing spec 以外の用途もあるので too much
  • [minor] RSpec 限定

route_tractor

  • dead project
  • routes 側から検証しているので Missing routes を検出できない
  • rake task ではなく RSpec や minitest から検証したかった

ruby-jp

開発の中途で悩んでいるとき、ruby-jp Slack コミュニティに助言やフィードバックを貰えて大変助かりました。

2020年9月ぐらいに話していたのでログは既に流れてしまったかと思いますが、改めて感謝の意を示させてください。

おわり

フィードバックやバグレポートやスターお待ちしています。

github.com


This article is for ohbarye Advent Calendar 2020.