Mock objects are useful when verifying that a method was called is more important than verifying the outcome of calling that method.
Completing a Reservation
For example, in the Test-Driven Laravel course I’m working on, I have a Reservation
class that represents some concert tickets that are being held for a user until they complete their purchase.
When you call the complete
method on a Reservation
, you pass it a PaymentGateway
and a paymentToken
, which the reservation uses to charge the customers card and finalize their order:
class Reservation
{
public $tickets;
public $email;
public function __construct($tickets, $email)
{
$this->tickets = $tickets;
$this->email = $email;
}
public function complete($paymentGateway, $paymentToken)
{
$charge = $paymentGateway->charge($this->totalPrice(), $paymentToken);
return Order::forReservation($this, $charge);
}
private function totalPrice()
{
return $this->tickets->sum('price');
}
}
Setting a Mock Expectation
Since the PaymentGateway
has its own tests to verify that it actually charges the customer’s card, our unit tests for the Reservation
class can use a mock object to make sure that the charge
method on the PaymentGateway
is called with the right parameters:
class ReservationTest extends PHPUnit_Framework_TestCase
{
function test_the_reservation_is_completed_successfully()
{
$tickets = collect([
['price' => 1250],
['price' => 1250],
['price' => 1250],
]);
$paymentGateway = Mockery::mock('PaymentGateway');
$paymentGateway->shouldReceive('charge')->with(3750, 'tok_valid-token')->once();
$reservation = new Reservation($tickets, '[email protected]');
$order = $reservation->complete($paymentGateway, 'tok_valid-token');
}
function tearDown()
{
Mockery::close();
}
}
Here we create a mock of the PaymentGateway
class using:
$paymentGateway = Mockery::mock('PaymentGateway');
…and set a mock expectation when we say:
$paymentGateway->shouldReceive('charge')->with(3750, 'tok_valid-token')->once();
If for whatever reason charge
isn’t called or is called with the wrong parameters, the test will fail and we’ll know we have a mistake somewhere in our code.
The Phases of a Test
Mocks are a great tool, but it’s always bugged me that they force you to set expectations in advance instead of making assertions at the end like you would in a traditional test.
Let me show you what I mean.
Imagine we were writing a test for PHP’s strrev
function, which takes a string and reverses it.
Traditionally, our test would have three phases:
Arrange, Act, and Assert.
Phase 1: Arrange
This is where we put together all of the necessary preconditions and inputs for our test.
For testing strrev
, that might mean just specifying the string that we want to reverse:
function test_it_reverses_the_string()
{
// Arrange
$input = "forwards";
}
Phase 2: Act
The Act phase is where we actually do the work that we’re trying to test.
In our case, that means reversing the string:
function test_it_reverses_the_string()
{
// Arrange
$input = "forwards";
// Act
$output = strrev($input);
}
Phase 3: Assert
Assert is where we make assertions about what happened, and verify that we got the outcome we expected.
For this test, that means verifying that our input was properly reversed:
function test_it_reverses_the_string()
{
// Arrange
$input = "forwards";
// Act
$output = strrev($input);
// Assert
$this->assertEquals("sdrawrof", $output);
}
Mixing Setup and Assertions
If you try and label our test for the Reservation
class with these three phases, things don’t quite line up.
Let’s break it down section by section.
-
First, we create the tickets for the reservation:
$tickets = collect([ ['price' => 1250], ['price' => 1250], ['price' => 1250], ]);
This is definitely an arrange step, so we’re good so far.
-
Next, we create a mock of the
PaymentGateway
, and set the expection about howcharge
should be called:$paymentGateway = Mockery::mock('PaymentGateway'); $paymentGateway->shouldReceive('charge')->with(3750, 'tok_valid-token')->once();
Creating the mock itself definitely feels like an arrange step, but setting the expectation is sort of a combination of arrange and assert, since that expection will trigger a test failure if it’s not met.
-
Finally, we create the reservation and
complete
it using our mock:$reservation = new Reservation($tickets, '[email protected]'); $order = $reservation->complete($paymentGateway, 'tok_valid-token');
This is our act phase.
Notice that there’s no distinct assert phase at the end?
Adding an Assertion
When the mock expectation is the only thing we’re verifying, it might not seem like a big deal to deviate from a traditional test structure.
But what if we also wanted to verify that the email address in the reservation was associated correctly with the final order?
class ReservationTest extends PHPUnit_Framework_TestCase
{
function test_the_reservation_is_completed_successfully()
{
// Arrange
$tickets = collect([
['price' => 1250],
['price' => 1250],
['price' => 1250],
]);
// Arrange/Assert
$paymentGateway = Mockery::mock('PaymentGateway');
$paymentGateway->shouldReceive('charge')->with(3750, 'tok_valid-token')->once();
// Act
$reservation = new Reservation($tickets, '[email protected]');
$order = $reservation->complete($paymentGateway, 'tok_valid-token');
// Assert
$this->assertEquals('[email protected]', $order->email);
}
function tearDown()
{
Mockery::close();
}
}
Now we have multiple assertions scattered across different parts of the test, which makes it a lot more difficult to understand at a glance.
Switching to Spies
A spy is a special type of mock object that allows you to verify that a method was received instead of setting an expectation that it should be received.
It’s a subtle difference, but it can really clean up the structure of our test.
To use a spy instead of a mock, we just need to make two small changes:
-
Instead of using Mockery’s
mock
method, use thespy
method:$paymentGateway = Mockery::spy('PaymentGateway');
-
Instead of using
shouldReceive
in the arrange step, useshouldHaveReceived
the assert step:$paymentGateway->shouldHaveReceived('charge')->with(3750, 'tok_valid-token')->once();
Here’s what the test looks like after making those changes:
class ReservationTest extends PHPUnit_Framework_TestCase
{
function test_the_reservation_is_completed_successfully()
{
// Arrange
$tickets = collect([
['price' => 1250],
['price' => 1250],
['price' => 1250],
]);
$paymentGateway = Mockery::spy('PaymentGateway');
// Act
$reservation = new Reservation($tickets, '[email protected]');
$order = $reservation->complete($paymentGateway, 'tok_valid-token');
// Assert
$this->assertEquals('[email protected]', $order->email);
$paymentGateway->shouldHaveReceived('charge')->with(3750, 'tok_valid-token')->once();
}
}
We have distinct arrange, act, and assert phases now, and all of our assertions are grouped together in the same place. Great!
The Trade-Offs
The only real gotcha when using spies is that they happily swallow every method call you make to them, even if you haven’t whitelisted that method in advance.
With a mock, trying to call any method that hasn’t been specified in advance will cause a test failure, but with a spy, you need to be explicit if you want to make sure a method was not called:
class ReservationTest extends PHPUnit_Framework_TestCase
{
function test_the_reservation_fails_to_complete()
{
// Pretend we set something up that would cause the reservation to fail
$paymentGateway->shouldNotHaveReceived('charge');
}
}
Overall I’ve found I can use spies instead of mocks basically 100% of the time, and end up being much happier with my tests as a result.
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.