Using different type of test double in PHPUnit


I would like to start this article with an example which has been taken from a real project, I usually do like to talk about real examples instead of using FOO|BAR example.

Before we start to explain different types of test double, I would like to explain what is the purpose of using them in the testing environment. When we start writing tests we are facing some situations which we need to avoid calling or using the real THING and we should also replace that thing which it could be (Class, method, endpoint, package) with something else that we are calling it Test Double.

For example, when we want to write a test for the endpoint which sends an email to subscribed users, We can't write a test and in that test make that endpoint send a real email, there are a couple of reasons that we need to be careful about it. 

  • Maybe that user does not even belong to your system (Because you are creating fake users in testing environment)
  • You are using your Mail server every time you run the tests, so that also costs you money and after some time you'll see you have sent a lot of emails without any reason.
  • Also, it may happen that those random emails which you are creating them in your tests which could be real users, could complain about the sender address and eventually your mail server could get blocked.

The above example was a very superficial example, This could happen in charging the user for a specific amount or upload the user profile image to s3. So there are dozens of situations that may harm your business if you don't use Test Double.

Different type of test Double

  • Test stub
  • Test spy
  • Mock Object
    • Partial Mocking
  • Fake Object

Test stub

Test stub is a way to replace the real component with the fake one which you have created. so that the test has a control point over the inputs.

Let's start with an example, Let's assume we have a video casting web application which we are casting tutorials and a lot of bunch of other videos to the public.

We have 3 types of users who could access these videos, Those types are (silver, bronze, golden). when a user tries to register for the first time he/she as default has the silver type, then they can upgrade their account to access more videos and tutorials.

The application has implemented in a way that the user financing operation is isolated in a different service. so you don't have any information about the user balance and other stuff in your system. you need to call that other third party API to get more information about the user balance or credits or debits. Now we have a method like the following code which the user can use it to upgrade his account from silver to bronze or golden.

/**
* @var FinanceServiceInterface
*/
private $financeService;

public function __construct(FinanceServiceInterface $financeService)
{
    $this->financeService = $financeService;
}

public function upgrade(UpgradeUserRequest $request)
{
    $user = Auth::user();

    $balance = $this->financeService->getBalance($user);

    if($balance < config::get('upgrade.'.$request->type.'.price')){
        PaymentService::charge($user, $balance - config::get('upgrade.'.$request->type.'.price'));
        $balance = 0;
    }else{
        $balance -= config::get('upgrade.'.$request->type.'.price'); 
    }

    if(!$this->financeService->updateBalance($user, $balance)){
        return Response::json(['success'=>false], 400);
    }

    $user->update(['type'=>$request->type]);

    event(UserAccountUpgraded::class);

    return Response::json(['success'=>true]);
}

As you can see we are getting the parent balance using the financeService class, then we charge the parent if the parent does not have that much balance. After that, we update the parent balance using the financeService and finally, we are calling UserAccountUpgraded event which sends email and SMS to the user to notify him about his purchase.

I usually start the example with the test but this time it's a little a bit tricky and we need to understand what's going on in the code so I thought it's better to have the code first so you can have a brief overview of what's going on.

/**
* @test
*/
public function upgrade_should_return_success_if_type_was_bronze()
{
    Event::fake();

    Config::set('upgrade.bronze.price', 400);    

    // mock user
    $user = factory(User::class)->create(['id'=>1]);
    Auth::shouldReceive('user')->andReturn($user);

    // swap the financeService class with StubFinanceService
    $this->app->instance(FinanceServiceInterface::class, StubFinanceService::class);

    // resolve the userController
    $userController = $this->app->make(UserController::class);

    $userRequest = new UpgradeUserRequest(['type'=>'bronze']);

    // Call upgrade
    $response = $userController->upgrade($userRequest);

    $this->assertEquals($response->getStatusCode(), 200);
}

As you can see in the above code, I have started the test with faking the event and then create a new user with an ID of 1 and made the auth facade returns that specific user. then swap the real financeService with the StubFinanceService. And the rest of the code which is clear. But now maybe you ask your self what will be in the StubFinanceService, StubFinanceService class code is as the following

class StubFinanceService implements FinanceServiceInterface
{

    public function getBalance(User $user)
    {
        if($user->id == 1){
            return 500;
        } else {
            return -200;
        }
    }

    public function updateBalance(User $user, int $newBalance)
    {

        if($user->id == 1 && $newBalance == 100){
            return true;
        } elseif($user->id != 1 && $newBalance >= 0){
            return true;        
        }

        return false;
    }
}

In the above StubFinanceService class, you can see we have two methods, the first method is getBalance which it retrieves the user balance, As you remember in the test code I have created a user with an id of 1, this way we are controlling the input, now if the SUT (System Under Test) trying to get the balance for the user with an id of 1, 500 will be returning for it. in the other hand we are setting the config.bronze.price to 400 and if we deduct them from each other 100 will remain. As you can see in the updateBalance From StubFinanceService class, we are checking if the balance is equal to 100 and the id is 1 which the results will be true. So now we can understand how to create a stub and what is the control point over it. 

Test spy

Why we should use spy over mock? before we start I should say that spy is one type of mocking but with less striction. since in spy we are not expecting all methods to return the exact value, we only want to test one specific method from that specific class, PHPUnit will automatically replace the rest of the method with dummy method which finally they will return null unless you specify what value should be returned from them. Now Let's try the same test code but this time using spy.

/**
* @test
*/
public function upgrade_should_call_getBalance_with_specific_user_input()
{
    // mock user
    $user = factory(User::class)->create(['id'=>1]);
    Auth::shouldReceive('user')->andReturn($user);

    // swap the financeService class with StubFinanceService
    $this->app->instance(FinanceServiceInterface::class, \Mockery::spy(FinanceServiceInterface::class, function($spy) use($user){
        $spy->shouldReceive('getBalance')->once()->with($user);
    }));

    $userController = $this->app->make(UserController::class);

    $userRequest = new UpgradeUserRequest(['type'=>'bronze']);
    $userController->upgrade($userRequest);
}

As you can see in the above code, we are binding the FinanceServiceInterface To the spy object and we are expecting the getBalance method to get called with specific input. so when we are calling upgrade method from UserController class Automatically the spy object gets resolved from the container and SUT will use the spy object instead of the real object. We can also move the shouldReceived method after calling the upgrade method and we can also use shouldHaveBeenReceived as it mentioned in the documentation. 

If you want to read more about test spies, you can check PHPUnit official documentation

Mock Object

The mock object it's like spy the only difference is that we are expecting some values from the method being called and it's much more strict than spies. For example, if we want to replicate the above test with mocking, it'll be thrown an error saying you haven't defined any expectation for updateBalance of FinanceService class. The following code will show how to write the test using mockery.

/**
* @test
*/
public function upgrade_should_return_success_if_type_was_bronze_test_mockery()
{
    // mock user
    $user = factory(User::class)->create(['id'=>1]);
    Auth::shouldReceive('user')->andReturn($user);

    // swap the financeService class with StubFinanceService
    $this->app->instance(FinanceServiceInterface::class, \Mockery::mock(FinanceServiceInterface::class, function($mock) use($user){
        $mock->shouldReceive('getBalance')->once()->with($user);
        $mock->shouldReceive('updateBalance')->once()->with($user, 0);
    }));

    $userController = $this->app->make(UserController::class);

    $userRequest = new UpgradeUserRequest(['type'=>'bronze']);
    $userController->upgrade($userRequest);
}

As you can see in the above code, we need to define the expectation for all of the methods that were called. This way we can define the mock object.

If you want to read more about mocking vs stub I'll recommend you to read this article written by martin fowler 

Partial mocking

Let's assume that we need to use authorize Laravel internal method in the upgrade method to check if the user authorized to do this action or not, so the code will be as followed 

public function upgrade(UpgradeUserRequest $request)
{
    $this->authorize('upgrade');

    $user = Auth::user();

    $balance = $this->financeService->getBalance($user);

    if($balance < config::get('upgrade.'.$request->type.'.price')){
        PaymentService::charge($user, $balance - config::get('upgrade.'.$request->type.'.price'));
        $balance = 0;
    }else{
        $balance -= config::get('upgrade.'.$request->type.'.price'); 
    }

    if(!$this->financeService->updateBalance($user, $balance)){
        return Response::json(['success'=>false], 400);
    }

    $user->update(['type'=>$request->type]);

    event(UserAccountUpgraded::class);

    return Response::json(['success'=>true]);
}

As you can see we are referring to the authorized method which it's located in the parent controller class, now how we should mock this method? well if we use Mockery::mock then we need to specify the expectation for every single method that we are using in the UserController which it's not what we want, If we used Mockery:spy still, upgrade method will return null because of the behaviour of the spy in PHPUnit, so we should use partial mocking.

PartialMocking is a way to mock only that specific method and leave the rest as it's, in the first manner it's working like spy with only one difference which in spy all of the rest of the methods are going to return null because they are dummy methods but in partial mocking, the logic of the method remain as it is.

/**
* @test
*/
public function upgrade_should_return_success_if_type_was_bronze_test_partial_mocking()
{
    // mock user
    $user = factory(User::class)->create(['id'=>1]);
    Auth::shouldReceive('user')->andReturn($user);

   $partialMockFinanceService = \Mockery::mock(FinanceService::class)->makePartial();
   $partialMockFinanceService->shouldReceive('getBalance')->once()->with($user)->andReturn(500);

    $this->app->instance(FinanceServiceInterface::class, $partialMockFinanceService);

    $userController = $this->app->make(UserController::class);

    $userRequest = new UpgradeUserRequest(['type'=>'bronze']);
    $userController->upgrade($userRequest);
}

As you can see we only used makePartial method, that will only mock the getBalance method but the other methods will remain as they are.

Fake Object

in testing, we don't want to hit the real thing. Well, the whole philosophy of this article is about not using the real thing and use fake object instead. One of the examples that I can tell about the fake object is sending emails in Laravel. We can change the driver from SMTP to log so when we are running the tests instead of sending a real email the emails logged into the storage or for example using an in-memory database instead of using the real database driver that we using in production.

I hope you liked the article :) .