Testing with immutable PSR-7 objects and Prophecy
Recently I have been working a lot with PSR-15 and PSR-18 and one of the characteristics of these recommendations is that it uses the immutable objects specified in PSR-7. Soon after we introduced PSR-18 in our codebase a colleague implemented a client including a unit test that was passing. And yet the code was failing when run in the browser. The cause of this was that the fact that RequestInterface
objects are immutable by specification was overlooked. Due to the use of Prophecy and prophesized objects we were struggling to properly test it. Until another colleague introduced me to Argument::that()
that is.
Let’s consider the following class as a simplified example of the situation described above:
<?php
declare(strict_types=1);use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;final class ApiKeyMiddleware implements MiddlewareInterface
{
/** @var string */
private $apiKey; public function __construct(string $apiKey)
{
$this->apiKey = $apiKey;
} public function process(RequestInterface $request, ClientInterface $client): ResponseInterface
{
$request->withHeader('Api-Key', $this->apiKey); return $client->sendRequest($request);
}
}
This is an example of a PSR-18 middleware we created and introduced into our codebase shortly after the approval of PSR-18¹. Seems correct at first glance. Now let’s have a look at a simple unit test to test the happy path using Prophecy:
<?php
declare(strict_types=1);use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;final class ApiKeyMiddlewareTest extends TestCase
{
public function testApiKeyIsAddedAndResponseIsReturned(): void
{
$apiKey = 'api key'; $client = $this->prophesize(ClientInterface::class);
$client
->sendRequest(Argument::type(RequestInterface::class))
->shouldBeCalled()
->willReturn($this->prophesize(ResponseInterface::class)->reveal()); $request = $this->prophesize(RequestInterface::class);
$request
->withHeader('Api-Key', $apiKey)
->shouldBeCalled()
->willReturn($request); $middleware = new ApiKeyMiddleware($apiKey);
$response = $middleware->process($request->reveal(), $client->reveal()); self::assertInstanceOf(ResponseInterface::class, $response);
}
}
At first glance the code and the unit test look good. But while the test will pass, the code does not actually do what it should; when running this code the remote server will most likely respond with a 403 Forbidden
response, because we are not sending the Api-Key
header with the request. This is caused by the immutability of the \Psr\Http\Message\RequestInterface
. The header is being set, but this operation returns a new instance of the request. Unfortunately the new object is not assigned to anything and therefore the request object without the header is passed to the actual client. We have found the culprit and the fix is simple, but how can we make sure this will not happen again in the future?
Prophecies, Prophecies everywhere
The first approach one can take is to make sure every operation return a new prophesized object:
public function testApiKeyIsAddedAndResponseIsReturned(): void
{
$apiKey = 'api key'; $requestWithApiKey = $this->prophesize(RequestInterface::class)->reveal(); $request = $this->prophesize(RequestInterface::class);
$request
->withHeader('X-Api-Key', $apiKey)
->shouldBeCalled()
->willReturn($requestWithApiKey); $client = $this->prophesize(ClientInterface::class);
$client
->sendRequest(Argument::is($requestWithApiKey))
->shouldBeCalled()
->willReturn($this->prophesize(ResponseInterface::class)->reveal()); $middleware = new ApiKeyMiddleware($apiKey);
$response = $middleware->process($request->reveal(), $client->reveal()); self::assertInstanceOf(ResponseInterface::class, $response);
}
This test will fail, as it should. But now consider a situation where not just one operation is done, but multiple. Maybe a header is set, another is modified. Or — shifting away from PSR-7 objects — look at other immutable objects that might have multiple operations. The amount of prophesized objects would grow and every time the order in which the object methods are called change the unit tests have to be changed. Every call to the immutable object adds three methods on the prophesized object and decreases readability.
Value object do not need prophesizing
The objects for the example are not just immutable objects, but can also be seen as value objects². And there is some discussion whether one would even mock value objects. Maybe instantiating these objects is a solution. In that case the order in which the object is modified is irrelevant and our test will focus more on what comes out, then on the order methods are called.
This works great if a method is tested that modifies and returns an object; we’ll get an instance of that object and we are able to run all sorts of assertions on it. But what if this is not the case. What if we have a situation where a value object is passed as a parameter, some modifications are done and the value object is passed to another object. Something like the middleware from the example earlier. Some testing frameworks like Mockery offer spies to test these situations. When using Prophecy this situation can be handled using Argument::that()
:
public function testApiKeyIsAddedAndResponseIsReturned(): void
{
$apiKey = 'api key'; $request = new Request('/url', 'GET'); $client = $this->prophesize(ClientInterface::class);
$client
->sendRequest(
Argument::that(function (RequestInterface $request) use ($apiKey): bool {
self::assertEquals($request->getHeaderLine('X-Api-Key'), $apiKey); return true;
})
)
->shouldBeCalled()
->willReturn($this->prophesize(ResponseInterface::class)->reveal()); $middleware = new ApiKeyMiddleware($apiKey);
$response = $middleware->process($request, $client->reveal()); self::assertInstanceOf(ResponseInterface::class, $response);
}
Instead of testing against mocked value objects this code will perform assertions on the object that is actually passed to — on this case — the client. The nice thing about this is that we are no longer testing whether the code makes a certain set of calls in a given order. We actually test whether an object is in the correct state when passed.
PSR-7 — a special case
This unit test will fail when ran against the implementation from earlier. And in this situation a failing test is a good thing. But for this specific situation another “issue” has made it’s way into the testcase; the unit test is now depending on the implementation that is being used for PSR-7. When working in a large codebase — like I currently am — one might have more than just a few usages of these interfaces and thus unit tests. Using the suggested approach, instantiating value objects instead of prophesizing, will lead to a large amount of object instantiations. And this will make switching to another implementation more work. Ideally this instantiating is centralised as much as possible. This is exactly why PSR-17 — HTTP Factories — was introduced. Typically dependencies — like these factories — are injected. For unit tests this is not feasible without plugins. My solution is a bit less fancy; a trait:
<?php
declare(strict_types=1);use Psr\Http\Message\RequestInterface;trait Psr7Factories
{
public function createRequest(string $method, $uri): RequestInterface
{
return (new RequestFactory())->createRequest($method, $uri);
}
}
This can be applied in the unit test:
<?php
declare(strict_types=1);use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;final class ApiKeyMiddlewareTest extends TestCase
{
use Psr7Factories;
public function testApiKeyIsAddedAndResponseIsReturned(): void
{
$apiKey = 'api key'; $request = $this->createRequest('GET', '/url'); $client = $this->prophesize(ClientInterface::class);
$client
->sendRequest(
Argument::that(function (RequestInterface $request) use ($apiKey): bool {
self::assertEquals($request->getHeaderLine('X-Api-Key'), $apiKey); return true;
})
)
->shouldBeCalled()
->willReturn($this->prophesize(ResponseInterface::class)->reveal()); $middleware = new ApiKeyMiddleware($apiKey);
$response = $middleware->process($request, $client->reveal()); self::assertInstanceOf(ResponseInterface::class, $response);
}
}
When testing a class that creates the PSR-7 objects via a PSR-17 factory this approach can also be used:
$requestFactory = $this->prophesize(RequestFactoryInterface::class);
$requestFactory
->createRequest('GET', '/url')
->shouldBeCalled()
->willReturn($this->createRequest('GET', '/url'));
More usages
In the examples up until now instances of \Psr\Http\Message\RequestInterface
have been created. But this approach can also be used for instances of Psr\Http\Message\ResponseInterface
or other PSR-7 objects, simply by adding the methods you want to use to the trait. We have opted to follow the PSR-17 method signatures for consistency. This will make it easy to inject different response fixtures to test how the code responds to them:
$client = $this->prophesize(ClientInterface::class);
$client
->sendRequest(Argument::type(RequestInterface::class))
->willReturn(
$this->createResponse(200)
->withBody(
$this->createStream(
file_get_contents('path/to/response/fixture.json')
)
)
);
And when testing controllers or request handlers it allows for constructing instances of ServerRequestInterface
in a more readable way than prophesizing the object:
$request = $this->createServerRequest('GET', '/uri')
->withAttribute('attribute', 'value')
->withHeader('content-type', 'application/json');
Conclusion
Working with immutable objects is tricky; forget one assignment and your code stops behaving the way you expect it should. By testing for this the right way the resilience of a codebase is improved. Using Prophecy and prophesized objects does not immediately remedy this.
When opting to not mock value objects, but rather use them as is in the unit test Argument::that()
can act as a spy allowing for more specific assertions on the value objects. This will also allow assertions to determine the immutability of the object has been satisfied.
The PSR-7 objects are a special case; you could consider them as value objects, but they are also interfaced. Using instances of these objects instead of mocks or prophecies allows for these same immutability asserts. In order to prevent a lot of object instantiations of these objects in unit tests one can use the PSR-17 factories. But since the unit tests don’t normally allow for dependency injection an alternative way of centralising this object instantiation is by creating a trait that handles this.
With the described approach we have managed to reduce the references to the PSR-17 implementation we used to two locations; the dependency injection container and one trait. If for whatever reason a decision is made to move away from the current PSR-7 or PSR-17 implementation this should be a relative small task.
This approach can also be used for packages. The nice thing of the PHP-FIG HTTP foundation is that you can create packages relying just on interfaces. But you also want to test your code. By adding an implementation of PSR-7 as a development dependency consumers of the package won’t notice this dependency. Only maintainers and contributors will need to notice.
[1]: Middleware is not part of the PSR-18 specification. It is a layer on top of PSR-18 that we built ourselves.
[2]: According to Martin Fowler value objects are comparable to each other based on the values. Since the PSR-7 objects are interfaced there is no documentation on how the object values should be represented internally, but there is a standardised format to represent HTTP requests as strings.