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
Sean Miller: The Wandering Coder