Mock external services using a Guzzle snapshot client

Implementing the spatie/phpunit-snapshot-assertions package.

Jeff
5 min readJan 23, 2018

An introduction to snapshot testing in PHP

The Snapshot testing strategy allows you to compare some data with a previous version to avoid regression.

Le’s try to explaining with an example:

public function test_it_is_foo() {
$order = new Order(1);
$this->assertMatchesJsonSnapshot($order->toJson());
}

The value of $order->toJson() is compared against a stored version of the same value, a “snapshot” version of it.

If the resulting value of new Order(1) changes with the time, then the test is going to fail.

The previous example comes from the official documentation of the “spatie/phpunit-snapshot-assertions” package:

Here you can find a practical example of how to use this package (includes a video) :

The following is a more in-depth article about the “phpunit-snapshot-assertions” package:

Taking advantage of the Snapshot component

Snapshot tests are used to prevent regressions in your application, but I may have found another use for this component when you are working with third-party services within your application.

Let’s say you are building a Twitter client, to show a list of the latest tweets from your personal account.

To do so, you may need to create a repository or a service to make all the necessary calls to the Twitter API to fetch the data, and this is when GuzzleHttp could be very handy.

The following is an example of the twitter repository

class TwitterApiRepository implements TwitterRepository {    // GuzzleHttp Client
protected $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function latest(array $params = []) : array
{
$data = $this->client
->get('/api-endpoint', ['query' => $params]);

return json_decode($data, true);
}
}

Of course, we are working with PHPUnit; we need to test that, and we can use the spatie/phpunit-snapshot-assertions package:

/**
* @test
**/
public function fetch_tweets()
{
$tweets = $this->twitterRepository->latest(['limit' => 20]);

$this->assertMatchesJsonSnapshot($tweets);
}

The flaw with this approach is the fact that the test will perform an HTTP call to the Twitter API every time we decide to run the test.

Also, if you are trying to test your homepage, for example, you may want to Mock or fake that data to avoid those extra calls to be made in the testing environment.

Guzzle allows you to Mock a response, but you need to specify which is going to be the content of that response, so, you’ll return a static piece of data instead of making the actual HTTP call to the API.

From the Guzzle docs:

// Create a mock and queue two responses.
$mock = new MockHandler([
new Response(200, ['X-Foo' => 'Bar']),
Request('GET', 'test'))
]);

$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);

echo $client->request('GET', '/')->getStatusCode();

What’s wrong with that?

What happens if you need to make 20 API calls, to different endpoints, with different params?

That’s right, you have to, manually create a bunch of stubs and then the code starts to get messy, especially when you try to add behavior tests like what happens when a user goes to the homepage and click xwz button

So, you may end up by creating things like doubles, fake repositories, mocks, etc.

Consider a Guzzle snapshot client

Do you remember the constructor of the TwitterApiRepository::class ?

class TwitterApiRepository implements TwitterRepository {    // GuzzleHttp Client
protected $client;
public function __construct(Client $client)
{
$this->client = $client;
}
}

Here we are using dependency injection; this allows us to define which implementation of the Client class should be resolved each time the TwitterRepository::class is instantiated, this can be done in the AppServiceProvider::class

class AppServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton('TwitterRepository', function () {
if (config('app.env') == 'testing') {
return new TwitterApiRepository(
new SnapshotClient(['base_uri' => 'api.url'])
);
}
return new TwitterApiRepository(
$this->guzzleClient()
);
});
}
}

As you can see, instead of returning a different repository to the testing environment, we deliver a different Guzzle client called SnapshotClient

<?phpuse GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\Psr7\Response as GuzzleResponse;
use Tests\Traits\JsonSnapshot;
class SnapshotClient extends GuzzleClient
{
use JsonSnapshot; //Overwriting the get() method
public function get(string $path = '/', array $params = [], array $options = [])
{
$key = md5($path . '?' . http_build_query($params));
$response = $this->snapshot(
$key,
function () use ($path, $params, $options) {
return $this->responseToJson(
parent::get($path, $params, $options)
);
});
return $this->jsonToResponse($response); } public function responseToJson(GuzzleResponse $response)
{
$data = [
'status' => $response->getStatusCode(),
'headers' => $response->getHeaders(),
'body' => (string)$response->getBody()
];
return (string)json_encode($data);
}

public function jsonToResponse($snapshot)
{
$response = json_decode($snapshot, true);
return new GuzzleResponse(
$response['status'],
$response['headers'],
$response['body']
);
}
}

Here, we overwrite the Guzzle get() method, to create a snapshot of the data that we are going to receive as the result of the HTTP call. If the snapshot already exists, then is returned and the actual API call is not going to be performed.

If you have also methods to send POST/PUT/DELETE requests to the external API, then you should use a mock for those scenarios.

This action is handled by the snapshot() method on the JsonSnapshot trait. This method receives a key, which in this case is a hash from the full API endpoint including the query params. The same key is going to be used as a file name to store the snapshot.

The JsonSnapshot trait

trait JsonSnapshot 
{
private function snapshotsFolder()
{
return base_path('tests/snapshots');
}

public function snapshot($key, callable $fn)
{
$filesystem = new Filesystem($this->snapshotsFolder());
$driver = new JsonDriver();
$snapshot = new Snapshot($key, $filesystem, $driver);
if ($snapshot->exists()) {
return $filesystem->read("$key.json");
}
$snapshot->create(call_user_func($fn));
return $filesystem->read("$key.json");
}
}

By default, the snapshots are going to be stored in the /tests/snapshots folder.

Wrapping up

The advantage of this approach is that you need to replace only the Guzzle client on your ServiceProvider and then you can perform any test on your application without the need of manually creating mocks, stubs, fakes, or anything to try to replicate the actual ApiRepository that you are trying to implement.

Of course, the first time you run the tests, you don’t have any snapshot created, so in this first attempt, the tests are going to be a little slow, but after that, you’ll see how fast they will perform.

--

--

Jeff

Web developer. Always learning... #fullstack #less #sass #php #laravel #javascript #VueJs