ActiveSupport::Concern を使用したモジュールのテスト方法
概要
Ruby on Rails では ActiveSupport::Concern
を使ってモジュールを作成することが多いですよね。
たとえばこのような。
module SomeModule
extend ActiveSupport::Concern
class_methods do
def upcase(arg)
arg.upcase
end
end
end
しかし、このモジュールの単体テストを書いていない現場が多い印象があります(慣習なのかはわかりませんが)。
上記の SomeModule のテストを、 include 先の単体テストのみで行っているのですが、
同じようなことをテストしていて、テストコードが重複しがちです。
テストコードが多くなること自体は大した問題ではありませんが、このモジュールで独立したテストを書いていないことに違和感があったので、今回はモジュールのテスト方法を書き残します。
環境
ruby '3.1.2'
rails '7.0.8'
結論
テスト内でダミーのクラスを定義して、それに include し、単体テストを書く。
以下が具体的なテストです。
RSpec.describe SomeModule do
before do
stub_const(
"DummyClass",
Class.new do
include SomeModule
end
)
end
describe ".upcase" do
context "小文字の引数が渡されたとき" do
it "大文字に変換されること" do
expect(DummyClass.upcase("aaa")).to eq("AAA")
end
end
end
end
なぜ普通に class を定義しないのか
上記で DummyClass を定義している箇所、実は普通に定義してもテストは通ります。
RSpec.describe SomeModule do
class DummyClass
include SomeModule
end
describe ".upcase" do
context "小文字の引数が渡されたとき" do
it "大文字に変換されること" do
expect(DummyClass.upcase("aaa")).to eq("AAA")
end
end
end
end
しかし、この用に定義してしまうと、他の Spec で同じ名前でクラスを再定義したときに、
元のクラスに影響が出てしまいます。
RSpec.describe SomeModule do
# DummyClass を再定義することで .upcase が上書きされる
class DummyClass
def self.upcase(arg)
"#{arg.upcase}!!!"
end
end
describe ".upcase" do
context "小文字の引数が渡されたとき" do
it "大文字に変換され、末尾に!!!がついていること" do
expect(DummyClass.upcase("aaa")).to eq("AAA!!!")
end
end
end
end
以下のように、 SomeModule の upcase メソッドが上書きされ、
末尾に!!!
がつくようになってしまいます。
> bundle exec rspec
F.
Failures:
1) SomeModule.upcase 小文字の引数が渡されたとき 大文字に変換されること
Failure/Error: expect(DummyClass.upcase("aaa")).to eq("AAA")
expected: "AAA"
got: "AAA!!!"
stub_const について
上記のようにクラス名の衝突を避けるため、stub_const
を使用します。
stub_const
を使って命名することで、クラスの定義はテスト内にとどまります。
RSpec.describe SomeModule do
before do
stub_const(
"DummyClass",
Class.new do
include SomeModule
end
)
end
describe ".upcase" do
context "小文字の引数が渡されたとき" do
it "大文字に変換されること" do
expect(DummyClass.upcase("aaa")).to eq("AAA")
end
end
end
end
RSpec.describe SomeModule do
before do
stub_const(
"DummyClass",
Class.new do
def self.upcase(arg)
"#{arg.upcase}!!!"
end
end
)
end
describe ".upcase" do
context "小文字の引数が渡されたとき" do
it "大文字に変換され、末尾に!!!がついていること" do
expect(DummyClass.upcase("aaa")).to eq("AAA!!!")
end
end
end
end
> bundle exec rspec
..
Finished in 0.03794 seconds (files took 3.96 seconds to load)
2 examples, 0 failures
無名クラスについて
上記のもう1つのポイントとして、無名クラスを使用しているという点があります。
無名クラスとは以下のようなコードです。
SomeClass = Class.new do
def self.upcase(arg)
"#{arg.upcase}!!!"
end
end
このようにすることで、名前を付けずにクラスを定義することができます(上記のSomeClass
は変数名です)。
stub_const を使わなくてもいい
無名クラスを let
で名前を与えて定義しておけば、実は stub_const
は不要です。
おそらくここらへんは好みの問題だと思いますが、
自分は stub_const
を使ったほうがクラスの感じが出るので好きです。
RSpec.describe SomeModule do
let(:dummy_class) do
Class.new do
include SomeModule
end
end
describe ".upcase" do
context "小文字の引数が渡されたとき" do
it "大文字に変換されること" do
expect(dummy_class.upcase("aaa")).to eq("AAA")
end
end
end
end
RSpec.describe SomeModule do
let(:dummy_class) do
Class.new do
def self.upcase(arg)
"#{arg.upcase}!!!"
end
end
end
describe ".upcase" do
context "小文字の引数が渡されたとき" do
it "大文字に変換され、末尾に!!!がついていること" do
expect(dummy_class.upcase("aaa")).to eq("AAA!!!")
end
end
end
end
> bundle exec rspec
..
Finished in 0.04281 seconds (files took 3.86 seconds to load)
2 examples, 0 failures
インクルード先のテスト
モジュールのテストがしっかり書けていれば、インクルード先の Spec では
モジュールをインクルードしているかどうかのテストを書いていれば良くなります。
ここらへんはプロジェクトにもよると思いますが、少なくともインクルード先の単体テストのみのときより、モジュールがしっかり動くことの担保は取れています。
expect(described_class.include?(SomeModule)).to be_truthy
もしくは
expect(described_class).to include(SomeModule)
まとめ
モジュールの単体テストは、普通のクラスの定義をすると、Spec 実行中にクラス名が衝突して再定義されてしまうので、 stub_const
を使って名前を Spec 内で独立させましょう。
Discussion