Replacing Mocks with Spies

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.

  1. 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.

  2. Next, we create a mock of the PaymentGateway, and set the expection about how charge 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.

  3. 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:

  1. Instead of using Mockery’s mock method, use the spy method:

     $paymentGateway = Mockery::spy('PaymentGateway');
    
  2. Instead of using shouldReceive in the arrange step, use shouldHaveReceived 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.