2019.8.21追記:Ruby 2.5からはこの問題が発生しなくなりました
Ruby 2.5からは定数探索のルールが変わったため、この記事で書いているような問題は発生しなくなりました。
参考:サンプルコードでわかる!Ruby 2.5の主な新機能と変更点 Part 1 - Qiita
Ruby 2.5環境だと、この記事で使用したテストケースはどちらも正常に実行されます。
# Ruby 2.5環境 + Rails 5.2で全specを実行した場合
$ bundle exec rspec
blogs_controller_spec.rb loaded
BlogsController loaded
staff/blogs_controller_spec.rb loaded
Staff model loaded
Staff::BlogsController loaded
Randomized with seed 46529
....
Finished in 0.09461 seconds (files took 3.17 seconds to load)
4 examples, 0 failures
Randomized with seed 46529
# Ruby 2.5環境 + Rails 5.2で個別にspecを実行した場合
$ bundle exec rspec ./spec/controllers/staff/blogs_controller_spec.rb
staff/blogs_controller_spec.rb loaded
Staff model loaded
Staff::BlogsController loaded
Randomized with seed 16532
..
Finished in 0.05565 seconds (files took 3.16 seconds to load)
2 examples, 0 failures
Randomized with seed 16532
参考用として、Ruby 2.5 + Rails 5.2で実行可能なサンプルコードを以下にアップしています。
ついでに書いておくと、Rails 6はRuby 2.5以上が必須になるので、自ずとこの問題は発生しなくなります。
以下はRails 6.0版のサンプルコードです。
(さらに言うと、Rails 6ではZeitwerkという新しいオートロードシステムが導入されたので、本記事の説明もRails 6では当てはまらない部分があります。Zeitwerk版のオートロードシステムに関する詳細はこちらのRailsガイドを参照してください)
というわけで、以下の本文は Ruby 2.4以前の環境で発生する事象 として読んでください。
TL;DR(最初に結論)
- モデルクラスの名前(
Admin
など)をネームスペースに流用する(たとえばAdmin::BlogsController
のようなクラスを作る)と、"warning: toplevel constant"という警告が発生し、エラーが起きる原因になります。 - もう少し厳密に言うと、
BlogsController
のような 同名のクラスがトップレベル(ネームスペース無し)に存在している場合にこの警告が出ます。 - 無用なトラブルの発生を防ぐため、ネームスペースは極力モデルクラスとかぶらない名前を付けてください(
Admins::BlogsController
など)
トラブルが発生するケース
ここではサンプルアプリケーションとして架空のブログアプリケーションを考えます。
仕様は以下の通りです。
- ブログは下書きとして保存することができる
- 一般ユーザーは下書きのブログが見えない
- スタッフは下書きのブログも見える
- 一般ユーザーは
http://localhost:3000/blogs
のようなURLにアクセスする - スタッフは
http://localhost:3000/staff/blogs
のようなURLにアクセスする
以下はこのアプリケーションのソースコードです。
(デバッグ用の puts を最初から仕込んであります)
モデル
class Blog < ActiveRecord::Base
scope :without_draft, -> { where(draft: false) }
end
class Staff < ActiveRecord::Base
puts "Staff model loaded"
end
ルーティング
Rails.application.routes.draw do
resources :blogs
namespace :staff do
resources :blogs
end
root to: 'blogs#index'
end
コントローラ
ここではindexとshowのみを記載します。
class BlogsController < ApplicationController
puts "BlogsController loaded"
before_action :set_blog, only: [:show, :edit, :update, :destroy]
def index
@blogs = Blog.without_draft.all
end
def show
end
private
def set_blog
@blog = Blog.without_draft.find(params[:id])
end
end
class Staff::BlogsController < ApplicationController
puts "Staff::BlogsController loaded"
before_action :set_blog, only: [:show]
def index
@blogs = Blog.all
end
def show
end
private
def set_blog
@blog = Blog.find(params[:id])
end
end
テストコード(コントローラスペック)
前述の仕様を検証するため、次のようなテストコードを書きました。
FactoryGirl.define do
factory :blog do
title "MyString"
content "MyText"
draft false
trait :draft do
draft true
end
end
end
require 'rails_helper'
puts "blogs_controller_spec.rb loaded"
RSpec.describe BlogsController, type: :controller do
let!(:blog) { create :blog, :draft }
describe 'GET #index' do
it '下書きのブログは含まれない' do
get :index
expect(assigns(:blogs)).to eq []
end
end
describe 'GET #show' do
it '下書きのブログは表示できない' do
expect { get :show, id: blog }.to raise_error ActiveRecord::RecordNotFound
end
end
end
require 'rails_helper'
puts "staff/blogs_controller_spec.rb loaded"
RSpec.describe Staff::BlogsController, type: :controller do
let!(:blog) { create :blog, :draft }
describe 'GET #index' do
it '下書きのブログも含まれる' do
get :index
expect(assigns(:blogs)).to eq [blog]
end
end
describe 'GET #show' do
it '下書きのブログも表示できる' do
get :show, id: blog
expect(assigns(:blog)).to eq blog
end
end
end
テストコードの実行結果(失敗)
上記のテストコードは問題なくパスするはずですが、なぜか以下のようなエラーが出て失敗しました。
$ bundle exec rspec
blogs_controller_spec.rb loaded
BlogsController loaded
staff/blogs_controller_spec.rb loaded
Staff model loaded
/(your-app)/spec/controllers/staff/blogs_controller_spec.rb:4: warning: toplevel constant BlogsController referenced by Staff::BlogsController
Randomized with seed 38796
FF..
Failures:
1) BlogsController GET #index 下書きのブログも含まれる
Failure/Error: expect(assigns(:blogs)).to eq [blog]
expected: [#<Blog id: 1, title: "MyString", content: "MyText", created_at: "2016-02-11 00:14:31", updated_at: "2016-02-11 00:14:31", draft: true>]
got: #<ActiveRecord::Relation []>
(compared using ==)
Diff:
@@ -1,2 +1,2 @@
-[#<Blog id: 1, title: "MyString", content: "MyText", created_at: "2016-02-11 00:14:31", updated_at: "2016-02-11 00:14:31", draft: true>]
+[]
# ./spec/controllers/staff/blogs_controller_spec.rb:9:in `block (3 levels) in <top (required)>'
2) BlogsController GET #show 下書きのブログも表示できる
Failure/Error: @blog = Blog.without_draft.find(params[:id])
ActiveRecord::RecordNotFound:
Couldn't find Blog with 'id'=1 [WHERE "blogs"."draft" = ?]
# ./app/controllers/blogs_controller.rb:68:in `set_blog'
# ./spec/controllers/staff/blogs_controller_spec.rb:14:in `block (3 levels) in <top (required)>'
Finished in 0.0915 seconds (files took 1.33 seconds to load)
4 examples, 2 failures
Failed examples:
rspec ./spec/controllers/staff/blogs_controller_spec.rb:7 # BlogsController GET #index 下書きのブログも含まれる
rspec ./spec/controllers/staff/blogs_controller_spec.rb:13 # BlogsController GET #show 下書きのブログも表示できる
Randomized with seed 38796
実行結果をよく見てみると、どちらも staff/blogs_controller_spec.rb
でエラーが発生しています。
そこで、このテストコードだけを単体で動かしてみることにしました。
$ bundle exec rspec ./spec/controllers/staff/blogs_controller_spec.rb
staff/blogs_controller_spec.rb loaded
Staff model loaded
Staff::BlogsController loaded
Randomized with seed 38943
..
Finished in 0.07659 seconds (files took 1.43 seconds to load)
2 examples, 0 failures
Randomized with seed 38943
あれあれ?今度はなぜかパスしてしまいました。
これは一体なぜなんでしょうか??
全体実行時にエラーが発生する原因
端的に言うと、スタッフ用のテストコード内で、Staff::BlogsController
ではなく、 BlogsController
(一般ユーザー用)が実行されてしまうためです。
ではなぜ、Staff::BlogsController
が呼び出されないのかというと、クラスが呼び出される順番と、Rubyの定数(=クラス名)探索の仕様を理解する必要があります。
まずデバッグ用に仕込んだ puts の出力結果を載せます。
blogs_controller_spec.rb loaded
BlogsController loaded
staff/blogs_controller_spec.rb loaded
Staff model loaded
/(your-app)/spec/controllers/staff/blogs_controller_spec.rb:4: warning: toplevel constant BlogsController referenced by Staff::BlogsController
上の出力結果を参考にしながら、RSpecの全体実行時に何が起きているのかを順に表すとこうなります。
- RSpecがspecディレクトリ内のファイルを順に読み込む。今回であれば
blogs_controller_spec.rb
、staff/blogs_controller_spec.rb
の順に読み込む。 -
blogs_controller_spec.rb
内にはRSpec.describe BlogsController, type: :controller do
の記述がある。このタイミングでBlogsController
が読み込まれる。( blogs_controller_spec.rb loaded 、 BlogsController loaded ) - 続いて、
staff/blogs_controller_spec.rb
の読み込みに移る。このファイルにはRSpec.describe Staff::BlogsController, type: :controller do
の記述がある。( staff/blogs_controller_spec.rb loaded ) - ここでまず
Staff::BlogsController
のStaff
クラスが読み込まれる。( Staff model loaded ) - 次に、Rubyは
Staff::BlogsController
の読み込み(定数探索)を試みる。この時点ではまだStaff::BlogsController
は実行環境内では読み込まれていないため、Rubyは「Staff
クラスはBlogsController
という定数(クラス)を定義していない」と見なす。 -
Staff
クラスにはBlogsController
が見つからなかったので、RubyはStaff
クラスの親クラス(正確にはStaff.ancestors
で返されるクラス)を順にたどっていき、それぞれのクラス内にBlogsController
が定義されていないかチェックする。 - 親クラスを順にたどっていくと、
Object
クラスにぶつかる。実はトップレベル(ネームスペース無し)のクラスはObject
クラス内の定数として定義される。上記 2 で読み込まれたBlogsController
もObject
クラス内に定義されている。よって、ここでBlogsController
が発見される。( ここが意図しない挙動!! ) -
BlogsController
が見つかったので、Ruby はStaff::BlogsController
がトップレベルのBlogsController
を参照していると見なして、定数探索を終了する。ただし、本来意図しない読み込みである可能性もあるため、"warning: toplevel constant BlogsController referenced by Staff::BlogsController" という 警告を出力 する。 - 結局、スタッフ用の
Staff::BlogsController
は読み込まれず、一般ユーザー用のBlogsController
が使われるため、テストが失敗する。
上の処理フローで重要なポイントは以下の通りです。
- 一般ユーザー用の
BlogsController
が先に読み込まれている。 -
BlogsController
はトップレベルで定義されているため、Object
クラスの定数(Object::BlogsController
)として扱われる。 - スタッフ用の
Staff::BlogsController
はまだ読み込まれていない。 - 指定されたネームスペース(今回であれば
Staff
)内に対象となる定数(クラス)が見つからない場合、Rubyはネームスペースの親クラスを順に探索していく。その結果、一般ユーザー用のBlogsController
(Object::BlogsController
)が見つかる
特に2番目と4番目ののポイントが、多くのRubyプログラマの普段意識しないところだと思います。
単体実行時にパスする原因
一方、staff/blogs_controller_spec.rb
を単体で実行すると、なぜかパスしてしまいました。
これはなぜなんでしょうか?
先ほどと同様、デバッグ用に仕込んだ puts の結果を載せます。
staff/blogs_controller_spec.rb loaded
Staff model loaded
Staff::BlogsController loaded
次に、RSpecの単体実行時に起きていることを説明します。
- RSpecが
staff/blogs_controller_spec.rb
を読み込む。このファイルにはRSpec.describe Staff::BlogsController, type: :controller do
の記述がある。( staff/blogs_controller_spec.rb loaded ) - ここでまず
Staff::BlogsController
のStaff
クラスが読み込まれる。( Staff model loaded ) - 次に、Rubyは
Staff::BlogsController
の読み込み(定数探索)を試みる。この時点ではまだStaff::BlogsController
は実行環境内では読み込まれていないため、Rubyは「Staff
クラスはBlogsController
という定数(クラス)を定義していない」と見なす。 -
Staff
クラスにはBlogsController
が見つからなかったので、RubyはStaff
クラスの親クラス(正確にはStaff.ancestors
で返されるクラス)を順にたどっていき、BlogsController
が定義されていないかチェックする。 - しかし、親クラスをたどっても
BlogsController
は見つからない。よって、Rubyはいったん定数探索に失敗する。 - Rubyは
Staff
クラスのconst_missing
メソッドを呼び出す。厳密にはActiveSupport::Dependencies
のconst_missing
メソッドが呼び出される。( ここがさっきと違うところ!! ) -
const_missing
メソッド内ではautoload_paths
で定義された各ディレクトリ内を探索して、"staff/blogs_controller" という名前に合致するファイルがないか探す。ここでapp/controllers/staff/blogs_controller.rb
が見つかる。 -
app/controllers/staff/blogs_controller.rb
をrequireする。ここでスタッフ用のStaff::BlogsController
が読み込まれる。( Staff::BlogsController loaded ) -
Staff::BlogsController
が無事に見つかったので、Rubyは定数探索を終了する。 - スタッフ用の
Staff::BlogsController
が読み込まれているので、テストは正常にパスする。
上の処理フローで重要なポイントは以下の2点です。
- Rubyは
Staff::BlogsController
の探索にいったん失敗する。 - しかし、
const_missing
メソッドを利用して、RailsがStaff::BlogsController
を自動的に見つけ出してくれる。
ちなみに、上記ステップ 2 の Staff
クラスも const_missing
メソッド経由で読み込まれています。
参考までにRubyMineを使って Staff::BlogsController
が読み込まれるまでのコールスタックを表示してみました。
これを見ると、 const_missing
メソッドが呼び出されているのがわかると思います。
さて、原因はわかりましたが、どうやって解決するのが良いでしょうか?
解決策1:モデル名とかぶらないようにネームスペースを変更する(オススメ)
若干修正の手間はかかりますが、テストコード以外の場所でも予期しないトラブルが発生したりしないよう、モデル名とは異なるネームスペースに変更することをオススメします。
たとえば、staff
を staffs
に変更するなどです。
ルーティング
Rails.application.routes.draw do
resources :blogs
namespace :staffs do
resources :blogs, only: %i(index show)
end
root to: 'blogs#index'
end
スタッフ用コントローラ
class Staffs::BlogsController < ApplicationController
puts "Staffs::BlogsController loaded"
before_action :set_blog, only: [:show]
def index
@blogs = Blog.all
end
def show
end
private
def set_blog
@blog = Blog.find(params[:id])
end
end
テストコード(スタッフ用コントローラ)
require 'rails_helper'
puts "staffs/blogs_controller_spec.rb loaded"
RSpec.describe Staffs::BlogsController, type: :controller do
let!(:blog) { create :blog, :draft }
describe 'GET #index' do
it '下書きのブログも含まれる' do
get :index
expect(assigns(:blogs)).to eq [blog]
end
end
describe 'GET #show' do
it '下書きのブログも表示できる' do
get :show, id: blog
expect(assigns(:blog)).to eq blog
end
end
end
こうすると、全体実行してもテストはパスします。
$ bundle exec rspec
blogs_controller_spec.rb loaded
BlogsController loaded
staffs/blogs_controller_spec.rb loaded
Staffs::BlogsController loaded
Staff model loaded
Randomized with seed 160
....
Finished in 0.09497 seconds (files took 1.4 seconds to load)
4 examples, 0 failures
Randomized with seed 160
ネームスペースを変えるとテストがパスする理由
エラーが起きない理由はこうです。
- RSpecがspecディレクトリ内のファイルを順に読み込む。今回であれば
blogs_controller_spec.rb
、staffs/blogs_controller_spec.rb
の順に読み込む。 -
blogs_controller_spec.rb
内にはRSpec.describe BlogsController, type: :controller do
の記述がある。このタイミングでBlogsController
が読み込まれる。( blogs_controller_spec.rb loaded 、 BlogsController loaded ) - 続いて、
staffs/blogs_controller_spec.rb
の読み込みに移る。このファイルにはRSpec.describe Staffs::BlogsController, type: :controller do
の記述がある。( staffs/blogs_controller_spec.rb loaded ) -
Staffs
はどこにも定義されていないが、Railsの自動モジュール機能(参照)により、自動的に モジュール として定義される。 - 次に、Rubyは
Staffs::BlogsController
の読み込み(定数探索)を試みる。この時点ではまだStaffs::BlogsController
は実行環境内では読み込まれていないため、Rubyは「Staffs
モジュールはBlogsController
という定数(クラス)を定義していない」と見なす。 - また、
Staffs
モジュールはObject
クラスを継承していない(正確にはStaffs.ancestors
にObject
クラスが含まれない)ので、親クラスをたどってもBlogsController
が見つからない。よって、Rubyはいったん定数探索に失敗する。( ここがさっきと違うところ!! ) - Rubyは
Staffs
モジュールのconst_missing
メソッドを呼び出す。厳密にはActiveSupport::Dependencies
のconst_missing
メソッドが呼び出される。 -
const_missing
メソッド内ではautoload_paths
で定義された各ディレクトリ内を探索して、"staffs/blogs_controller" という名前に合致するファイルがないか探す。ここでapp/controllers/staffs/blogs_controller.rb
が見つかる。 -
app/controllers/staffs/blogs_controller.rb
をrequireする。ここでスタッフ用のStaffs::BlogsController
が読み込まれる。( Staffs::BlogsController loaded ) -
Staffs::BlogsController
が無事に見つかったので、Rubyは定数探索を終了する。 - スタッフ用の
Staffs::BlogsController
が読み込まれているので、テストは正常にパスする。
簡潔にまとめると、Staff
はクラスだったので、Object
クラスが探索対象になりましたが、Staffs
はモジュールなので Object
クラスが探索対象にならない、というのが大きな違いです。
解決策2:Staffクラスを読み込んだ際に、ネームスペース配下のクラスを強制的に読み込む(未検証)
解決策1はエラーは起きなくなりますが、ネームスペースを変更するため、これまで使っていた URL も変わります。
(http://localhost:3000/staff/blogs
が http://localhost:3000/staffs/blogs
に変わる)
すでにリリース済みのアプリケーションだと、URLが突然変わってしまうのはあまり望ましくありません。
その場合は、以下のようにネームスペース配下のクラスを強制的に読み込むようにすると良いかもしれません。
class Staff < ActiveRecord::Base
puts "Staff model loaded"
end
Dir[File.join(Rails.root, 'app/controllers/staff/*.rb')].each { |f| require_dependency f }
こうしておけば、Staff::BlogsController
を参照したときに、以下のような動きになるのでエラーが起きなくなるはずです。
-
Staff
クラスが読み込まれる -
Staff
クラスの読み込みが終わると、その直後に同一ネームスペース配下のクラス(主にコントローラ)が読み込まれる。 -
BlogsController
という定数がStaff
クラス内に見つかるので、定数探索がそこで終わる。
以下はテストの実行結果です。
$ bundle exec rspec
blogs_controller_spec.rb loaded
BlogsController loaded
staff/blogs_controller_spec.rb loaded
Staff model loaded
Staff::BlogsController loaded
Randomized with seed 31099
....
Finished in 0.09575 seconds (files took 1.44 seconds to load)
4 examples, 0 failures
Randomized with seed 31099
ただし、これはジャストアイデアであり、実務で開発しているアプリケーションで試したことはないので、もしかすると別の問題を引き起こしたりするかもしれません。
僕は普段、解決策1を使っています。
なお、上記のコードで使っている require_dependency
については下記のドキュメントを参照してください。
解決策3:テスト実行前に強制的にクラスを読み込む(非推奨)
別のアイデアとして、spec/rails_helper.rb
内で強制的にネームスペースに属するクラスを読み込んでしまう(つまりテスト実行前に読み込んでしまう)、という案もあります。
Dir[File.join(Rails.root, 'app/controllers/staff/*.rb')].each { |f| require_dependency f }
RSpec.configure do |config|
# ...
ただ、これだと確かにテストはパスするようになりますが、開発環境や本番環境で動かしたときに、予期しないタイミングで "warning: toplevel constant" が発生するかもしれません。(発生しないかもしれませんが、僕にはハッキリわかりません)
なので、テスト環境だけに特殊な仕掛けを入れるのはあまり得策ではない気がします。
サンプルコード
今回使用したアプリケーションのソースコードはGitHubに置いてあります。
JunichiIto/namespaced-controller-sandbox
- テストが失敗するバージョン => masterブランチ
- ネームスペースを変更したバージョン => use-different-nameブランチ
- 強制的にクラスを読み込むバージョン => force-requireブランチ
まとめ
僕はもともと社内で「モデル名と同じネームスペースを付けるとトラブルの原因になるよ」と聞いていたので、いつも解決策1の方法でアプリケーションを作っていました。
しかし、そういった予備知識が無いと、どちらも同じ名前を付けてしまうことは結構起きそうな気がします。
もしこの話を知らなかった人がいたら、これからRailsアプリケーションを開発するときにモデル名とネームスペースの設計に十分注意してください。
参考文献
-
Rspecでwarning: toplevel constantが出る現象に対応する - Qiita
- もともとはこの記事のエラー原因を書こうとしていました。
-
書籍「プログラミング言語 Ruby」
- 「7.9 定数の探索」という章が参考になりました。
-
定数の自動読み込みと再読み込み | Rails ガイド
- Railsの自動読み込みや自動モジュールに関する仕様が詳しく説明されています。
- Warning: Toplevel Constant XYZ Referenced Admin:XYZ – code.dblock.org | tech blog
-
warning: toplevel constant SomeController referenced by Admin::SomeController - Google グループ
- 同じ原因による、同じエラーが発生しています。