Testing
Testing matters more, not less, for a server-driven UI. Your interface is described in PHP and serialized to the browser, so a test can assert against the exact payload a user receives — what renders, what is hidden, what a field is seeded with — without a browser or a duplicated UI contract. Correctness becomes cheap to pin down, so treat tests as a first-class part of building with Lattice, not an afterthought.
Lattice holds itself to the same bar: the package is covered by an extensive Pest and Vitest suite, and it ships — and dogfoods — the very helpers documented here.
Two traits cover the ground. AssertsLatticeComponents makes readable assertions about the
components your pages render — which forms, tables, and actions are visible, whether a field
is shown or hidden, what value it carries, which filters a table exposes — all against the
serialized wire tree, the exact payload that reaches the browser. InteractsWithLatticeComponents
adds the other half: it submits to a component’s own endpoint with a signed ref sealed from the
class and context, so you can exercise forms, actions, tables, and fragments end to end. It
bundles AssertsLatticeComponents, so a single trait gives you both.
The assertion helpers are inspired by Filament’s, adapted to Lattice’s stateless Inertia output.
Apply InteractsWithLatticeComponents once in your base TestCase. It bundles
AssertsLatticeComponents, so a single trait provides both the assertions and the endpoint
helpers:
use Lattice\Lattice\Support\Testing\InteractsWithLatticeComponents;
abstract class TestCase extends \Tests\TestCase{ use InteractsWithLatticeComponents;}…or per file in Pest:
uses(Lattice\Lattice\Support\Testing\InteractsWithLatticeComponents::class);Entry points
Section titled “Entry points”They produce the same fluent assertions; pick the one that fits the test.
Against a component you build in the test — fast, no HTTP:
use Lattice\Lattice\Forms\Components\Form;use Lattice\Lattice\Forms\Components\TextInput;
$form = Form::make('product')->action('/products')->schema([ TextInput::make('name', 'Name'),]);
$this->assertLatticeComponent($form) ->assertHasForm('product');Against a rendered page — reads the component tree from the Inertia response:
$this->assertLatticePage($this->get('/products')) ->assertRendered('table:products');Against a page’s layout shell — the sidebar, topbar, and other chrome around the page:
$this->assertLatticeLayout($this->get('/')) ->assertRendered('topbar') ->assertRendered('menu-item:settings');Page tests render a real Inertia view. If your front-end assets aren’t built in the test environment, call
withoutVite()first (use function Pest\Laravel\withoutVite;).
Navigating the tree
Section titled “Navigating the tree”From the root you navigate to a component, then assert on it. Pass a closure to scope a group of assertions and keep chaining from the root; omit it to get the node back directly.
use Lattice\Lattice\Support\Testing\Assertions\FormAssertions;use Lattice\Lattice\Support\Testing\Assertions\FieldAssertions;
$this->assertLatticeComponent($form) ->form('product', fn (FormAssertions $form) => $form ->assertSubmitsTo('/products') ->field('name', fn (FieldAssertions $field) => $field ->assertVisible() ->assertInitialValue('Desk Lamp')));form(?id), table(?id), and action(id) find a component by type (and optional id). When
the id is omitted, the first component of that type is used. component(type, ?id, ?tap) does
the same for any component type; assertProp(key, value) and assertProps([...]) then assert
the scoped node’s props:
$this->assertLatticeLayout($this->get('/')) ->component('topbar', tap: fn ($topbar) => $topbar->assertProp('sticky', true)) ->component('menu-item', 'settings', fn ($item) => $item->assertProp('icon', 'settings'));The id segment matches a component’s interactive id or its author-supplied key, so
layout and container components (topbar, menu-item:settings, dropdown:user-menu, …) are
addressable the same way.
Anywhere a type is accepted you may pass the component class instead of its wire string — it
resolves to the declared type, so a rename or typo is caught by the compiler:
use Lattice\Lattice\Layouts\Components\MenuItem;use Lattice\Lattice\Layouts\Components\Topbar;
$this->assertLatticeLayout($this->get('/')) ->component(Topbar::class, tap: fn ($topbar) => $topbar->assertProp('sticky', true)) ->component(MenuItem::class, 'settings', fn ($item) => $item->assertProp('icon', 'settings'));assertProp() keys may be dot-notated to reach into nested prop data — handy for form state or
table columns without walking the tree by index:
$this->assertLatticePage($this->get('/products/1/edit')) ->component('form', 'products.form', fn ($form) => $form->assertProps([ 'method' => 'patch', 'state.name' => 'Desk Lamp', 'state.sales_prices.0.amount' => '49.99', ]));Asserting what is rendered
Section titled “Asserting what is rendered”A component is “rendered” when it survives shouldRender() and authorize() and appears in
the tree. Use selectors of the form type or type:id:
$this->assertLatticePage($this->get('/products')) ->assertRendered('table:products') ->assertRendered('action:create') ->assertNotRendered('action:archive') // hidden for this user ->assertRenderedCount('action', 1);This is the natural way to test authorization-driven visibility: gate an action behind a
policy, act as a user who fails it, and assert assertNotRendered('action:…').
Forms and fields
Section titled “Forms and fields”$this->assertLatticeComponent($form) ->form('product', fn (FormAssertions $form) => $form ->assertSubmitsTo('/products') ->assertHasField('name') ->assertMissingField('secret') ->field('name', fn (FieldAssertions $field) => $field ->assertVisible() ->assertRequired() ->assertInitialValue('Desk Lamp')));Field assertions: assertVisible / assertHidden, assertVisibleWhen($state) /
assertHiddenWhen($state), assertRequired / assertOptional, assertDisabled /
assertEnabled, assertReadOnly, assertInitialValue($value), and
assertHasCondition($type, $field, $operator, $value).
assertVisible / assertHidden check whether a field is force-hidden via ->hidden(). A
field shown only by a condition (->visibleWhen(...)) is not statically hidden, so it passes
assertVisible. To evaluate conditional visibility for a given form state, use
assertVisibleWhen / assertHiddenWhen, which run the field’s conditions against the state
you pass:
use Lattice\Lattice\Core\Enums\Op;
$field = TextInput::make('sku')->visibleWhen('type', 'physical');
$this->assertLatticeComponent($form) ->form('product', fn (FormAssertions $form) => $form ->field('sku', fn (FieldAssertions $field) => $field ->assertVisibleWhen(['type' => 'physical']) ->assertHiddenWhen(['type' => 'digital']) ->assertHasCondition('visible', 'type', Op::Equals, 'physical')));assertInitialValue resolves the value the field is seeded with: a ->fill()ed form’s state
wins, otherwise the field’s own ->value() — matching the bound-edit runtime precedence.
Tables and filters
Section titled “Tables and filters”use Lattice\Lattice\Core\Enums\Op;use Lattice\Lattice\Tables\Enums\FilterType;use Lattice\Lattice\Support\Testing\Assertions\TableAssertions;use Lattice\Lattice\Support\Testing\Assertions\FilterAssertions;
$this->assertLatticePage($this->get('/products')) ->table('products', fn (TableAssertions $table) => $table ->assertHasColumn('name') ->assertHasFilter('name') ->assertMissingFilter('internal_notes') ->assertHasBulkAction('archive') ->filter('name', fn (FilterAssertions $filter) => $filter ->assertType(FilterType::Text) ->assertDefaultOperator(Op::Contains) ->assertOperators([Op::Contains, Op::Equals])));Actions
Section titled “Actions”use Lattice\Lattice\Core\Enums\ButtonVariant;use Lattice\Lattice\Support\Testing\Assertions\ActionAssertions;
$this->assertLatticeComponent($action) ->action('archive', fn (ActionAssertions $action) => $action ->assertLabel('Archive') ->assertEndpoint('/lattice/actions/archive') ->assertVariant(ButtonVariant::Destructive) ->assertHasConfirmation() ->assertConfirmationTitle('Archive product?') ->assertHasForm());Submitting to a component endpoint
Section titled “Submitting to a component endpoint”The assertions above check what a page renders. To exercise the other half — what happens
when a form is submitted, an action is invoked, or a table is queried — InteractsWithLatticeComponents
seals a signed ref from the component class and context and posts to the generic endpoint for
you, returning the TestResponse to assert on with the usual Laravel helpers:
$this->submitForm(ProductForm::class, ['name' => 'Desk Lamp']) ->assertRedirect('/products');
$this->callAction(ArchiveProduct::class, ['id' => 7], context: ['team' => $team]) ->assertJsonPath('ok', true);
$this->callBulkAction(ArchiveProducts::class, ['selected' => [1, 2]], context: ['table' => 'products']) ->assertOk();
$this->loadTable(ProductsTable::class, ['filter' => ['name' => 'lamp'], 'page' => 2]) ->assertJsonPath('data.0.name', 'Desk Lamp');
$this->loadFragment(SalesChart::class) ->assertOk();Each helper builds the component exactly as a render would — same class, same context — extracts
the signed ref, and issues the request with the component’s declared HTTP method and the
X-Lattice-Ref header. The request payload is the second argument; context (the active team or
tenant, and the bound table slug for a bulk action) is the third.
Test selectors
Section titled “Test selectors”Lattice uses data-test as its standard test-hook attribute. data-testid is not used.
Pest browser tests target data-test via the @shorthand selector. ->click('@foo') is
equivalent to clicking [data-test="foo"].
Vitest + Testing Library unit tests query it via getByTestId, queryByTestId, and friends.
The resolver is pointed at data-test by the configure({ testIdAttribute: "data-test" }) call
in resources/js/test/setup.ts, so no test-by-test configuration is needed.
Helpful failures
Section titled “Helpful failures”Every assertion fails with context. A missing field lists the available fields; an unrendered selector lists what was rendered; a missing filter lists the table’s filters — so you spend less time printing the payload to find a typo.
Lattice form field [emial] not found at [page › form#product].Available fields: [name, sku, price].