Background
RSpec has a described_class method, which, given a spec starting
RSpec.describe Accounting::Ledger::Entry do
lets us use described_class
for Accounting::Ledger::Entry
within the spec, which is less noisy (particularly with long class names) and very handy if we later rename the class.
RSpec also has a subject method which exposes not the class but an instance of the class. It can be over-ridden implicitly, by putting a different class name in an inner describe block’s description:
RSpec.describe Accounting::Ledger::Entry do # subject starts as instance of Accounting::Ledger::Entry … describe Accounting::Ledger::SpecificEntryType do # subject is now instance of Accounting::Ledger::SpecificEntryType
or explicitly, by defining it directly:
RSpec.describe Accounting::Ledger::Entry do # subject starts as instance of Accounting::Ledger::Entry … subject { "just what I choose it to mean – neither more nor less" } # subject is now that string, just like Humpty Dumpty said
Problem
What if we’re building a request spec for an API endpoint, and our spec starts
RSpec.describe "/accounting/ledger/entries" do it "GET" do get "/accounting/ledger/entries" expect(response).to have_http_status(:ok)
It would be nicer if we had a described_url
method, so we could say get described_url
instead of repeating the url string in each example.
We could set subject
once to the url:
RSpec.describe "/accounting/ledger/entries" do subject { "/accounting/ledger/entries" } it "GET" do get subject
But the single repetition still feels like it should be unnecessary, particularly with the string we need only one line away.
Towards a Solution
We see from the documentation that we can get the inner description string from the example
if we specify it as a block parameter:
RSpec.describe "/accounting/ledger/entries" do it "GET" do |example| expect(example.description).to eql("GET") # => passes end end
We won’t want to say do |example|
all the time, though, so let’s start building code in a single before
block. Further, we’ll only want this in request specs, so let’s tag request specs as such
RSpec.describe "/accounting/ledger/entries", :requests do
(:requests
here is equivalent to requests: true
), and put the before do |example|
block, scoped to specs tagged :requests
, in spec/spec_helper.rb
:
RSpec.configure do |config| … config.before(:each, :requests) do |example| binding.pry end
From here we can run the spec and at the debugger and see that example.description
is indeed “GET”.
> example => #<RSpec::Core::Example "GET"> > example.description => "GET"
Looking at lib/rspec/core/example.rb
shows us that def description looks in something called metadata[:description]
and passing example.metadata
to the debugger gives us a screen-full of hash data, including
{ … :description => "GET" :full_description => "/accounting/ledger/entries GET", :example_group => { … :description => "/accounting/ledger/entries"
So if we’re one layer deep, example.metadata[:example_group][:description]
gives us the outer description.
However, the actual example might be nested inside a describe or context block. Given
RSpec.describe "/accounting/ledger/entries" do describe "GET" do it "first case" do
the relevant metadata extract is instead:
{ … :description => "first case" :full_description => "/accounting/ledger/entries GET first case", :example_group => { … :description => "GET", :full_description => "/accounting/ledger/entries GET", … :parent_example_group => { … :description => "/accounting/ledger/entries", :full_description => "/accounting/ledger/entries"
And yes, testing with another nested group demonstrates that a :parent_example_group
can include an inner :parent_example_group
.
A Solution
But that’s a simple recursion problem: keep going until you don’t have a next :parent_example_group
and then grab the outermost :description
. We could replace the contents of the before
block with:
def reckon_described_url(metadata) if metadata[:parent_example_group].nil? metadata[:description] else reckon_described_url(metadata[:parent_example_group]) end end @described_url = reckon_described_url(example.metadata[:example_group])
Why the Snark Is a Boojum
(Yes, arguably, because the first space in the :full_description
string happens to be between the outer description and the second-outermost description, we could get the same answer from
@described_url = example.metadata[:full_description].split(" ").first
but only by coincidence, and it would break for unrelated reasons if they changed how they build :full_description
. So let’s not do that.)
Back to the Solution
We can now use the code from our before blog in a request spec. Going back to our original example, we can now implement it
RSpec.describe "/accounting/ledger/entries", :requests do it "GET" do get @described_url
If we’d prefer not to have an unexplained instance variable, or are looking forward to the possibility of passing in parameters, we can wrap it in a method call in the before block:
def described_url @described_url end
And then it can (finally) be used by the end user just like described_class
:
RSpec.describe "/accounting/ledger/entries", :requests do it "GET" do get described_url
A Solution that Takes Parameters
A more realistic version of the request spec url might be /accounting/ledgers/:ledger_id/entries
. Fortunately, since we’ve just turned described_url
into a method, we can make it take parameters, so a slightly changed test can pass in the parameters it needs to substitute:
RSpec.describe "/accounting/ledgers/:ledger_id/entries", :requests do let(:ledger) { … build test ledger … } it "GET" do get @described_url(params: {ledger_id: ledger.id})
and the method can create a copy of the base @described_url (as different tests may well require different parameters) and return the copy with the parameters, if any, filled in:
def described_url(params: {}) url = @described_url.dup params.each do |key, value| url.sub!(":#{key}", value.to_s) end url end
Appendix: The Complete Before Block
RSpec.configure do |config| … config.before(:each, :requests) do |example| def described_url(params: {}) url = @described_url.dup params.each do |key, value| url.sub!(":#{key}", value.to_s) end url end def reckon_described_url(metadata) if metadata[:parent_example_group].nil? metadata[:description] else reckon_described_url(metadata[:parent_example_group]) end end @described_url = reckon_described_url(example.metadata[:example_group]) end … end