🤯️

rspec-rails にて power_assert を assert の名前で使えるようにする手順がかなりしんどかった

2023/11/28に公開

はじめに

rspec-rails にて power_assert を assert { 1 + 2 == 3 } のような形で使いたい。ただそれだけのことが一筋縄ではいかなかったので誰の役にも立たない記録を残しておく。

実際の Rails アプリの spec/rails_helper.rb にコピペするとしたときにわかりやすいよう必要な部分だけ if でインデントしてある。

前提知識1: 素の RSpec に minitest は入っていない

require "bundler/inline"

gemfile do
  gem "rspec"
end

require "rspec/autorun"

describe do
  it do
    expect(1 + 2).to eq(3)
    respond_to?(:assert)  # => false
  end
end
  • 何の設定もしなければ使いやすいとは言えない expect が有効になる
  • もちろん minitest のアサーションは入っていない

前提知識2: rspec-rails は minitest を強制的に導入する

require "bundler/inline"

gemfile do
  gem "rails"
  gem "rspec-rails"
end

require "rails"
require "action_view"
require "action_controller"
require "rspec/rails"
require "rspec/autorun"

RSpec.configure do |config|
  config.include RSpec::Rails::RailsExampleGroup
end

describe do
  it do
    expect(1 + 2).to eq(3)
    assert_equal 3, 1 + 2
    assert 1 + 2 == 3
  end
end
  • minitest が必要なら本来 expect_with :minitest と書く決まりになっている
  • しかし rspec-rails はそれとは無関係に minitest を適用している
  • 理由はマッチャの一部を minitest の assertion に委譲しているため
  • つまり rspec-rails は minitest をベースにしているとも言える
  • そういうことなら expect で人々を苦しめなくても、かわりに assert 系メソッドを使う選択肢もあったと最初に広言してくれてもよかったと思う

rspec-power_assert 編

require "bundler/inline"

gemfile do
  gem "rspec-power_assert"
  gem "rails"
  gem "rspec-rails"
end

require "rails"
require "action_view"
require "action_controller"
require "rspec/rails"
require "rspec/autorun"

RSpec.configure do |config|
  config.include RSpec::Rails::RailsExampleGroup
end

if true
  require "rspec-power_assert"
  RSpec::Rails::Assertions.remove_method(:assert)
  RSpec::PowerAssert.example_assertion_alias :assert
end

describe do
  it do
    assert { 1 + 2 == 3 }
  end
end
  • rspec-power_assert は minitest に依存しない形で power_assert に対応した is_asserted_by メソッドを生やしてくれる
  • そのような場合は、変にこだわりを持たず、用意してくれたものをそのまま使う適応力や柔軟性が大切だとはいえ、シンプルに assert の延長として使いたい自分としてはどうしてもこの長いメソッド名を受け入れがたかった
  • たんに assert { } として使いたかった
  • rspec-power_assert はそういうときのためにもエイリアスできるようにしてくれている
  • だが普通にやると minitest の assert に負ける
  • そこでまず RSpec::Rails::Assertions から assert をぶっ殺す
  • 続けて RSpec::PowerAssert.example_assertion_alias :assert すると minitest に勝てる
  • ここで重要なのが明示的に require "rspec-power_assert" した直後でやること
    • require "rspec-power_assert" の替わりに .rspec のなかで -r rspec-power_assert としてしまうと実行順序の関係で minitest に負ける
  • こうしてなんとか使えるようになった assert だが minitest の assert との互換性がない
    • 具体的には assert をブロックなしで呼べない
    • 今後、rspec-rails に assert をブロックなしで呼ばれたり、今もどこかでそんな使われ方をされているとエラーになるかもしれない
    • 心配であれば is_asserted_by を assert にエイリアスするのはやめたほうがよさそう

test-unit (+ power_assert) 編

require "bundler/inline"

gemfile do
  gem "test-unit"
  gem "rails"
  gem "rspec-rails"
end

require "rails"
require "action_view"
require "action_controller"
require "rspec/rails"
require "rspec/autorun"

RSpec.configure do |config|
  config.include RSpec::Rails::RailsExampleGroup
end

if true
  # test-unit のメソッドを上書きしやがるメソッドを空で上書きする
  # /opt/rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/rspec-rails-6.0.3/lib/rspec/rails/adapters.rb
  RSpec::Rails::MinitestAssertionAdapter::ClassMethods.module_eval do
    def define_assertion_delegators
    end
  end

  RSpec.configure do |config|
    config.expect_with :test_unit
  end
end

describe do
  it do
    assert { 1 + 2 == 3 }
  end
end
  • test-unit は power_assert を内部で利用しているため test-unit を導入するだけで assert { } が使えるようになる
  • そして再度書くと rspec-rails は minitest の使用を前提としている、というか勝手に適用している
  • 自分はそこまでの内情を知らなかったし、RSpec の設定で expect_with :test_unit と書けてしまうので test-unit が普通に使えるようになるものだと考えていた
  • しかし、実際は適用した test-unit を minitest が上書きしてしまう (メソッドがほぼ同じなため上書きされたことにも気づきにくい)
  • これは it の中で self.class.ancestors.collect(&:name).grep(/unit|mini/i) としてみるとわかりやすい
  • Test::Unit::Assertions より RSpec::Rails::MinitestAssertionAdapter の方が直近にでてくるため Test::Unit::Assertions が完全に負けているのがわかる
  • しょうがないので test-unit を上書きしやがる MinitestAssertionAdapter の該当メソッドを空で上書きしてやる
  • これでやっと assert { } が使えるようになる
  • 上書きしやがるメソッドを上書きする方法以外では RSpec::Rails::Assertions.remove_method(:assert) としてもいいが assert 以外のメソッドは minitest に上書きされたままになってしまっているので根っこから上書きされないようにした方がよい
  • 何度も書くが rspec-rails は minitest が動いている前提なので中身が test-unit に置き換わって今後も安全に動作するかは怪しい
  • なので心配なら rspec-rails で test-unit を使うのはやめたほうがいい

minitest-power_assert 編

require "bundler/inline"

gemfile do
  gem "minitest"
  gem "minitest-power_assert"
  gem "rails"
  gem "rspec-rails"
end

require "rails"
require "action_view"
require "action_controller"
require "rspec/rails"
require "rspec/autorun"

RSpec.configure do |config|
  config.include RSpec::Rails::RailsExampleGroup
end

if true
  require "minitest-power_assert"
  Minitest::Assertions.prepend Minitest::PowerAssert::Assertions
end

describe do
  it do
    assert { 1 + 2 == 3 }
    assert 1 + 2 == 3
  end
end
  • minitest-power_assert は assert をブロック付きにして power_assert 対応してくれる
  • しかし require "minitest-power_assert" としても、そのままでは使えない
    • minitest-power_assert は Minitest::Test にある assert メソッドを拡張している
    • しかし rspec-rails は Minitest::Assertions を見ている
    • つまり minitest-power_assert は拡張しているところが空振っている
    • おそらく prepend がない、または使いづらい時代だったせいで Minitest::Assertions に直接適用するのが難しかったのか、そもそも Minitest::Assertions モジュールだけがひっぱり出されて RSpec で使われることなんか想定外だったのではないかと思われる
  • そこで試しに Minitest::Assertions に prepend してみると assert { } が使えるようになった
  • assert はブロックなしでも呼べる (重要)
    • 元の仕様と互換性があるので minitest-power_assert を入れたことで rspec-rails と minitest の関係に不整合が生じる恐れはないと思われる

まとめ

  • どの方法もすんなりとはいかない
  • Rails で RSpec を使った時点でレールから外れているのにさらに外れると非常に険しい
  • rspec-power_assert の is_asserted_by を assert に alias するのは危ない
  • rspec-rails と test-unit の相性は最悪
  • minitest-power_assert で既存の assert を拡張する方法がわりかし安全と思われる

Discussion