
I’m a huge fan of RSpec because it’s a testing framework and a specification for describing application behavior in Ruby. Test coverage — along with behavioral understanding — is a lot of value provided by a single framework (not to mention having an excellent Domain Specific Language (DSL) for writing expressive specs as well).
For today, I want to focus on test doubles only. Namely:
-
Stubs
-
Mocks
-
Spies
-
Fakes
I’ve, unfortunately, worked with a lot of test suites which confuse how test doubles are meant to be used. So I want to spend time talking about each, what they are, and why they are important (or not 😅).
Setup
Should you wish to follow along within your local development environment, feel free to use Rubysmith to automatically generate a working setup for you. Here’s how to get started (totally optional, reading along is fine too):
gem install rubysmith
rubysmith --build demo
Once generated, open demo.rb
in your editor and add the following:
require "logger"
class Demo
def initialize logger: Logger.new(STDOUT)
@logger = logger
end
def call(message) = logger.info message
private
attr_reader :logger
end
Here we are injecting a logger dependency so we can test our Demo
implementation logs messages as
desired. The Demo
implementation isn’t meant to be tremendously interesting — which is fine — but how we go about testing the implementation will be.
To complete your setup, create a demo_spec.rb
for experimentation purposes. Example:
require "spec_helper"
RSpec.describe Demo do
subject(:demo) { described_class.new logger: }
let(:logger) { Logger.new $stdout }
describe "#call" do
it "logs information" do
expectation = proc { demo.call "test" }
expect(&expectation).to output(/test/).to_stdout
end
end
end
All we’re doing is ensuring our Demo
implementation can log a specific message. I’m using a
closure (i.e. proc
) to wrap our implementation in a block so our expectation can be validated in a
single line. Otherwise, using block syntax when composing your expect
statement will span multiple
lines and gets unseemly, fast. Now everything succinctly fits on a single line for improved
readability.
By the way, if you run the above, you should see specs are green (assuming you still are following along locally, if you like).
Configuration
When it comes to configuring test doubles within RSpec, there are two important configuration settings you need:
RSpec.configure do |config|
# Truncated for brevity.
config.expect_with :rspec do |expectations|
expectations.syntax = :expect
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end
config.mock_with :rspec do |mocks|
mocks.verify_doubled_constant_names = true
mocks.verify_partial_doubles = true
end
end
The first block ensures you are using expect
syntax for all specs while the chain setting
ensures failure message include clauses from methods defined using chain
. Hopefully, you don’t
have to chain messages but, if you do, at least you’ll have more diagnostic information.
The second block ensures mocked constants and partial doubles are verified. This is the most important of the two blocks because verification ensures that — if your implementation changes but your specs aren’t updated — then your specs will fail. You definitely want this.
Test Doubles
Now that setup and configuration are out of the way, let’s talk about test doubles by starting with stubs.
Stubs
Stubs are a quick way to answer canned responses to sent messages. In our case, all we need to do is
stub the #info
message sent to the injected logger to verify our implementation. This means we can
rewrite our original spec as follows:
it "logs information" do
allow(logger).to receive(:info).with("test") { puts "test" }
expectation = proc { demo.call "test" }
expect(&expectation).to output(/test/).to_stdout
end
In this case I’ve allowed the logger to receive an #info
message with the string "test"
and
print to the console. Normally, you’d allow your stub to return a message using #and_return
.
Example:
allow(logger).to receive(:info).with("test").and_return("test")
…but since we are testing that messages are logged to the console, we can use a block to mimic expected behavior instead.
Stubs are also known as partial doubles because they are combination of original implementation plus mocked behavior. They are also considered a code smell because they are a superficial way to test your implementation while masking over a deeper implementation flaw which probably needs to be refactored. This is why I seldom use stubs. So let’s talk about mocks next to improve upon this further.
Mocks
Mocks allow you to test for specific behavior or throw an error otherwise. They come in three flavors:
-
double
: A non-verifying mock and not recommended because if your implementation changes your spec will still pass. -
class_double
: A verifying mock for classes only. -
instance_double
: A verifying mock for instances only.
All of the above works the same, syntactically, but since we’ve injected an instance of our logger
into our implementation, I’ll only demo the use of an instance_double
. Here’s how the spec changes
when using mocks:
RSpec.describe Demo do
subject(:demo) { described_class.new logger: }
let(:logger) { instance_double Logger }
describe "#call" do
it "logs information" do
expect(logger).to receive(:info).with("test")
demo.call "test"
end
end
end
Notice how I’ve changed the logger
to be an instance double (mock) of the Logger
class in the
let
statement and then, in the spec, I expect the logger to receive a message before messaging
our implementation. There are couple important observations I want to highlight with this approach:
-
This spec uses fewer lines of code than our earlier, stubbed, spec which is definitely nice.
-
This spec, unfortunately, reads backwards because we must set up our expectation before we message our implementation.
Due to fact that our spec reads backwards, this is why I avoid using mocks because reading specs backwards — starting from the bottom of the spec and reading upwards — is not normal and also jarring when quickly attempting to grok what is going on.
This brings us to my personal favorite: spies.
Spies
Like mocks, spies record received messages and error if not recorded. They come in three flavors as well:
-
spy
: A non-verifying spy and not recommended because if your implementation changes your spec will still pass. -
class_spy
: A verifying spy for classes only. -
instance_spy
: A verifying spy for instances only.
Since we’re only dealing with an instance of a logger class in our implementation I’ll use the
instance_spy
for demonstration purposes but the syntax is the same for all three.
Here’s how we update the spec to use a spy:
RSpec.describe Demo do
subject(:demo) { described_class.new logger: }
let(:logger) { instance_spy Logger }
describe "#call" do
it "logs information" do
demo.call "test"
expect(logger).to have_received(:info).with("test")
end
end
end
Notice how elegant the above is and uses less code than the mock example. The differences might are subtle but powerful:
-
The
instance_double
is replaced withinstance_spy
to build a spy of the Logger class. -
The spec reads sequentially instead of backwards (unlike with the earlier mock). First we message our implementation and then we check the logger got the intended message. Very readable and natural.
-
The
have_received
message is used instead ofreceived
. This is an important distinction when using spies because you are checking that the message was received after the fact. Plus, it reads logically in terms of past tense which is nice.
Due to the above reasons, spies are far more superior than mocks in this regards and definitely encourage using them over mocks.
There is one last test double to talk about and that’s fakes.
Fakes
Technically, fakes, are not specific to RSpec but I want to talk about them because they are often overlooked when talking about test doubles and good to have in your arsenal of testing tools. They do require a bit more setup and maintenance, though.
Fakes are a way to swap out an existing implementation with an identical Object API which is great for speeding up your test suite when your original implementation might be CPU or I/O intensive. For example, making API requests to third party services or any process that is slow to respond, occasionally times out, etc.
In our case, our logger isn’t CPU or I/O intensive but we can still make a fake of it. Here’s what a very naive fake logger would look like:
require "spec_helper"
RSpec.describe Demo do
subject(:demo) { described_class.new logger: logger.new(STDOUT) }
let :logger do
Class.new do
def initialize io
@io = io
end
def debug(...) = io.puts(...)
def info(...) = io.puts(...)
def warn(...) = io.puts(...)
def error(...) = io.puts(...)
def fatal(...) = io.puts(...)
def unknown(...) = io.puts(...)
private
attr_reader :io
end
end
describe "#call" do
it "logs information" do
expectation = proc { demo.call "test" }
expect(&expectation).to output(/test/).to_stdout
end
end
end
⚠️ For simplicity, I’ve inlined the fake logger within an anonymous class via the let
statement.
I’ve also made the implementation be a simple pass though to any I/O object but avoided handling
block syntax. Fleshing out more of those details is out of scope for this article. The point is only
to showcase the pattern of a fake object. Lastly, for improved reusability, I’d recommend swapping
out the let
statement for a properly named FakeLogger
class which you can reuse in multiple
specs.
That aside, you’ll notice that our spec is back to testing our injected logger dependency — as first shown at the start of this article. At this point, you could swap out the fake logger with the real logger and see no change in spec behavior. That is the beauty of using a fake but, as mentioned earlier, it does come with a maintenance cost so only use fakes when you need them for CPU or I/O intense situations.
Conclusion
I hope you’ve enjoyed this brief tour of stubs, mocks, spies, and fakes — also known as test doubles — in RSpec. I also hope this demystifies any confusion you might have when it comes to reaching for any one of these test doubles too.
Finally, always reach for a spy first and a fake second. Now you can ignore stubs and mocks for a much easier to read and maintain test suite. May your test suite be a joy to work with. 🎉