In a Laravel feature test, how do you check an Inertia page's component and props with assertInertia, and why might a scoped has() fail?
answer
- AssertableInertia extends AssertableJson
- component() also checks the file exists
- has(key, count, closure) scopes the first item
- scoped closures demand every key or etc()
- where() compares strictly
basics
~20 sCall ->assertInertia(fn (Assert $page) => ...) on a normal GET response, then chain component(), has(), where() and missing(). A closure passed to has() opens a scope that fails unless every key inside is asserted or etc() is called.
solid answer
~40 s`assertInertia` is a `TestResponse` macro from the adapter. It reads the page object from the root view's `page` data, so you make a normal `$this->get()`; a JSON response to an `X-Inertia` request fails with "Not a valid Inertia response." The closure receives `Inertia\Testing\AssertableInertia`, which extends Laravel's fluent `AssertableJson`. `component('Properties/Index')` checks the name and, while `inertia.testing.ensure_pages_exist` is `true` (the default), that the page file exists. `has('properties', 3)` counts, `where('filters.status', 'vacant')` compares strictly, and `missing('owner_notes')` proves a field was not sent. Passing a closure to `has()` scopes into that prop (or with a count, into its first item), and the scope then requires every key in it to be asserted unless you call `etc()`: otherwise it fails with "Unexpected properties were found in scope". The root level is not checked that way.
code
php · 21 lines<?php
use App\Models\Property;
use App\Models\User;
use Inertia\Testing\AssertableInertia as Assert;
test('managers see their properties', function () {
$manager = User::factory()->create();
Property::factory()->count(3)->for($manager, 'manager')->create();
$this->actingAs($manager)
->get(route('properties.index'))
->assertInertia(fn (Assert $page) => $page
->component('Properties/Index')
->has('properties', 3, fn (Assert $p) => $p
->hasAll(['id', 'name', 'city'])
->missing('owner_notes')
->etc()
)
);
});go deeper
Recall assertInertia with component(), has() and where() on a normal GET, importing AssertableInertia as Assert.
Explain the AssertableJson scope rules, strict where(), has() with a count and closure, and the component file check.
Use missing() and full scopes to catch over-sharing props, and keep tests stable in CI with the pages paths and config options.
Decide what the backend suite owns versus browser tests: the page contract in feature tests, rendering in the frontend's own tests.
## The tool Inertia's Laravel adapter adds `assertInertia()` to Laravel's `TestResponse`, along with helpers such as `inertiaProps()` and `inertiaPage()`. The assertion closure receives an `Inertia\Testing\AssertableInertia` instance. That class **extends Laravel's `AssertableJson`**, so the fluent methods you know from `assertJson(fn (AssertableJson $json) => ...)` work on the page props, with a few Inertia-specific additions. ## How it finds the page `AssertableInertia::fromTestResponse()` reads the `page` variable the adapter passed to the **root view**. Consequences: - Make an ordinary request: `$this->actingAs($manager)->get(route('properties.index'))`. - A request sent with `X-Inertia: true` gets a `JsonResponse` (or a `409` if its asset version does not match), neither of which has view data, so the assertion fails with **"Not a valid Inertia response."** - A redirect, or an error page rendered as ordinary Blade, usually fails the same way, which is a useful signal in itself. ## The main methods | Method | Checks | |---|---| | `component('Properties/Index')` | the component name and, by default, that its file exists | | `has('properties')` | the key exists (dot notation allowed) | | `has('properties', 3)` | the value has exactly 3 items | | `has('lease', fn ($l) => ...)` | opens a scope on that prop | | `has('units', 5, fn ($u) => ...)` | counts, then scopes into the **first** item | | `where('lease.rent', 1200)` | strict equality with `assertSame` | | `missing('owner_notes')` | the key is absent | | `url('/properties')`, `version('...')` | the page object's other fields | `where()` also accepts a closure, which receives the value (a `Collection` for arrays) and must return `true`. An `Arrayable` expected value, such as a model, is converted with `toArray()` before comparison. ## Why scoped assertions fail 1. **Unasserted keys in a scope.** When a closure scope ends, `AssertableJson` checks that every key inside it was interacted with. `has('lease', fn (Assert $l) => $l->where('rent', 1200))` fails with "Unexpected properties were found in scope [lease]" if `lease` also holds `id` and `tenant_name`. Assert them, or end the chain with `->etc()`. This is deliberate: it catches props that leak extra fields. The root level of `assertInertia` is not checked this way, which is why shared props such as `errors` and `auth` never break a test. 2. **Strict types.** `where('rent', '1200')` fails against the integer `1200`; a `decimal:2` cast, however, serialises as the string `"1200.00"`. 3. **Scoping a list without a count.** `has('units', 5, fn ...)` scopes into `units.0`; `has('units', fn ...)` scopes into the list itself, whose keys are `0`, `1`, `2`… 4. **The component file check.** With `inertia.testing.ensure_pages_exist` left at `true`, `component()` looks the name up under `pages.paths`, by default `resources/js/pages`, with the configured extensions. A casing mismatch or a page not yet created fails the test even though the name matches. ## Beyond assertions - `$response->inertiaProps('properties.0.name')` returns a prop by dot path for plain PHPUnit or Pest expectations. - `$response->inertiaPage()` returns the whole page array. - For props that are deliberately not in the first response, the assertion object has reload helpers; those belong with the props features they test. ## PHPUnit and Pest look the same The macro sits on `TestResponse`, so the syntax is identical in a PHPUnit test method and in a Pest `test()` closure. In both, alias the class with `use Inertia\Testing\AssertableInertia as Assert;` so closures can type-hint `Assert $page`. Chains read best one assertion per line: - first the `component()` check, which fails fastest when the controller rendered the wrong page; - then the counts and scopes for collections; - last the `where()` checks for filters, totals and flags. When a scope fails, the message names the dot path, for example `properties.0`, so it is quicker to read than a raw JSON diff. ## A habit worth keeping Use `missing()` for fields that must never reach the browser, such as an owner's private notes or a bank reference. Everything in `props` is readable in the page source and in the network tab, so a test that proves absence guards against a later `$property` passed whole instead of a selected set of fields.
- Why do shared props such as errors and auth not break the unasserted-keys check?The unasserted-keys check runs only when a scope closure passed to `has()`, `first()` or `each()` finishes. `assertInertia` hands you the root of the props but never runs that check on the root. Shared props therefore sit at the root without being asserted, while any nested scope you open must account for every key or call `etc()`.
- How would you stop the component file check failing in a backend-only CI job?Set `testing.ensure_pages_exist` to `false` in `config/inertia.php`, or pass `false` as `component()`'s second argument for one assertion. The check needs the frontend page files on disk under `pages.paths`. If the job checks out the whole repository they are present, so disabling it is only needed when the frontend tree is absent.
saying these in an interview costs you the question
- assertInertia needs the request to send X-Inertia: true.
- component() only compares the name string.
- where() compares loosely, so '1200' matches 1200.
- Shared props must be listed or the root-level check fails.
- has('units', fn ...) scopes into the first unit automatically.