🐘

ActiveSupport::Concern を使用したモジュールのテスト方法

2024/01/22に公開

概要

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 を使用します。
https://rubydoc.info/github/rspec/rspec-mocks/RSpec%2FMocks%2FExampleMethods: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