0
votes

I'm using Mockery in my Laravel based PHP project to help test a Laravel MVC controller. Below is the relevant part of my controller class I'm trying to test.

class DevicesController extends Controller
{
    private $deviceModel;
    private $rfDeviceModel;
    private $userModel;
    private $userDeviceModel;

    public function __construct(Device $deviceModel, RFDevice $rfDeviceModel, User $userModel, UserDevice $userDeviceModel)
    {
        $this->middleware('guest');

        $this->deviceModel = $deviceModel;
        $this->rfDeviceModel = $rfDeviceModel;
        $this->userModel = $userModel;
        $this->userDeviceModel = $userDeviceModel;
    }

    ...

    public function add(Request $request)
    {
        $name = $request->input('name');
        $description = $request->input('description');
        $onCode = $request->input('onCode');
        $offCode = $request->input('offCode');
        $pulseLength = $request->input('pulseLength');
        $type = 1;

        $currentUserId = $this->currentUser()->id;

        $newDeviceId = $this->deviceModel->add($name, $description, $type)->id;
        $this->rfDeviceModel->add($onCode, $offCode, $pulseLength, $newDeviceId);
        $this->userDeviceModel->add($currentUserId, $newDeviceId);

        return redirect()->route('devices');
    }
}

In particular, I'm writing several unit tests around the controller's add(Request $request) function to make sure that each of the three model add(...) functions are called. My test case to handle this looks like the following:

public function testAdd_CallsAddForModels()
{
    $mockDeviceModel = Mockery::mock(Device::class);
    $mockDeviceModel->shouldReceive('add')->withAnyArgs()->once();
    $this->app->instance(Device::class, $mockDeviceModel);

    $mockRFDeviceModel = Mockery::mock(RFDevice::class);
    $mockRFDeviceModel->shouldReceive('add')->withAnyArgs()->once();
    $this->app->instance(RFDevice::class, $mockRFDeviceModel);

    $mockUserDeviceModel = Mockery::mock(UserDevice::class);
    $mockUserDeviceModel->shouldReceive('add')->withAnyArgs()->once();
    $this->app->instance(UserDevice::class, $mockUserDeviceModel);

    $user = $this->givenSingleUserExists();

    $this->addDeviceForUser($user->user_id);
}

private function givenSingleUserExists()
{
    $user = new User;

    $name = self::$faker->name();
    $email = self::$faker->email();
    $userId = self::$faker->uuid();

    $user = $user->add($name, $email, $userId);

    return $user;
}

private function addDeviceForUser($userId)
{
    $this->withSession([env('SESSION_USER_ID') => $userId])
        ->call('POST', '/devices/add', [
        'name' => 'Taylor',
        'description' => 'abcd',
        'onCode' => 1,
        'offCode' => 2,
        'pulseLength' => 3
    ]);
}

When I run this test, I get the following output in the console:

There was 1 error:

1) Tests\Unit\Controller\DeviceControllerTest::testAdd_CallsAddForModels
Mockery\Exception\InvalidCountException: Method add() from Mockery_1_App_RFDevice should be called
 exactly 1 times but called 0 times.

But the funny and perplexing thing is that if I comment out and combination of 2 of the 3 mockery sections, my test pass. This means to mean, that my code is actually working correctly, but for some reason in this case, I can't inject multiple mocked model objects into my controller and test them all at once. I guess I could split this up into three separate tests that make sure each model's add(...) function is called, but I want to do it all in one test case if possible. I also know I could use a repository pattern to wrap all the business logic in the controller's add(...) function into a single call, but then I would run into the same problem while testing the repository class.

1
Try binding the mocks in setUp instead of the test function. - nCrazed
I tried your suggestion, I get the exact same behavior. I don't think I would like the idea of having to bind my mocks in the setUp since I don't need to share those mocks for most of my test cases. - roundtheworld
How's the route defined? - nCrazed
I just tried to copy your code and run the same tests using mockery/mockery 0.9.7 and laravel/framework 5.3.30 as well as 5.4.4. Mocks are being correctly injected and the test passes without issues. what versions of Mockery and Laravel are you using? - nCrazed
I downgraded to your exact versions and everything still works. Here's the code github.com/nCrazed/SO-41863723 - nCrazed

1 Answers

1
votes

You're not mocking the return values of the methods so this line attempts to access an attribute (id) on a null.

   $newDeviceId = $this->deviceModel->add($name, $description, $type)->id;

You can fix this by adding a return value to your Device model mock like so:

$mockDeviceModel = Mockery::mock(Device::class);
$device = new Device;
$mockDeviceModel->shouldReceive('add')->withAnyArgs()->once()->andReturn($device);

To make such problems easier to debug in the future, change your error handler to re-throw the exceptions in a testing environment instead of rendering a valid HTML response.