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
で)値の配列をチェックしたい場合、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について議論がかなり白熱しています(良し悪しはともかく)。
概要
原著者の許諾を得て翻訳・公開いたします。