Testing Lattice: assert the payload, not the pixels
Part 12 of the Building Lattice series. Part 11 covered the generated wire types.
Generated types prove the contract’s shape at compile time. They say nothing about behavior — a form that validates the wrong field has a perfectly well-typed payload. That’s the other half of trusting the contract, and it’s where a server-driven UI pays out a dividend I didn’t fully anticipate: because the interface is a serialized payload, a test can assert against the exact thing the user receives — what renders, what’s hidden, what a field is seeded with — without a browser and without duplicating the UI contract in a test DSL.
Setup is one trait
Mix InteractsWithLatticeComponents into your base TestCase and everything
below is available on $this. The examples are Pest, but it’s plain PHPUnit
machinery underneath.
Asserting what renders
assertLatticePage() takes a normal HTTP test response and navigates the
schema tree that came over the wire:
$response = get('/products')->assertOk();
$this->assertLatticePage($response)
->component('button', 'create-product',
fn ($button) => $button->assertProp('href', '/products/create'))
->component('table', 'workbench.products',
fn ($table) => $table->assertProp('data.0.name', 'Desk Lamp'));
Forms get their own assertion vocabulary, down to the conditional visibility rules from Part 3:
$this->assertLatticeComponent($form)
->form('create', fn (FormAssertions $form) => $form
->assertSubmitsTo('/products')
->assertHasField('email')
->assertMissingField('secret')
->field('company', fn (FieldAssertions $f) => $f
->assertVisibleWhen(['type' => 'business'])
->assertHiddenWhen(['type' => 'personal'])));
Note what’s absent: no HTML strings, no CSS selectors, no headless browser. You’re asserting against the schema — the same tree the React renderer walks.
Driving the endpoints
Every interactive definition has a real endpoint, and the helpers hit it the
way the client would — they build the component, extract its signed ref,
and send it in the X-Lattice-Ref header, so the signing layer is exercised
rather than bypassed:
$this->submitForm(ProfileForm::class, ['name' => 'Ada'])
->assertRedirect('/profile');
$this->callBulkAction(ArchiveSelected::class,
['selected' => [1, 2]], ['table' => 'app.products'])
->assertOk();
Since Part 5, actions answer with effects — and as of 0.21 those are first-class assertions too, matched by type and a subset of props rather than raw JSON paths:
$this->callAction(ArchiveProduct::class, ['id' => $product->id])
->assertOk()
->assertToast(Variant::Success, 'Product archived.')
->assertReloadsComponent('app.products');
Authorization has two halves — test both
A denied component doesn’t render disabled; it leaves no trace on the wire — no node, no endpoint URL in the payload. But absence from the payload is only half the guarantee; the endpoint itself must refuse too. The denied-component helpers seal a ref directly against the definition’s key, precisely so the test can reach an endpoint the render path would never expose:
$this->assertLatticePage($response)
->assertNotRendered('action:app.products.archive');
$this->callDeniedAction(ArchiveProduct::class)->assertForbidden();
That second line is my favorite assertion in the framework. It answers the question a hidden button always begs: what if someone calls the endpoint anyway?
The trade, honestly
None of this replaces a handful of real browser tests for genuinely client-side behavior — the modal choreography, the live validation debounce. What it replaces is the thick middle of UI testing: the “does the right thing render for the right user with the right data” tests that are slow and brittle in a browser and nearly free against a payload. The full assertion catalog is in the testing docs.
Compile-time shapes, test-time behavior — the contract holds within one application. Next time, the experiment I teased back in Part 10: what happens when the contract has to stretch across applications, and the honest state of a feature I’m still not sure about.