Adding your own fluent JSON assertions in Laravel

Fluent JSON testing is an elegant feature that let’s you test JSON responses using a chain of calls and checking fields one by one. As shown in the docs:

use Illuminate\Testing\Fluent\AssertableJson;
/* ...... */
$response = $this->json('GET', '/users/1');

$response
->assertJson(fn (AssertableJson $json) =>
$json->where('id', 1)
->where('name', 'Victoria Faith')
->missing('password')
->etc()
);

But the toolkit provided by the AssertableJson class might fall short on some more specific cases and this article explains how you can extend the class with you own assertions. To do this we’ll have to provide a bit of boilerplate, but once it’s all in place, there’s no more friction and you can add all the features you need.

Extending AssertableJson

Adding methods is simple. Just extend the AssertableJson class and define (or override) the methods you need. I prefer to put this right besides where I use it — in Feature tests.

// tests/Feature/AssertableJson.php
<?php
namespace Tests\Feature;use Arr;
use Illuminate\Testing\Fluent\AssertableJson as BaseAssertableJson;
use PHPUnit\Framework\Assert as PHPUnit;
class AssertableJson extends BaseAssertableJson
{
/**
* Check if at least one of these keys is present.
*/
public function hasAny($keys): static
{
$keys = is_array($keys) ? $keys : func_get_args();
PHPUnit::assertTrue(
Arr::hasAny($this->prop(), $keys),
sprintf('None of properties [%s] exist.', implode(', ', $keys))
);
foreach ($keys as $key)
$this->interactsWith($key);
return $this;
}
}

This method follows conventions established by Laravel’s own AssertableJson methods:

  • It uses a static PHPUnit’s assertion with a custom error message.
  • It uses interactsWith method to mark the keys that have been checked. Fluent JSON testing keeps track of keys that are checked and will fail the test if any unexpected keys remain, unless an ->etc() is also added to the chain.
  • It returns the object itself so you can keep chaining.

Using your AssertableJson

Now that we have a class defined we can use it in a custom response. Let us inject it in out TestResponse class and also use it to define a new custom assertion on this TestResponse class.

// tests/Feature/TestResponse.php
<?php
namespace Tests\Feature;use Arr;
use Illuminate\Testing\TestResponse as BaseTestResponse;
// If you don't have your AssertableJson in the same namespace
// You have to import it or use FQCN.
class TestResponse extends BaseTestResponse
{
/**
* Inject our extension of AssertableJson.
*/
public function assertJson($value, $strict = false)
{
$json = $this->decodeResponseJson();

if (is_array($value)) {
$json->assertSubset($value, $strict);
} else {
$assert = AssertableJson::fromAssertableJsonString($json);
$value($assert);

if (Arr::isAssoc($assert->toArray())) {
$assert->interacted();
}
}
return $this;
}
/**
* Some rudimentary checking to see if this resembles JSON API.
*/
public function assertIsJsonApi()
{
$this->assertJson(fn(AssertableJson $json) =>
$json
// One of these is required in JSON API.
->hasAny('data', 'errors', 'meta')
// All of these are allowed and this will be true because of
// previous. We include it to explicitly name all allowed
// params and let the test fail if anything else is present.
->hasAny('data', 'errors', 'meta', 'jsonapi', 'links', 'included')
);
return $this;
}
}

Once this is done we can make a custom TestCase and use our new TestResponse.

// tests/Feature/TestCase.php
<?php
namespace Tests\Feature;use Tests\TestCase as BaseTestCase;
// import TestResponse if not in the same namespace
abstract class TestCase extends BaseTestCase
{
/**
* Inject our extension of TestResponse.
*/
protected function createTestResponse($response)
{
return TestResponse::fromBaseResponse($response);
}
}

Finally we can use it in our tests.

// tests/Feature/SomeJsonTest.php
<?php
namespace Tests\Feature;// import our new TestCase and AssertableJson if not same namespaceclass ContentTest extends TestCase
{
public function testContents()
{
$this->getJson('flintstones')
->assertJson(fn(AssertableJson $json) =>
$json
->hasAny('Fred', 'Wilma', 'Pebbles')
->missingAll('Barney', 'Betty')
);
}
public function testFormat()
{
$this->getJson('parking-lots')
->assertStatus(200)
->assertIsJsonApi(); // custom response assertion
}
}

Note: make sure to have your Laravel updated before trying. This only works since Laravel 8.42. The process was more cumbersome before this PR.