Introduction
Like many test frameworks RSpec allows you to stub method implementations and to set
expectations in your tests, to verify which methods should be invoked, and with what arguments.
This is achieved using either the expect
or allow
syntax.
By setting message expectations in this way our unit tests can assert that some dependency is
called in the manner expected, without us having to worry about actually invoking the method on
that dependency. For example:
RSpec.describe "setting a message expectation with a block" do
it "returns an instance of Foo" do
expect(api_client).to receive(:create) { Foo.new }
result = api_client.create
expect(result).to be_a Foo
end
end
This code shows how we can stub the api_client.create
method to return a new instance of
Foo
. This new instance is created and returned by the block passed to the receive
method exposed by RSpec. When the stubbed method is invoked the block we have defined will be yielded
along with any arguments passed by the caller. For example:
RSpec.describe "setting a message expectation with a block" do
it "returns an instance of Foo" do
expect(api_client).to receive(:create) { |name| Foo.new (name: name) }
result = api_client.create("Bar")
expect(result.name).to eq "Bar"
end
end
We have kept the block pretty simple in these starter examples, but the logic could be arbitrarily complex. This makes the block syntax extremely flexible and useful in a wide variety of cases. Typical examples cited in the docs include:
- Performing some calculation
- Simulating transient network failure
- Verifying arguments
The code we want to test
We will demonstrate how to use the RSpec block syntax to flexibly validate method arguments.
To do that we will introduce a UserBuilder
class that we wish to test:
class UserBuilder
attr_reader :api_client,
:name,
:shoe_size,
:south_paw
# The :api_client must expose two methods:
# * :create method to create the User
# * :get_token to return an Authorization token for the API call
def initialize(api_client: nil,
name: "None",
shoe_size: 10,
south_paw: false)
raise ArgumentError, ":api_client is required" unless api_client
unless (3..16).include?(shoe_size)
raise ArgumentError, ":shoe_size must be between 3-16"
end
@api_client = api_client
@name = name
@shoe_size = shoe_size
@south_paw = south_paw
end
def build
api_client.create({
"Content-Type" => "application/json",
"Authorization" => "Bearer #{api_client.get_token}"
}, {
name: name.downcase,
shoe_size: convert_uk_to_us(shoe_size),
south_paw: south_paw
})
end
private
def convert_uk_to_us(shoe_size)
shoe_size + 0.5
end
end
The UserBuilder
will accept a hash of arguments. The :api_client
is required, and
provides a means for the UserBuilder
to call the API. We don't care how the :api_client
works, but it must conform to the interface expected by the UserBuilder
, and described in the comments.
As well as the :api_client
, the initializer also accepts a few attribtes (:name
,
:shoe_size
and :south_paw
). These are given default values if not supplied, with
some basic validation applied to the :shoe_size
attribute.
NOTE this technique of passing our :api_client
into the UserBuilder
constructor is an example of dependency-injection.
And you will see that this simple trick will make our usage and testing a lot simpler.
The #build
method exposed by the UserBuilder
class is the main method that we wish to
test. We can see that this method really just calls the :api_client
with a hash of headers and an appropriatly
constructed hash of attributes. The arguments passed to UserBuilder
undergo some simple mappings
before being passed off to the API, and this really summarizes the job of the UserBuilder
class; it takes the
raw attributes of the user and it knows how to map these to a form expected to make the API call.
We can see a simple example of how this UserBuilder
can be used in the following script:
require 'date'
require './user_builder'
dummy_client = Object.new.tap do |client|
def client.get_token
"90809080"
end
def client.create(*args)
puts "Making API call with args: #{args}"
end
end
builder = UserBuilder.new(
api_client: dummy_client,
name: "Rocky Balboa",
shoe_size: 10.5,
south_paw: true
)
builder.build
We first create a dummy_client
which conforms to the interface required for our API client. We then use
this client along with some attributes to create a new UserBuilder
instance, upon which we invoke the
#build
instance method.
So now we know what the UserBuilder
is and how to use it, we now want to look at how to
test it.
Verifying arguments using RSpec block syntax
To test the #build
method, we want to verify that the :api_client
is called with appropriate
arguments. You can see that the call to :api_client
should be sufficiently complicated so as to make you
shudder at the thought of testing the message expectations using
argument matchers.
It is certainly complicated enough to put me off. Instead we are going to look at how we can test these :api_client
calls simply by means of the RSpec block syntax.
Without further ado we'll take a look at how we can test the #build
method:
require "./user_builder"
RSpec.describe UserBuilder do
before :each do
@dummy_client = double("api client", {
create: "Done",
get_token: "90809080"
})
@params = {
api_client: @dummy_client,
name: "Apollo Creed",
shoe_size: 11
}
end
…
describe "instance method" do
before :each do
@builder = UserBuilder.new(@params)
end
describe "#build" do
it "should call the API client" do
expect(@dummy_client).to receive(:create).and_return("Done")
@builder.build
end
describe "API client call" do
it "should set headers appropriately" do
expect(@dummy_client).to receive(:create) do |headers, _body|
expect(headers["Content-Type"]).to eq "application/json"
expect(headers["Authorization"]).to eq "Bearer 90809080"
end.and_return("Done")
@builder.build
end
it "should set the body appropriately" do
expect(@dummy_client).to receive(:create) do |_headers, body|
expect(body[:name]).to eq "apollo creed"
expect(body[:shoe_size]).to eq 11.5
end.and_return("Done")
@builder.build
end
end
end
end
end
You can appreciate that the technique used hinges on setting up a message expectation on the injected API client,
@dummy_client
. The block passed to this stubbed method will be invoked when the
@dummy_client#create
method is called, and the arguments passed in that call (i.e.
headers
and body
in this case) will be accessible in our block:
expect(@dummy_client).to receive(:create) do |headers, body|
…
end
Within the block we can then set our assertions against the method arguments that are passed:
expect(@dummy_client).to receive(:create) do |headers, body|
expect(body[:name]).to eq "apollo creed"
…
end
The final step in each test is then to actually invoke @builder.build
which should trigger our stubbed
method and run our assertions.
Summary
RSpec offers block syntax which can be used in a variety of different ways. We looked at using this syntax to get a fine-grained control over verifying arguments passed to method calls. This technique is particularly useful when the arguments to a method call have a complex structure that does not lend itself to testing using regular argument matchers.
Comments
There are no existing comments
Got your own view or feedback? Share it with us below …