close

User interactions

This guide covers how to simulate user interactions in Browser Mode tests, and helps you choose between stability, maintainability, and control granularity.

In Browser Mode, choose your interaction approach in the following priority order (only fall back when needed):

  • Locator API (preferred): Use page.getBy* + expect.element for semantic queries, interactions, and assertions, suitable for the vast majority of interaction tests
  • Testing Library: Best for migrating existing tests or reusing an established Testing Library toolchain; generally not the first choice for new tests
  • Native DOM API (fallback): Lighter and allows precise control over event properties, but requires manual event sequencing — ideal for verifying low-level event logic or special interaction details

Locator API

The Locator API is the default choice in Browser Mode. It is officially provided by Rstest: @rstest/browser provides the page query and interaction entry point, and @rstest/core provides expect.element assertion capabilities.

It adopts a Playwright-style Locator syntax (page.getBy* + chaining + expect.element), enabling both component tests and DOM tests to reuse the same interaction and assertion patterns.

Reasons to prefer the Locator API:

  • More stable queries: prioritizes locating elements by user-perceivable semantics such as role, label, and text
  • More direct interactions: actions like click, fill, check, and press are attached directly to the Locator
  • More natural assertions: combined with expect.element, waiting and assertion semantics stay consistent
  • Higher consistency: a single API set covers queries, actions, and assertions, reducing context-switching between multiple tools

Example

The following example focuses on the most common workflow: filling forms, clicking, and asserting.

import { page } from '@rstest/browser';
import { expect, test } from '@rstest/core';

test('interacts with form using locator api', async () => {
  document.body.innerHTML = `
    <form aria-label="Login form">
      <label for="username">Username</label>
      <input id="username" />

      <label for="password">Password</label>
      <input id="password" type="password" />

      <label>
        <input id="remember" type="checkbox" />
        Remember me
      </label>

      <button type="button">Login</button>
    </form>
  `;

  await page.getByLabel('Username').fill('alice');
  await page.getByLabel('Password').fill('secret123');
  await page.getByLabel('Remember me').check();
  await page.getByRole('button', { name: 'Login' }).click();

  await expect.element(page.getByLabel('Username')).toHaveValue('alice');
  await expect.element(page.getByLabel('Remember me')).toBeChecked();
});

Common queries and composition

You can compose Locators just like in Playwright, progressively narrowing the scope to the target element:

import { page } from '@rstest/browser';
import { expect, test } from '@rstest/core';

test('composes locators', async () => {
  document.body.innerHTML = `
    <section>
      <h2>Home</h2>
      <button>Save</button>
    </section>
    <section>
      <h2>Profile</h2>
      <button>Save</button>
    </section>
  `;

  const saveInProfileSection = page
    .locator('section')
    .filter({ has: page.getByRole('heading', { name: 'Profile' }) })
    .getByRole('button', { name: 'Save' });

  await expect.element(saveInProfileSection).toHaveCount(1);
});

Currently available query/composition capabilities include:

In practice, prefer semantic queries (getByRole, getByLabel) first, and only fall back to getByTestId or CSS selectors when semantic information is insufficient.

Common interactions and assertions

Locators support common interaction APIs (such as click, fill, check, hover, press, selectOption), and can be directly combined with expect.element assertions:

It's recommended to assert observable results immediately after key interactions (for example, status text, button state, field values) — this keeps failure messages focused and reduces debugging cost.

Auto-wait vs Auto-retry

These are two distinct mechanisms in the Locator API:

  • Auto-wait (interactions): Methods like click(), fill(), check() automatically wait for the target element to be visible, enabled, and stable before executing the action.
  • Auto-retry (assertions): expect.element matchers continuously retry the assertion within the timeout until it passes, ideal for async rendering scenarios.

In most cases, you only need to await each call — the framework handles all waiting and retrying internally.

You can also chain not and use an optional timeout:

await expect
  .element(page.getByRole('button', { name: 'Save' }))
  .not.toBeDisabled({ timeout: 1000 });
Strictness

Locator actions are strict: if a locator matches more than one element, actions like click and fill will throw an error. Use first(), last(), or nth() to select a specific element.

Testing library

Testing Library is a testing utility library focused on user behavior. It encourages writing tests that mirror how users actually interact with your application, rather than relying on implementation details. In Browser Mode, it is better suited as a compatibility and migration solution:

  • @testing-library/dom: Handles queries, providing methods like getByRole, getByText, and getByLabelText that let you find elements by user-perceivable semantics
  • @testing-library/user-event: Handles interactions, providing more complete event simulation flows; in Browser Mode, the Locator API is still recommended for new tests

Installation

npm
yarn
pnpm
bun
deno
npm add @testing-library/dom @testing-library/user-event -D

Example

Here's a complete form submission test example demonstrating common user interaction methods:

src/LoginForm.test.tsx
import { expect, test } from '@rstest/core';
import { render } from '@rstest/browser-react';
import { getByLabelText, getByRole } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

test('submits login form with user credentials', async () => {
  const user = userEvent.setup();
  const onSubmit = rstest.fn();

  const { container } = await render(<LoginForm onSubmit={onSubmit} />);

  // Type into input fields
  await user.type(getByLabelText(container, 'Username'), 'alice');
  await user.type(getByLabelText(container, 'Password'), 'secret123');

  // Check the "remember me" checkbox
  await user.click(getByLabelText(container, 'Remember me'));

  // Submit the form
  await user.click(getByRole(container, 'button', { name: 'Login' }));

  // Assert the form was submitted with correct data
  expect(onSubmit).toHaveBeenCalledWith({
    username: 'alice',
    password: 'secret123',
    rememberMe: true,
  });
});

If your project already uses Testing Library extensively, you can continue reusing its click, text input, keyboard event, dropdown selection, drag and drop, and other capabilities. For detailed usage, see the user-event documentation.

Native DOM API

If you prefer not to add extra dependencies, or need lower-level event control (such as precisely specifying clientX, ctrlKey, etc.), you can use native browser DOM APIs directly. This is typically used as a fallback, only when you need precise control over event parameters.

Example

The following example demonstrates common native event operations including click, input, and keyboard events:

src/native-events.test.ts
import { expect, test } from '@rstest/core';

test('handles click and input events', () => {
  // Create elements
  const button = document.createElement('button');
  const input = document.createElement('input');
  document.body.append(button, input);

  // Click event
  let clicked = false;
  button.addEventListener('click', () => (clicked = true));
  button.click();
  expect(clicked).toBe(true);

  // Input event
  input.focus();
  input.value = 'hello';
  input.dispatchEvent(new InputEvent('input', { bubbles: true }));
  expect(input.value).toBe('hello');
});

test('handles keyboard shortcuts', () => {
  let shortcutTriggered = false;

  document.addEventListener('keydown', (e) => {
    // Detect Ctrl+S shortcut
    if (e.ctrlKey && e.key === 's') {
      e.preventDefault();
      shortcutTriggered = true;
    }
  });

  document.dispatchEvent(
    new KeyboardEvent('keydown', {
      key: 's',
      code: 'KeyS',
      ctrlKey: true,
      bubbles: true,
    }),
  );

  expect(shortcutTriggered).toBe(true);
});