Validate method parameters using callbacks in PHPUnit cover image
Image by Miryam León

During testing, you can mock a service class to prevent hitting a database or setting up a complex world. You do, however, want to make sure the methods on that service are called correctly. Sometimes it's not enough to test whether a specific method was called, but you need to make sure the provided parameters were correct as well. While this is relatively easy for scalar parameters like string, bools, int and float; it can be difficult for complex objects.

Let's first take a look at how we might normally validate a provided parameter.

public function testMethodArguments(): void
{
    $service = $this->createMock(App\MyService::class);
    $service
        ->expects(self::once())
        ->method('my_method')
        ->with('value')
        ->willReturn(true);

    self::assertTrue($service->my_method('value'));
}

In this example we make sure the my_method() function was only called once and received value as the parameter. As we can see: a scalar value is pretty simple to assert using the with() method. Especially because we can provide the value to test inside the test case.

But what if we are not that "lucky"? What if our service is called from within a private function on our class, and the provided object is extremely complex, with dependencies, sub-objects and a drop of holy water? Using the "normal" approach we would be forced to recreate the entire object inside the unit test, and make sure it resembles the provided object exactly. This can mean you have to mock a bunch of services to get this done. Let's not do that.

self::callback() to the rescue

The with() method is an assertion in itself that needs to pass. If you provide a different value than you expected, the test will fail that assertion. With the help of self::callback() you can overwrite the normal assertion PHPUnit would do at that time. The (bool) result of this callback should indicate whether the provided parameter is valid. So if the callback returns true the assertion will pass, while false will make the assertion fail. This means that the following example will also pass, while we are no longer making sure that value was actually provided.

public function testMethodArguments(): void
{
    $service = $this->createMock(App\MyService::class);
    $service
        ->expects(self::once())
        ->method('my_method')
        ->with(self::callback(fn(): bool => true))
        ->willReturn(true);

    self::assertTrue($service->my_method('value'));
}

How does this help us? Well, the callable you provide inside the self::callback() method actually receives the parameter you have provided on the originating call. So you can perform assertions on the original value!

Note: with() can receive multiple parameters, each referencing their original counterpart. This means you provide a callback for a single parameter, not all of them! So if you call your method like: $service->my_method('value', $object) you can assert value like you normally would, and $object in a callback:

->with('value', self::callback(fn($object): bool => ... ))

To "fix" our previous example, we only need to change the following line to make sure the provided parameter equals value:

->with(self::callback(fn($value): bool => $value === 'value'))

We were however not really interested in complicating a simple scalar test, but simplifying a complex object test. So we cannot really provide a true or false result as it might take a few checks to make sure the object is valid.

Because we used a simple callback function the test class itself is available inside the function scope, meaning we can preform different assertions inside the callback. We can also simply return true to make sure the with() assertion doesn't fail.

->with(self::callback(function ($object): bool {
    self::ssertInstanceOf(MyObject::class, $object);
    self::assertSame(['value'], $object->getValue());
    // other tests

    return true; // make sure `with` assertion itself passes
}))

While we are "dumbing" down the with() assertion, we are actually performing more precise tests in the process. And we no longer need to recreate that complex object, making the test class smaller and easier to understand!

Validate while setting up the response

PHPUnit also has a willReturnCallback() method that can be used to set up a correct response to the provided parameters. This method also needs a callable that will receive all the parameters provided by the originating call. That means you can use this callback to preform those more complex assertions too.

public function testMethodArguments(): void
{
    $service = $this->createMock(App\MyService::class);
    $service
        ->expects(self::once())
        ->method('my_method')
        ->willReturnCallback(function ($object): bool {
            self::assertInstanceOf(MyObject::class, $object);
            self::assertSame(['value'], $object->getValue());

            return true; // This is now the returning value
        });

    self::assertTrue($service->my_method(new MyObject(['value'])));
}

A very simple, yet powerful testing technique, that will hopefully make your life a bit easier. I know it has been a life-saver for me a bunch of times.

Share this article

Twitter Facebook LinkedIn