Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: RSpecをもっとDRYに書くテクニック(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。


  • 2017/11/29: 初版公開
  • 2023/07/11: 更新

Rails: RSpecをもっとDRYに書くテクニック(翻訳)

🔗 これは何?

RSpec APIは可能な限りDRYで読みやすいDSLへと絶え間なく進化し続けています。しかし、specをさらにDRYにできる方法(トリックや追加メソッドなど)はまだまだあるのです。

警告: 本記事を読んで「この方法を使うより先に、テストしやすいコードを書けるように設計を見直す方がいいのでは?」と言いたくてたまらなくなるかもしれませんが、そうした方法は既に知られているとお考えください。

更新情報(2017/08/15): saharspecはその後正式にリリースされました。テストもドキュメントもあります。ぜひお試しください。

zverok/saharspec - GitHub

🔗 DRYに書く方法

🔗 subjectを活用

subject文は、最新のRSpecに追加されました。subjectを使うことで、テストの対象を明確に記述してからテストを実行できます。

# DRYかつ良い
subject { 'foo' }
it { is_expected.to eq 'foo' }

rspec-its(以前はRSpecのコアにありましたが、v3からは別gemに切り出されました)を使うと、さらに独創的なチェックを行えます。

# これもDRYかつ良い
its(:size) { is_expected.to eq(3) }

しかしこのアプローチには少々制限があります。

🔗 subjectにarrayを書く

subjectで)値の配列をチェックしたい場合、itsチェックでは(英語的に)うまく合いません。

# `subject`を繰り返して長い`expect`引数を使う必要がある :(
subject { %w[foo test shenanigans] }
it { expect(subject.map(&:size)).to include(3) }

では次のようなits_mapを使う方法ならどうでしょうか。

subject { %w[foo test shenanigans] }
its_map(:size) { is_expected.to include(3) }

実装は次のとおりです(既にrspec/itsをrequireして名前空間を再利用している前提です)。

module RSpec
  module Its
    def its_map(attribute, *options, &block)
      describe("map(&:#{attribute})") do
        let(:__its_map_subject) do
          attribute_chain = attribute.to_s.split('.').map(&:to_sym)
          attribute_chain.inject(subject) do |inner_subject, attr|
            inner_subject.map(&attr)
          end
        end

        def is_expected
          expect(__its_map_subject)
        end

        alias_method :are_expected, :is_expected

        options << {} unless options.last.is_a?(Hash)

        example(nil, *options, &block)
      end
    end
  end
end

次のようにチェインすることも可能です。

its_map(:'chars.first') { is_expected.to include('s') }
🔗 subjectにブロックを書く

次のコードで考えてみます。

describe 'addition' do
  subject { 'foo' + other }

  context 'when compatible' do
    let(:other) { 'bar' }
    # DRYかつ良い
    it { is_expected.to eq 'foobar' }
  end

  context 'when incompatible' do
    let(:other) { 5 }
    # subjectをまた書かないといけない:(
    it { expect { subject }.to raise_error(TypError) }
  end
end

上より以下の方が良いとは思いませんか?(私はそう思います)

subject { 'foo' + other }
# ...
context 'when incompatible' do
  let(:other) { 5 }
  its_call { is_expected.to raise_error(TypeError) } # よし、これもDRYになった
end

実装は次のとおりです(既にrspec/itsをrequireして名前空間を再利用している前提です)。

module RSpec
  module Its
    def its_call(*options, &block)
      describe("call") do
        let(:__call_subject) do
          -> { subject }
        end

        def is_expected
          expect(__call_subject)
        end

        example(nil, *options, &block)
      end
    end
  end
end

its_callは多くの便利マッチャと併用できます。

subject { hunter.shoot_at(:lion) }
its_call { is_expected.to change(Lion, :count).by(-1) }
🔗 subjectにメソッドを書く

ある比較的シンプルなメソッドについて、条件をさまざまに変えると多数の戻り値がどのように変わるかをテストしなければならないとします。こんなとき、あなたならどう書きますか?

it { expect(fetch(:age)).to eq 30 }
it { expect(fetch(:weight)).to eq 50 }
it { expect(fetch(:name)).to eq 'June' }
# .... パターンが見えてきますよね?...

では以下の書き方ならどうでしょうか(これはRSpecとrspec/itsだけで追加コードなしで書けます)。

subject { ->(field) { fetch(field) } }

its([:age]) { is_expected.to eq 30 }
its([:weight]) { is_expected.to eq 50 }
its([:name]) { is_expected.to eq 'June' }

これだけで動きます。its([arg])とするとsubjectの[]が呼び出され、Ruby'のProcでは[]の定義が.callと同義になっているからです。

原注

同じsubjectをテストするもうひとつの方法は、上のits_callを書き換えてits_call(:age) { is_expected.to eq 30 }のように引数を受け取れるようにすることです。

🔗 マッチャで楽しむ

上でご紹介したアイデアは、どちらもRSpecメンテナーたちによって検討の末rejectされました。だからといってこのアイデアが完全に役に立たないということにはなりません(正直、私はメンテナーの好みよりこちらの方がずっと明確だと思います)。

🔗 否定テスト

更新(2017年夏): この方法は非常によくないので使わないでください。演算子の優先順位が原因で、and_notを2つ連続で使うと思いもよらぬ結果が生じます。

訳注

取り消し線の部分は訳出しませんでした。

🔗 メソッドのexpectation

#934で、「あるオブジェクトのあるメソッドを、あるコードから呼び出せるexpectation」をRSpecで1文で書けない理由について長い間議論されています(長すぎて私もあまりフォローできていません)。

訳注

現在#934はcloseしています。

現時点では、次のどちらかの書き方が使えます。

# その1
expect(obj).to receive(:method)
some_code

# その2
obj = spy("Class")
some_code(obj)
expect(obj).to have_received(:method)

私にはどちらもあまり「アトミック」には見えません。では次のソリューションならどうでしょうか。

RSpec::Matchers.define :send_message do |object, message|
  match do |block|
    allow(object).to receive(message)
      .tap { |m| m.with(*@with) if @with }
      .tap { |m| m.and_return(@return) if @return }
      .tap { |m| m.and_call_original if @call_original }

    block.call

    expect(object).to have_received(message)
      .tap { |m| m.with(*@with) if @with }
  end

  chain :with do |*with|
    @with = with
  end

  chain :returning do |returning|
    @return = returning
  end

  chain :calling_original do
    @call_original = true
  end

  supports_block_expectations
end

これで以下のように1文で書けます。

# 引数の順序はchange()マッチャと似ている
expect { some_code }.to send_message(obj, :method).with(1, 2, 3).returning(5)

# 前述のits_callとの相性もよい
subject { some_code }
it { is_expected.to send_message(obj, :method).with(1, 2, 3).returning(5) }

クールだと思いませんか?

軽くまとめ

更新情報(2017/08/15): saharspecはその後正式にリリースされました。テストもドキュメントもありますので、ぜひお試しください。

ご紹介したスニペットはいずれも私が日常的に業務で便利に使っており、ちゃんと動いています。もちろん別の方法がよいこともあるでしょう。しかしいずれにしろ、どの実装も雑なりにちゃんと動きます(「動くけど雑」と思う人もいるかもしれませんが)。

私はこれらのスニペットをsaharaspecというちょっと気の利いた名前のリポジトリに置きましたが、本記事執筆時点ではまだ正式なgemになっておらず、テストやドキュメントもありません。しかし正式なgemspec付きでGitHubに置かれているので既に利用可能な状態になっていますので、ぜひ皆様のご感想をお寄せください。

Gemfileに以下を追記します(おそらくdevelopmentグループ)。

gem 'saharspec', git: 'https://github.com/zverok/saharspec.git'

なお、Redditでは本記事のワンライナーDRY specについて議論がかなり白熱しています(良し悪しはともかく)。

関連記事

Ruby: テストを不安定にする5つの残念な書き方(翻訳)

TestProf: Ruby/Railsの遅いテストを診断するgem(翻訳)

Rails: ActiveRecord関連付けのpreload/eager-loadをテストする2つの方法(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。