Augusts Bautra
Posted on September 27, 2023
Immediate caveat - usually you want to favour KISS over DRY in specs. I've railed against shared examples (a DRYing technique) in the past, so this may seem hypocritical of me, but hear me out.
I'll argue that there is a difference between "boilerplate" code (the describe
, context
and it
calls) and "substance" code (let
s and actual asserts), and that reducing the boilerplate will make the substance shine through better.
The example
Let's look at a naive example. We have a class that has a class method, an initializer, and an instance method:
class Triangle
attr_reader :a, :b, :c
def initialize(a, b, c)
@a = a
@b = b
@c = c
end
def self.sum_of_interior_angles
180
end
def perimeter
a + b + c
end
end
Seeing how initializer isn't doing anything interesting, we should get by with speccing .sum_of_interior_angles
and #perimeter
:
RSpec.describe Triangle do
describe ".sum_of_interior_angles" do
subject(:sum_of_interior_angles) { described_class.sum_of_interior_angles }
it { is_expected.to eq(180) }
end
describe "#perimeter" do
subject(:perimeter) { triangle.perimeter }
let(:triangle) { described_class.new(3, 4, 5) }
it { is_expected.to eq(12) }
end
end
The gripe
My gripe with the spec is minor, but affects nearly all specs, at least for methods without arguments - we've repeated the method name three times, across two lines where one call is plenty.
We could have this:
RSpec.describe Triangle do
describe ".sum_of_interior_angles" do
it { is_expected.to eq(180) }
end
describe "#perimeter" do
let(:triangle) { described_class.new(3, 4, 5) }
it { is_expected.to eq(12) }
end
end
How? Easy - convention! Use describe's .
or #
to tell whether a class or instance method is being specced and define the subject based on that. For instance method specs infer the name of the instance by underscoring the class name.
But I agree that this may be a bit too implicit and terse for new developers. Here's a bit more explicit version where we specify instance_name: :triangle
metadata to tell where to read the instance from, and :implicit_subject
metadata to signal that subject is being defined under the hood.
RSpec.describe Triangle, instance_name: :triangle do
describe ".sum_of_interior_angles", :implicit_subject do
it { is_expected.to eq(180) }
end
describe "#perimeter", :implicit_subject do
let(:triangle) { described_class.new(3, 4, 5) }
it { is_expected.to eq(12) }
end
end
Yummy!
The implementation
To support this behavior a shared context with metadata-based inclusion can be used.
# in some path that gets loaded for specs, usually spec/support/shared_contexts/*.rb
METHOD_DESCRIPTORS = %w[. #]
RSpec.shared_context "with implicit subject defining", :implicit_subject do |context|
if metadata[:description].first.in?(METHOD_DESCRIPTORS)
method_name = metadata[:description].split("(").first[1..]
case metadata[:description].first
when "." # class_method
subject(method_name) { described_class.send(method_name) }
when "#" # instance_method
instance_getter =
metadata[:instance_name] ||
:instance # you can do class-name-based inference here if you wish
subject(method_name) { send(instance_getter).send(method_name) }
end
end
end
Posted on September 27, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.