July 24, 2026

Testing Lattice: assert the payload, not the pixels

By Manuel Christlieb — Staff Engineer

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.