壊れたルーティングの検出、routing specを自動化するroute_mechanic
gem を作って公開しました。この gem の紹介と内部実装の話を書きます。
背景
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 です。
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.
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.routes
でActionDispatch::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_generates
は Rails が提供する 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
先行事例
作成前に検討した先行事例たちです。
いずれも内部実装は参考にさせてもらいつつ、ユースケースを絞り込むのにも役立たせてもらいました。
- やりたいことに最も近かったが、rake task ではなく RSpec や minitest で検証したかった
テストを自動生成する gem です。
- controller 起点なので Missing action methods を検出できない
RAILS_ENV=test rails generate regressor:controller
を毎回 routes を足すたびに実行しないといけないshoulda-matchers
,faker
に依存している- gem 自体に routing spec 以外の用途もあるので too much
- [minor] RSpec 限定
- dead project
- routes 側から検証しているので Missing routes を検出できない
- rake task ではなく RSpec や minitest から検証したかった
ruby-jp
開発の中途で悩んでいるとき、ruby-jp Slack コミュニティに助言やフィードバックを貰えて大変助かりました。
2020年9月ぐらいに話していたのでログは既に流れてしまったかと思いますが、改めて感謝の意を示させてください。
おわり
フィードバックやバグレポートやスターお待ちしています。
This article is for ohbarye Advent Calendar 2020.