skip to content

In Laravel HTTP tests, how do assertJson, assertExactJson, assertJsonPath and fluent AssertableJson differ when checking a checkout API response?

level: middleimportance: should knowfreq 45%

answer

  1. subset versus exact match
  2. assertJson compares loosely
  3. assertJsonPath uses assertSame
  4. closure receives AssertableJson
  5. etc() allows unchecked keys

basics

~20 s

assertJson 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
<?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

for a junior

Know assertJson for partial matches and assertJsonPath for one value, and that both work on the TestResponse from getJson or postJson.

for a middle

Explain subset versus exact, loose versus strict (assertJsonPath and where use assertSame), and what etc() does in the fluent form.

for a senior

Choose assertions that fail for real contract breaks: strict types on money, fluent checks without etc() to catch leaked payment fields.

for a principal

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.