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

概要

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

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

これは何?

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

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

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

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で)値のarrayをチェックしたい場合、itsチェックでは(英語的に)うまくはまりません。

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

次の方法はいかがでしょうか。

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について議論がかなり白熱しています(良し悪しはともかく)。

関連記事

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

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

Ruby: 「マジック」と呼ぶのをやめよう(翻訳)

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

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833

コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。
これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。
かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。
実は最近Go言語が好き。
仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

BPSアドベントカレンダー

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ