Quantcast
Channel: hachi8833の記事一覧|TechRacho by BPS株式会社
Viewing all articles
Browse latest Browse all 1759

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

$
0
0

概要

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

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つの方法(翻訳)


Viewing all articles
Browse latest Browse all 1759

Trending Articles