Summer Sale! All accounts are 50% off this week.

crwh05's avatar

Tests instantiate the class instead of using Mocked class

I have an issue within a project which I have simplified down to an abstract example to keep it simple and anonymous.

The class:

<?php

namespace App;

class Calculator
{
    private int $a = 1;
    private int $b = 2;

    public function sum(): int
    {
        return $this->a + $this->b;
    }
}

The test:

    public function testCalculatorSum()
    {
        $this->mock(Calculator::class, function (MockInterface $mock) {
            $mock->shouldReceive('sum')
                ->once()
                ->andReturn(5);
        });

        $calculator = new Calculator();
        $this->assertEquals(5, $calculator->sum());
    }

Test output:

Failed asserting that 3 matches expected 5.
Expected :5
Actual   :3

My understanding was that the mock() function would bind the Calculator class to the laravel instance and so any future uses of the class would use the bound mocked class instead of instantiating a new one. https://laravel.com/docs/8.x/mocking#mocking-objects

I think I have some fundamental misunderstanding here and I would appreciate any advice.

Note: This is just a sample of a problem I have, in my project I am actually calling an api endpoint on my app which calls a third part service. I want to mock the third party class to return the desired response for the test.

0 likes
4 replies
Nakov's avatar

But as you pointed out from the documentation, you should be assigning the result of the Mock to the $calculator not to instantiate a new class.. because you are not binding/resolving that through the container.

So this should work in your example above:

  public function testCalculatorSum()
    {
        $calculator = $this->mock(Calculator::class, function (MockInterface $mock) {
            $mock->shouldReceive('sum')
                ->once()
                ->andReturn(5);
        });

        $this->assertEquals(5, $calculator->sum());
    }
crwh05's avatar

Cheers @nakov, that makes perfect sense for my dumbed down example.

I made a mistake though, it doesn't really reflect my actual problem well enough so here is another more detailed example. I am writing test coverage for the route /sum. I want to cover a successful request where the sum is returned and I also want to cover when there is an exception (some error with the third party service Acuity). To do this I wanted to fake the sum function and return either an expected result or an exception.

Here is the example route:

Route::get('/sum', function () {
    try {
        $calculator = new App\Calculator();
        return $calculator->sum();
    } catch (Exception $exception) {
        return $exception->getMessage();
    }
});

My example class:

<?php

namespace App;

use App\Acuity\AcuitySchedulingApi;

class Calculator
{
    private int $a = 1;
    private int $b = 2;

    public function sum(): int
    {
        /*********************************************************/
        // Calls a third party service API which I don't want called
        $acuity = new \AcuityScheduling([
            'userId' => config('services.acuity.user'),
            'apiKey' => config('services.acuity.secret'),
        ]);
        $response = $acuity->request('/me');
        // Dumping response to show API is called
        dump($response);
        /*********************************************************/
        
        // Dummy data for this example
        return $this->a + $this->b;
    }
}

Here is the example test

    public function testCalculatorSum()
    {
        $this->mock(Calculator::class, function (MockInterface $mock) {
            $mock->shouldReceive('sum')
                ->once()
                ->andReturn(5);
        });

        $response = $this->get('/sum');

        $this->assertEquals(5, $response->content());
    }

Example test results:

array:42 [
  "expires" => null
  "id" => 2xxxxxx

...

  "hasExpired" => false
]

Failed asserting that '3' matches expected 5.
Expected :5
Actual   :3

I thought mocking the Calculator function sum would mean the Acuity API wouldn't be called but my mocked class is not called, instead the route /sum is instantiating its own Calculator class and calling the API as it would normally.

How can I mock the sum function? Or should I be testing the code differently? Acuity uses cURL rather than HTTP Client so I don't know how I would go about mocking it directly so I thought mocking sum would be the easiest. Any thoughts?

Nakov's avatar
Nakov
Best Answer
Level 73

But you are instantiating the AcuityScheduling from within the test, that will always return new instance. You should mock that one instead by binding it to the container first.

So in your AppServiceProvider or in a custom service provider in the boot method you will add this:

$this->app->bind(\AcuityScheduling::class, function() {
     return new \AcuityScheduling([
            'userId' => config('services.acuity.user'),
            'apiKey' => config('services.acuity.secret'),
        ]);
});

In your Calculator class you can add that one to the constructor

class Calculator...

public $acuity;

public function __construct(AcuityScheduling $acuity)
{
       $this->acuity = $acuity;
}

 public function sum(): int
    {
        $response = $this->acuity->request('/me');
        // Dumping response to show API is called
        dump($response);
        /*********************************************************/
        
        // Dummy data for this example
        return $this->a + $this->b;
    }
....

Then from your test:

$acuity = $this->instance(
    AcuityScheduling::class,
    Mockery::mock(AcuityScheduling::class, function ( MockInterface $mock ) {
        $mock->shouldReceive('request')->once()->andReturn(5);
    })
);

$calculator = new Calculator($acuity);

$this->assertEquals(5, $calculator->sum());

Something like this. Does it make sense? You should mock external calls, not your own classes.

1 like
crwh05's avatar

Yeap! That makes total sense. Thank you very much!

Please or to participate in this conversation.