Be careful with the mocks
Mocking objects is a common practice when writing tests, however it can be painful when refactoring a class tested with mocks. I will show a simple example to explain the problems that mocking can generate
Mocking objects is a common practice when writing tests, however it can be painful when refactoring a class tested with mocks. I will show a simple example to explain the problems that mocking can generate, but first I would like to give some definitions:
- Stubs are objects that respond to a subset of messages from the object to be stubbed with specific responses.
- Mocks are objects that verify the integration between the system under test and the mocked object.
- Fake are objects with implementation that simulate the real one but they are simpler and/or faster.
Suppose we have the next class for sending email notifications to an user of our system:
class EmailNotifier {
def initialize(email_client, from_email) {
@email_client = email_client
@from_email = from_email
}
def notify(an_user, a_message) {
@email_client.send(@from_email, an_user.email, a_message)
}
}
There are some different ways to write the tests for the notify
method. Maybe the easiest way of doing it is using mocks:
describe EmailNotification do
describe :notify do
it 'sends an email to the given user with the given message' do
from_email = "server@test.com"
user_email_address = "user@test.com"
user = double("User", email: user_email_address)
message = "Message"
email_client = double('EmailClient')
expect(email_client).to receive(:send).with(from_email, user_email_address, message).and_return(:success)
notifier = EmailNotifier.new email_client, from_email
notifier.notify(user, message)
end
end
end
On this test we have created one stub for User and a mock for EmailClient. In order to check if an email is sent, we defined the expectations knowing the implementation should call the send message, but by doing this we are writing tests that are strongly coupled to their method implementation because it is expecting notify
to send the message send
to email_client
. So, in my opinion, testing a message in this way has the next problems:
- It is testing HOW the method is implemented instead of WHAT the method should do, which may result on test failures if we change the code. Instead of testing the
send
message is called, we should be testing an email has been sent. - It forces you to write the expected behavior of the mocked class in each test, which distracts you from the test itself when reading and writing the test, and also, it may increase the difficulty of understanding it.
- The tests are hard to maintain, changing a message of the mocked class will require to change all the places in which that message was mocked. If one of the tests that mocks this message is not changed, it may generate a false positive, for example, if you remove the message
send
from EmailClient, the mock wouldn't fail, so the test will pass.
One option to solve the first 2 problems and reduce the impact of the last one can be to use a Fake object. In this case, we should write a FakeEmailClient, which should respond to the same messages of the real EmailNotifier, and also it should have a message to test the sender, the receiver, the message and if an email has been sent. Creating a FakeEmailClient won't solve the false positive problem, but it will be easier to fix because you should only update the fake object instead of all the tests where the EmailClient is used.
Let's assume the EmailClient class has a constructor without parameters, and it only has the send(from, to, message)
method. In that case, the Fake Class would be:
class FakeEmailClient {
def send(sender_address, to_address, message) {
@last_sender_address = from_address
@last_receiver_address = to_address
@last_message_sent = message
}
attr_reader :last_sender_address, :last_receiver_address, :last_message_sent
def assert_last_email_received(from, to, message)
expect(@last_sender_address).to eq(from_address)
expect(@last_receiver_address).to eq(to_address)
expect(@last_message_sent).to eq(message)
end
This Fake object should be used in every test that needs to use an EmailClient, and if you need to test if an email has been sent, you can ask it to the fake client.
describe EmailNotification do
describe :notify do
it 'sends an email to the given user with the given message' do
from_email = "server@test.com"
user_email_address = "user@test.com"
user = double("User", email: user_email_address)
message = "Message"
email_client = FakeEmailClient.new
notifier = EmailNotifier.new email_client, from_email
notifier.notify(user, message)
email_client.assert_last_email_received(from_email, user_email_address,message)
end
end
end
To conclude, I am not saying that mocks are useless, but I think that avoiding them whenever is possible makes the tests safer and easier to understand and maintain.