Small steps to DRYer RSpec
What?
RSpec API is constantly evolving towards as DRY and readable DSL as possible. Though, there are several things (tricks and additional methods) that could make your specs even DRYer.
Warning: While reading, you may feel a temptation to say something like “instead of using this, you’d better redesign your code for testability!” Let’s imagine it was already said, OK?
UPD Aug 15, 2017: Gem saharspec
is now properly released,
with tests and docs! Please give it a try.
How?
Fancy subject
s
subject statement is a really idiomatic addition to modern RSpec, which allows to unambiguosly say what are we testing, and simplify tests afterwards.
# Good and DRY:
subject { 'foo' }
it { is_expected.to eq 'foo' }
Using rspec-its (which was once in RSpec core, but extracted to separate gem in v3), we can do even more inventive checks:
# Still good and DRY:
its(:size) { is_expected.to eq(3) }
But this approach have some limitations.
subject
as Array
If you want to check (= have a subject) an array of values, there is no idiomatic way to
do its
-checks on it:
# We need to repeat `subject` and have long "expect" argument :(
subject { %w[foo test shenanigans] }
it { expect(subject.map(&:size)).to include(3) }
WDYT about this trick?
subject { %w[foo test shenanigans] }
its_map(:size) { is_expected.to include(3) }
Implementation (assumes you’ve already required rspec/its and reuses its namespace):
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
Chains work also:
its_map(:'chars.first') { is_expected.to include('s') }
subject
as a block
Imagine this code:
describe 'addition' do
subject { 'foo' + other }
context 'when compatible' do
let(:other) { 'bar' }
# nice & DRY!
it { is_expected.to eq 'foobar' }
end
context 'when incompatible' do
let(:other) { 5 }
# subject is repeated :(
it { expect { subject }.to raise_error(TypError) }
end
end
Wouldn’t it look better this way? (Spoiler: for me, it would.)
subject { 'foo' + other }
# ...
context 'when incompatible' do
let(:other) { 5 }
its_call { is_expected.to raise_error(TypeError) } # OK, it's DRY too now!
end
Implementation (assumes you’ve already required rspec/its and reuses its namespace):
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
Works with tons of useful matchers:
subject { hunter.shoot_at(:lion) }
its_call { is_expected.to change(Lion, :count).by(-1) }
subject
as a method
Imagine you need to test tons of return values of some relatively simple method, in different conditions it should return different results. How you do it?
it { expect(fetch(:age)).to eq 30 }
it { expect(fetch(:weight)).to eq 50 }
it { expect(fetch(:name)).to eq 'June' }
# .... do you see a pattern here?..
What about this? (Note, that it is just rspec + rspec/its, no additional code needed):
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' }
It “just works”, because the form its([arg])
calls []
on subject, and Ruby’s Proc defines
[]
as a synonym to .call
!
Note: Another way to test the same subject is update
its_call
above to accept arguments, likeits_call(:age) { is_expected.to eq 30 }
.
Fun with matchers
Both ideas in this section are already considered and rejected by RSpec maintainers. But it doesn’t mean they are absolutely useless (TBH, I find them both much clearer than those preferred by maintainers).
Negations
UPD summer ‘17: Please don’t use this! Extremely bad idea. Due to operators priorities, two and_not
s
in a row produce absolutely unexpected result.
Method expectations
Again, there is long-living (yet hard for me to follow) discussion why “expect some code to call some methods on some objects” is the only RSpec check that can’t be expressed in a single statement.
Currently, you can do:
# either
expect(obj).to receive(:method)
some_code
# or
obj = spy("Class")
some_code(obj)
expect(obj).to have_received(:method)
For me, neither look “atomic” enough. So, the solution:
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
Now, you can just:
# arguments order like in change() matcher
expect { some_code }.to send_message(obj, :method).with(1, 2, 3).returning(5)
# works great with aforementioned its_call
subject { some_code }
it { is_expected.to send_message(obj, :method).with(1, 2, 3).returning(5) }
Nifty, ha?
Some final notes
UPD Aug 15, 2017: Gem saharspec
is now properly released,
with tests and docs! Please give it a try.
I use all of code snippets shown above in my everyday work, and they show themselves as working and helpful. Though, it may be other way for you. In any case, notice that implementations are working, yet naive (or “naive, yet working” if you want). I gathered all of them in witty-named repo, but still haven’t made in proper gem with tests and docs and stuff. Though, you already can use it from GitHub (it has a proper gemspec), and say me what you think.
Just add this to your Gemfile (development
group probably):
gem 'saharspec', git: 'https://github.com/zverok/saharspec.git'
Reddit discussion with pretty heated argument on one-line/DRY specs, and whether they are any good.