In Laravel HTTP tests, how do assertJson, assertExactJson, assertJsonPath and fluent AssertableJson differ when checking a checkout API response?
answer
- subset versus exact match
- assertJson compares loosely
- assertJsonPath uses assertSame
- closure receives AssertableJson
- etc() allows unchecked keys
basics
~20 sassertJson checks a loose subset of the body; assertExactJson needs the whole body to match; assertJsonPath checks one path strictly with assertSame; a closure given to assertJson gets an AssertableJson that fails on unchecked keys unless etc() is called.
solid answer
~40 s`assertJson(['status' => 'paid'])` passes when those keys appear anywhere in the body with loosely equal values, ignoring everything else. `assertExactJson([...])` needs the entire body to match. `assertJsonPath('data.total', 12999)` reads one dotted path and compares with `assertSame`, so a string `'12999'` fails against an integer; it also accepts a closure. Passing a closure to `assertJson` gives an `Illuminate\Testing\Fluent\AssertableJson`: `where()` (also strict), `has()`, `missing()`, `each()` and `first()` walk the document, and at the end every root key must have been touched, or the test fails with "Unexpected properties were found on the root level", unless you call `etc()`. That strictness makes the fluent form good at catching leaked fields such as a card token.
code
php · 28 lines<?php
namespace Tests\Feature;
use Illuminate\Testing\Fluent\AssertableJson;
use Tests\TestCase;
class CheckoutApiTest extends TestCase
{
public function test_checkout_returns_the_paid_order(): void
{
$response = $this->postJson('/api/checkout', ['sku' => 'AM90-42', 'size' => 42]);
$response
->assertCreated()
->assertJsonPath('data.status', 'paid')
->assertJsonPath('data.total', 12999) // strict: '12999' would fail
->assertJson(fn (AssertableJson $json) => $json
->has('data', fn (AssertableJson $order) => $order
->whereType('id', 'integer')
->where('status', 'paid')
->has('items', 1)
->missing('card_token')
->etc()
)
);
}
}go deeper
Know assertJson for partial matches and assertJsonPath for one value, and that both work on the TestResponse from getJson or postJson.
Explain subset versus exact, loose versus strict (assertJsonPath and where use assertSame), and what etc() does in the fluent form.
Choose assertions that fail for real contract breaks: strict types on money, fluent checks without etc() to catch leaked payment fields.
Set an API testing policy: which endpoints get exhaustive fluent checks, which get structure checks, and how contract changes are reviewed through tests.
## The checkout response under test Suppose `POST /api/checkout` in a sneaker shop returns: ```json {"data": {"id": 42, "status": "paid", "total": 12999, "items": [{"sku": "AM90-42", "qty": 1}]}} ``` Laravel's `TestResponse` offers several ways to check it, and they differ in how much of the body they look at and how strictly they compare. ## The four tools | Assertion | Scope | Comparison | |---|---|---| | `assertJson(array)` | the given keys, anywhere in the structure | subset, loose `==` | | `assertExactJson(array)` | the whole body | exact match | | `assertJsonPath('data.total', 12999)` | one dotted path | `assertSame`, strict | | `assertJson(fn (AssertableJson $json) => ...)` | whatever the closure walks | `where()` strict, plus a check that nothing was left unexamined | Supporting assertions fill the gaps: `assertJsonStructure()` for key shape without values, `assertJsonCount(1, 'data.items')` for array lengths, `assertJsonFragment()` for a fragment anywhere, `assertJsonMissing()` for data that must not appear, and `assertJsonPaths([...])` to check several paths at once. ## Subset and loose: assertJson with an array `assertJson(['data' => ['status' => 'paid']])` merges the expected array into the actual one and compares with `==`. Extra keys are ignored, and PHP's loose comparison applies, so `'12999'` and `12999` compare equal. That makes it forgiving: good for "the status is paid", weak for "the total is an integer". ## Exact: assertExactJson `assertExactJson` fails as soon as the body has a key the expectation lacks. It suits small, stable payloads; on a growing API it breaks every time a field is added, which is often noise rather than a regression. ## Strict and targeted: assertJsonPath `assertJsonPath('data.total', 12999)` reads one value by dot notation and compares with `assertSame`. Type matters: if the API serializes money as `"129.99"`, a test expecting `129.99` fails. That strictness is useful, because a type change in an API is a breaking change for clients. A closure checks rules instead of values: ```php $response->assertJsonPath('data.items', fn (array $items) => count($items) === 1); ``` ## Fluent: AssertableJson Passing a closure to `assertJson` hands you an `AssertableJson` scoped to the root: - `where('status', 'paid')` compares strictly, or with a closure; - `has('items', 1, fn ($item) => ...)` checks presence, count and the first element; - `missing('card_token')` checks a key is absent; - `whereType('total', 'integer')` checks a type; - `each()` and `first()` scope into arrays. After the closure runs, Laravel checks that **every root key was interacted with**. If the closure only looked at `data` and the body also had `meta`, the test fails with "Unexpected properties were found on the root level." Calling `etc()` marks the rest as intentionally unchecked. Nested scopes behave the same way. That rule is the fluent form's main value: a test that enumerates what an endpoint may return fails the day a controller starts leaking `card_token` or `password`, while `assertJson(array)` would pass happily. ## Debugging a failing JSON assertion When a JSON assertion fails, look before you loosen it: - `$response->dump()` prints the response, and `$response->ddJson('data.items')` dumps one decoded path and ends the whole run, so remove it afterwards. - `$response->json('data.total')` returns the decoded value, which shows at a glance whether it is an integer, a float or a string. - The fluent form's messages name the path, such as "Property [data.total] does not match the expected value.", so the failing key is usually obvious. Resist replacing a strict assertion with a loose one just to get green: if the test expected an integer and the API now sends a string, a mobile client parsing that field may break exactly the way the test did. ## Choosing 1. One or two important values: `assertJsonPath`. 2. The overall shape of a large payload: `assertJsonStructure` plus a few paths. 3. An endpoint whose output must not grow silently, such as anything touching payment data: the fluent form without `etc()` at the level you care about. 4. Tiny fixed responses: `assertExactJson`. Interviewers ask this to see whether a candidate knows the comparisons differ in strictness, and why a test passed that should not have.
- A Laravel test using assertJson(['total' => 12999]) passes, but the API returns the total as a string; why?`assertJson` with an array compares as a loose subset, so `'12999'` equals `12999` under `==`. `assertJsonPath('total', 12999)` or the fluent `where()` would fail, because both use `assertSame`. Use a strict assertion wherever the type is part of the contract.
- Why might you deliberately omit etc() in a fluent JSON test?Without `etc()`, the test fails if the response contains any key the closure did not examine at that level. For a payment endpoint that turns the test into a guard against leaking fields such as a card token: adding a field forces someone to update the test and decide consciously that it may be exposed.
saying these in an interview costs you the question
- assertJson with an array fails when the response has extra keys.
- assertJsonPath compares loosely, so '12999' matches 12999.
- A fluent assertJson closure only checks what you assert, never unexamined keys.
- assertExactJson ignores keys missing from the expectation.
- etc() asserts that no other keys exist in the response.