Once in a while I run into a situation where trying to use a mocking library hurts the readability of my test.
For example, say I'm building out a basic user registration flow where someone signs up and receives a welcome email:
class UserRegistrationController extends Controller
{
public function store()
{
$this->validate(request(), [
'name' => ['required'],
'email' => ['required', 'email'],
'password' => ['required'],
]);
$user = User::create(request()->only('name', 'email', 'password'));
Mail::send('emails.welcome', ['name' => $user->name], function ($m) use ($user) {
$m->to('john@example.com')->subject('Welcome to my app!');
});
return redirect()->home();
}
}
To test that an account is created correctly, I can make a request to the endpoint and verify that the new account exists in a test database:
class UserRegistrationTest extends TestCase
{
use DatabaseMigrations;
public function test_user_is_created_when_registering()
{
$this->post('register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'secret',
]);
$user = User::first();
$this->assertEquals('John Doe', $user->name);
$this->assertEquals('john@example.com', $user->email);
$this->assertTrue(Hash::check('secret', $user->password));
}
}
This covers creating the account itself, but what's the best way to test the welcome email?
Using a Mocking Library
One approach would be to use a library like Mockery.
By replacing the Mailer
with a mock or a spy, I can set expectations about which methods should be called and with what parameters. If those expectations aren't met, the test fails.
This is easy enough for the first two parameters to Mail::send()
:
public function test_new_users_are_sent_a_welcome_email()
{
$mailer = Mockery::spy('Illuminate\Contracts\Mail\Mailer');
Mail::swap($mailer);
$this->post('register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'secret',
]);
$mailer->shouldHaveReceived('send')->with(
'emails.welcome',
['name' => 'John Doe'],
/* But what goes here? */
);
}
...but it gets tricky when I want to make assertions about what happens inside the callback that is passed as the third parameter.
The best I've come up with so far is to pass a custom matcher using Mockery::on()
, create another spy to stand in for the message itself, and set expectations on the message to make sure the right methods are called with the right parameters:
public function test_new_users_are_sent_a_welcome_email()
{
$mailer = Mockery::spy('Illuminate\Contracts\Mail\Mailer');
Mail::swap($mailer);
$this->post('register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'secret',
]);
$message = Mockery::spy();
$message->shouldReceive('to')->andReturn($message);
$mailer->shouldHaveReceived('send')->with(
'emails.welcome',
['name' => 'John Doe'],
Mockery::on(function ($callback) use ($message) {
$callback($message);
return true;
})
);
$message->shouldHaveReceived('to')->with('john@example.com');
$message->shouldHaveReceived('subject')->with('Welcome to my app!');
}
Look confusing? It is!
There are some real disadvantages to this approach:
- The test feels like it's mirroring too much information about how
send()
is called. - The test is coupled to implementation details like whether
to()
orsubject()
gets called first on the message. - It's confusing as hell.
Mockery is a fantastic tool but when a test starts to look like this, I know it's time to reach for something else.
Actually Sending Emails
When mocking gets ugly, the next thing I usually try is integrating with the real collaborator.
In this case I could use a service like Mailtrap, a fake SMTP server for developers that can receive real email.
I could build a small wrapper around their API and use that to clear the inbox at the beginning of the test, then make assertions about the contents at the end of the test.
This would work, and it would give me a lot of confidence that my code would do what I expect in production, but it comes at a cost:
- The tests would be very slow.
- They couldn't run without an internet connection.
- I would be at the mercy of any system interruptions at Mailtrap.
- I might run into race conditions if I'm checking the inbox before Mailtrap has processed the email.
I don't mind integrating against things I can run locally (like a database), but integrating with an external service is usually a last resort.
Writing a Custom Fake
People talk a lot about mocks and stubs, but you don't hear as much about fakes. XUnit Patterns defines a fake as:
A much simpler and lighter weight implementation of the functionality provided by the depended-on component without the side effects we choose to do without.
Fakes have a few key benefits:
- They're fast.
- They don't leak implementation details into the test.
- They can expose inspection methods to make assertions against.
Here's what the test might look like with a fake:
public function test_new_users_are_sent_a_welcome_email()
{
$mailer = new InMemoryMailer;
Mail::swap($mailer);
$this->post('register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'secret',
]);
$this->assertTrue($mailer->hasMessageFor('john@example.com'));
$this->assertTrue($mailer->hasMessageWithSubject('Welcome to my app!'));
}
Minimal noisy setup, and very easy to understand what's being tested!
The implementation of the fake could look something like this:
class InMemoryMailer
{
private $messages;
public function __construct()
{
$this->messages = collect();
}
public function send($template, $data, $callback)
{
$message = new Message($template, $data);
$callback($message);
$this->messages[] = $message;
}
public function hasMessageFor($email)
{
return $this->messages->contains(function ($i, $message) use ($email) {
return $message->to == $email;
});
}
public function hasMessageWithSubject($subject)
{
return $this->messages->contains(function ($i, $message) use ($subject) {
return $message->subject == $subject;
});
}
}
class Message
{
public $template;
public $data;
public $to;
public $subject;
public function __construct($template, $data)
{
$this->template = $template;
$this->data = $data;
}
public function to($email)
{
$this->to = $email;
return $this;
}
public function subject($subject)
{
$this->subject = $subject;
return $this;
}
}
The Trade-Offs
Fakes aren't a perfect solution to every problem, and come with a few significant costs:
- You need to write another implementation of a collaborator from scratch.
- You need to be careful to keep the API in sync with the real collaborator.
Closing Tips
Test your fakes.
Fakes are real objects that do real things, so treat them as first-class objects in your application and write tests for them.
Faking complex APIs is hard.
Trying to fake an entire web API is really hard. If something is hard to fake, see if you can write a wrapper to limit the API to just what your application needs.
Prefer the real collaborator if it's cheap.
If using the real collaborator is fast and stable, use the real collaborator. Fakes are a wonderful tool, but it still won't give you as much confidence as testing with the real thing.
Trying to wrap your head around testing? Test-Driven Laravel is a course I recently launched that teaches you how to TDD an app from start to finish. Learn more about it here.