Bifrost turns 1! Get 30% off annual plans with code
HAPPYBIRTHDAY

Interactions


Overview#

Interactions drive a screen the same way a person would — pressing buttons, typing into fields, flipping toggles. Each one dispatches a real event through the component's normal event path and then re-renders, so the assertions after it see the updated frame.

Every interaction method returns the harness, so they chain fluently.

Targeting elements#

Most interactions take a target. There are two ways to point at an element.

By visible text#

tap() finds the nearest pressable element whose subtree contains the given text. This is the most natural way to press a labelled button or card:

Copied!
Native::test(Dashboard::class)->tap('Refresh');

By ref#

For anything without unique visible text — an icon-only button, a specific input, a card among many — give the element a ref and target it by name. A ref works in Blade on any element:

Copied!
<native:pressable ref="vibrate-card" @press="vibrate">
<native:text>Tap to vibrate</native:text>
</native:pressable>
 
<native:outlined-text-input ref="message-input" native:model="message" />

...and via ->ref() on element builders:

Copied!
Pressable::make()->ref('vibrate-card')->onPress('vibrate');

Then target that name from the test:

Copied!
Native::test(HapticsDemo::class)
->tap('vibrate-card')
->assertSet('buzzes', 1);

Refs make a test read cleanly and survive copy changes — the label can move without breaking the test.

4.7 A ref also reaches the platform's accessibility tree, as the accessibility identifier on iOS and the resource ID on Android, so tools that drive the real app, such as Maestro or XCUITest, can find elements by the same name.

The same ref on two screens makes that element morph between them when you navigate with the morph transition. Add morph="none" to an element whose ref is only there for tests if you don't want that.

Pressing#

  • tap($target) — press by ref or visible text (the friendliest default).
  • press($target) — press an element bound to a method, expression, or ref.
  • longPress($target) — fire a long-press.
Copied!
it('opens the menu on long press', function () {
Native::test(Gallery::class)
->longPress('photo-1')
->assertSee('Delete');
});

Inputs and form controls#

Each control fires the same event its native counterpart emits:

  • input($target, $text) — type into a text field.
  • submit($target, $text = '') — submit a field (e.g. the return key).
  • toggle($target, $value) — flip a toggle on or off.
  • check($target, $value = true) — check or uncheck a checkbox.
  • slide($target, $value) — move a slider to a float value.
  • select($target, $value) — choose a value in a select.
  • selectRadio($target, $value) — pick a radio option.
  • changeTab($target, $index) — switch a tab row to an index.
  • dismissSheet($target) — dismiss a bottom sheet.

Fields bound with native:model sync their property as you'd expect:

Copied!
it('fills and submits the toast form', function () {
Native::visit('/dialogs/toast')
->input('message-input', 'Hello from CI')
->assertSet('message', 'Hello from CI')
->press('show-toast')
->assertNativeCalled('Dialog.Toast', fn (array $p) => $p['message'] === 'Hello from CI');
});

List rows#

A list item's checkbox, radio and swipe-to-delete callbacks are best targeted by the expression you bound, since each row in a loop has its own:

Copied!
<native:list-item
:headline="$task->title"
:leadingCheckbox="$task->done"
on-leading-change="toggleTask({{ $task->id }})"
on-swipe-delete="deleteTask({{ $task->id }})"
/>
Copied!
Native::test(Tasks::class)
->check('toggleTask(1)') // calls toggleTask(1, true)
->press('deleteTask(1)'); // swipe-to-delete arrives as a press

Setting properties and calling methods#

Two lower-level tools reach the component directly.

set($property, $value) sets a public property through the same binding path a native input uses — so it fires any updatedFoo() hook — then re-renders:

Copied!
Native::test(SecureStorageDemo::class)
->set('key', 'api-key')
->set('value', 's3cret')
->call('store');

call($method, ...$args) invokes a component method directly, then re-renders. Reach for it to trigger logic that isn't wired to a visible control, or to set up state:

Copied!
Native::test(Home::class)
->call('doubleTapped')
->assertSet('gesture', 'Double-tapped!');

The generic primitive: fireEvent()#

All the input sugar above is built on fireEvent($target, $type, $fields = []), which dispatches a wire event at the callback registered for a target. You rarely need it directly, but it's there when you're driving a custom element that emits an event the sugar doesn't cover:

Copied!
Native::test(CustomControl::class)
->fireEvent('custom-widget', TestableComponent::EVENT_SLIDER_CHANGE, ['value' => 0.5]);

Polling#

Screens that refresh with #[Poll] don't wait for a timer under test — you fire them on demand.

  • firePolls() fires every #[Poll] method immediately and re-renders, as if all timers came due at once.
  • firePoll($method) fires a single #[Poll] method by name.
Copied!
it('polls the native recording status on demand', function () {
Native::fakeBridge()->respondTo('Microphone.GetStatus', ['status' => 'recording']);
 
Native::test(MicrophoneDemo::class)
->call('startRecording')
->firePoll('pollStatus')
->assertSet('status', 'Native status: recording')
->assertSee('Native status: recording');
});

For screens that override onSearchQuery(), run a query through the same handler the runloop uses. search($query) drives the handler and stages the results for the next frame; searchResults() returns those staged results:

Copied!
it('filters the contact list', function () {
$screen = Native::test(ContactSearch::class)->search('ada');
 
expect($screen->searchResults())->toHaveCount(1);
$screen->assertSee('Ada Lovelace');
});

Back button#

pressBack() simulates the system back gesture or hardware back button:

Copied!
Native::test(EditPost::class)
->pressBack()
->assertWentBack();

See Navigation & Flows for asserting on where back takes you.