Honeybadger Staff
Posted on September 22, 2022
This article was originally written by Abiodun Olowode on the Honeybadger Developer Blog.
Generally speaking, a mock is a replica or imitation of something. RSpec mocks, in the same sense, are an imitation of return values or method implementations. The ability to carry out this kind of imitation makes it possible to set expectations that specific messages are received by an object. Amazing, right? 😃
RSpec mocks create test doubles with which we can implement our imitations, although these imitations can also be carried out on objects that are part of one's system.
What are Test Doubles?
According to the book xUnit Patterns, which I believe offers a perfectly good explanation, test doubles can be likened to a stunt double.
When the movie industry wants to film something that is potentially risky or dangerous for the leading actor to carry out, they hire a "stunt double" to take the place of the actor in the scene. The stunt double is a highly trained individual who is capable of meeting the specific requirements of the scene. They may not be able to act, but they know how to fall from great heights, crash a car, or whatever the scene calls for. How closely the stunt double needs to resemble the actor depends on the nature of the scene. Usually, things can be arranged such that someone who vaguely resembles the actor in stature can take their place.
For example, if one needs to make a call to an external API in a certain class, it's very likely that during testing, one will want to avoid this API call. In this case, a "stunt double" can be created to simulate the API response.
Creating my First Test Double
Let's start by creating two new files named crypto.rb
and trader_spec.rb
within a folder named rspec_mocks
. Within this folder, we install the needed dependencies.
gem install rspec # for rspec-core, rspec-expectations, rspec-mocks
gem install rspec-mocks # for rspec-mocks only
In our trader_spec.rb
file, let's create tests that determine whether a certain crypto trader with a current coin worth is able to buy a new coin and double that coin's value by partaking in a promo. Of course, this would, in turn, lead to an increase in his total coin worth.
# trader_spec.rb
require_relative 'crypto'
describe Trader do
before(:each) do
@trader = Trader.new(50)
end
describe '#partake in promo' do
it 'should increase his coin worth' do
new_coin = Coin.new(10)
@trader.partake_in_promo(new_coin)
expect(@trader.coin_worth).to eq(70)
end
end
end
In the above test, we are testing that a trader is created with an initial coin worth 50
; he purchases a coin of value 10
and partakes in a promo that doubles the coin value to 20
which, in turn, leads to a total coin worth of 70
(50 + 20).
Within the rspec_mocks folder, let's run our tests using the command rspec trader_spec.rb
. Without a trader class and any methods, our test returns an error, of course. Now, let's proceed to write the code that makes this test pass.
In our crypto.rb
file, let's create the trader class as follows:
class Trader
attr_accessor :coin_worth
def initialize(coin_worth)
@coin_worth = coin_worth
end
def partake_in_promo(coin)
coin_new_value = coin.win_promo
@coin_worth += coin_new_value
end
end
We also have to create the corresponding Coin class:
class Coin
attr_accessor :value
def initialize(value)
@value = value
end
def win_promo
@value = @value * 2
end
end
Running our test at this point gives an all green.
However, when testing, we do not want our tests to fail or pass based on the behavior of an object outside the scope of the class being tested. For example, if the win_promo
method changes within the Coin
class, and value * 2
becomes value + 2
, our tests fail. This is discouraging because the failure is only due to an implementation change within a dependency rather than any inconsistencies on the part of the Trader class. The test for the coin value doubling should be left for a Coin spec and not done within the Trader spec so that we're able to keep our tests within scope.
Let's go further and create a double for our coin by replacing new_coin = Coin.new(10)
with new_coin = double('Coin')
. Running our test again gives the following error:
This error tells us that our double has no access to a certain win_promo
method; this is why it is called an unexpected message. As a result, we need to pass this message to our double and signify the expected response, which is 20 in our case.
new_coin = double('Coin', :win_promo => 20)
Running our test again gives us a green 💪.
Let's carry out a little experiment. Let's go ahead and change the win_promo
method name to win_a_promo
. Surprisingly, our tests still pass, but with a real object, we should get a no_method
error. This happens because doubles
are not an exact replica of the object they imitate. However, RSpec provides the instance_double
method, which verifies whether doubles are an exact representation of the object they are "impersonating".
Using the instance_double
method, we have the following:
new_coin = instance_double('Coin', :win_promo => 20)
Running our test generates the following error:
Hence, we can test an object in scope without testing the behavior of dependencies and still validate them against real objects such that they are throwing the same errors expected of real objects. Reverting the method name to win_promo
causes the tests to pass.
Method Stubs
A method stub is an instruction to an object (real or test double) to return a specific value in response to a message. Let's put this into action by trying to withdraw some coins. In our case, before any withdrawal, we should ensure that a trader is verified.
describe "withdraw some coins" do
it 'should be successful if trader is verified' do
allow(@trader).to receive(:verified?) { true }
@trader.withdraw(20)
expect(@trader.coin_worth).to eq(30)
end
it 'should not be successful if trader is unverified' do
allow(@trader).to receive(:verified?) { false }
@trader.withdraw(20)
expect(@trader.coin_worth).to eq(50)
end
end
Above, we have written a test case for a successful withdrawal when a trader is verified, as well as an unsuccessful one, when he is not. The statement allow(@trader).to receive(:verified?) { false }
translates to allowing the trader object to receive the message verified?
, which in turn calls a verified?
method and returns a response of false
. What this means is that we are dictating the result of a particular method called on an object or a double. Running our tests now would cause a failure because we have not implemented the withdraw
method for the Trader class.
The ensuing code can be thus:
# in crypto.rb, within the trader class
def withdraw(value)
return unless verified?
@coin_worth -= value
end
When we run our tests, they pass, even without an actual verified?
method in our Trader class. This is because we have specified in our stub, the method name and the result to be returned; hence, RSpec sees no need to call that method within the class to obtain a result.
In real-life cases, though, this method would exist, and maybe within it, there may be other dependencies totally unrelated to the class being tested. Hence, we can say that method stubs are especially useful when we have other dependencies that we do not want tested, such as external APIs. We can skip testing these by dictating a known value as the result of that method call thanks to method stubs!!
Message Expectations
The terms message and method are often used synonymously, but they do have a slight difference. In object-oriented programming, objects communicate by sending messages to one another. When an object receives a message, it invokes a method with the same name as that message. An example is shown below:
class User
def save
end
end
When an instance of the class User
called user
is created, we can pass a save
message to it via user.save
. This in turn calls the save
method defined within the User
class.
A message expectation is an expectation that an object should receive a certain message before the example ends. Similar to the user.save
example, one would be testing that the user
object receives a save
message. Let's implement this with our trader class.
Remember our verified?
method? Let's go ahead and actually implement the verification. Let's assume that during sign up as a trader, a verification center has to be picked, where one would submit all his documents and also carry out some biometric analysis. This external service would be responsible for checking that a trader has been verified (i.e., submitted all documents and done his biometrics).
Our before_each
method would change to incorporate the new changes:
before(:each) do
@verification_center = VerificationCenter.new
@trader = Trader.new(50, @verification_center)
end
We should send a verify
message to the verification center with the trader to be verified as an argument. The corresponding test would be as follows:
describe 'verification is carried out' do
it 'should call the verify method to verify a trader' do
expect(@verification_center).to receive(:verify).with(@trader)
@trader.verified?
end
end
In the above test, we're saying that when the verified?
message is sent, we expect that the verified?
method should send a verify
message to the verification center with the trader as an argument. If this statement is not quite clear, check above and read about messages and methods.
To make this test pass, we would implement the following changes to our code:
attr_accessor :coin_worth, :verification_center
def initialize(coin_worth, verification_center)
@coin_worth = coin_worth
@verification_center = verification_center
end
def verified?
@verification_center.verify(self)
end
Let's add the verification center class:
class VerificationCenter
def verify(trader)
# checks if the trader is verified using an external api
end
end
Now, our tests pass. We have been able to confirm that the verification center received the verify
message with the trader as an argument. This is useful because we are not concerned with testing the verification center class at this time; we just test that the message was received and the right arguments were sent.
If we want to set a response other than nil
from the verify message within our test, it can be carried out using and_return
or passing the response as a block.
expect(@verification_center).to receive(:verify).with(@trader).and_return("return value")
expect(@verification_center).to receive(:verify).with(@trader) {"return value"}
When could setting responses be needed? If we wanted to test that we do not try to verify a trader more than once during a particular session, we can check that the verify
method is called only once.
describe 'verification is carried out' do
it 'should call the verify method to verify a trader' do
expect(@verification_center).to receive(:verify).with(@trader).and_return(true).once
@trader.verified?
@trader.verified?
end
end
Running the test now yields the following:
To make this test pass, it is imperative that we update our verified?
method to use an instance variable if it has been previously called.
def verified?
@verified ||= @verification_center.verify(self)
end
Our tests pass now because by returning true
, we assign a value to our instance variable @verified
. We make just one trip to the verification center, and we can test that this happens using message expectations.
Test Spies
With message expectations, we talk in terms of the future; we already declare our expectation of the message to be received, and then we carry out the actions that would make that possible. This is why we have the expect
statement before the trader.verified?
action. What if we wanted to carry out the actions first, and then spy on the object during the process to enable us to check that messages were received with an accurate count?
This is where test spies come in; as opposed to testing that an object will receive a message, we test that an object received a message. To do this (verify that a message was received), the given object must be setup to spy on it.
Implementing the previous test using spies, we would create a spy verification center and specify a return value for the message verify
.
describe 'verification is carried out' do
it 'should call the verify method to verify a trader' do
verification_center = spy("Verification center", :verify => true)
@trader.verification_center = verification_center
@trader.verified?
@trader.verified?
expect(verification_center).to have_received(:verify).once.with(@trader)
end
end
As seen above, test spies determine whether a message was received, while message expectations test whether a message will be received.
Conclusion
RSpec gives us a wide range of tools that enable us to test the behavior of our objects. Mocks are useful in testing the interaction between objects, verifying what messages are sent or not sent, and ensuring that they communicate as intended. Stubs are particularly useful in imitating a return value of objects when you're more interested in their results rather than their behavior, such as an external API. Spies also test the interaction between objects but carry out checks that events happened rather than testing that they will happen sometime in the future. Being able to identify, at all times, what behaviors are to be tested and their dependencies, as well as whether testing those dependencies are relevant, goes a long way towards helping determine what tool to use each time.
Posted on September 22, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.