Learning path · Level 12 · Tutorial 2

Test React Components Through User Interactions

Test React forms and controls through accessible queries and realistic user actions, checking observable outcomes without depending on component internals.

  • Level: Intermediate
  • Duration: 25 min
  • 9 steps
Start tutorial Back to the path
Test React Components Through User Interactions

What you will go through

  1. Prepare a Local Component-Test Environment Configure a local Vite React project for component tests that use a scoped jsdom environment and DOM-oriented matchers. 4 min
  2. Render the Form and Find Its Controls Render the local ProfileForm and locate its visible controls through accessible roles and names. 3 min
  3. Choose Queries for Presence, Absence, and Appearance Choose accessible React Testing Library queries based on whether a form element is present now, absent now, or expected to appear later. 3 min
  4. Type, Clear, and Check Control Availability Use awaited user interactions to verify a controlled textbox value and the enabled or disabled state of the Save button. 3 min
  5. Submit the Form and Assert Its Feedback Submit ProfileForm through its real controls, assert its eventual status feedback, and verify that Reset restores the visible initial state. 3 min
  6. Verify Keyboard Actions and Focus Use awaited keyboard interactions and DOM focus assertions to verify keyboard submission and reset behavior in ProfileForm. 3 min
  7. Keep Every Component Test Independent Organize repeatable setup and explicit cleanup so each test starts with a fresh form and user-event session. 3 min
  8. Recognize What jsdom Cannot Prove Identify what a jsdom component test can verify and when a real browser or assistive technology is necessary. 2 min
  9. Complete and Run the Interaction Test Suite Complete, run, and review a compact component-test suite that protects ProfileForm behavior through accessible queries and realistic user interactions. 4 min

What you will learn

  • Configure Vitest to render React components in jsdom with DOM-oriented assertion matchers.
  • Choose accessible queries for present, absent, and asynchronously appearing elements.
  • Simulate awaited user interactions and assert changes to content, control values, availability, and focus.
  • Keep component tests independent through fresh rendering and cleanup.
  • Identify behavior that requires a real browser or assistive technology rather than jsdom.

Before you start

  • Test TypeScript Logic with Vitest
  • Build Controlled Forms in React
  • Lift Shared State to a Common Parent
  • Focus Native Controls with React Refs

Step 1 of 9

Prepare a Local Component-Test Environment

Configure a local Vite React project for component tests that use a scoped jsdom environment and DOM-oriented matchers.

Map the local testing tools

One component-test toolchain

A component test runs TSX and checks the rendered DOM from a user-facing perspective. The tools have distinct jobs:

  • Vitest runs and reports tests.
  • jsdom supplies a DOM simulation.
  • React Testing Library renders React into that DOM.
  • user-event later performs realistic user actions.
  • jest-dom adds DOM-oriented assertion matchers.

They work together locally, but they do not replace a real browser.

From TSX to observable DOM

Diagram showing a React form rendered into a simulated DOM and checked by separate testing tools.

Vitest coordinates the component test; jsdom supplies the simulated document where rendering and DOM assertions occur.

Responsibilities at a glance

Use each dependency for its own responsibility rather than expecting one package to provide every layer.

Local development dependencies for a React component test
ToolResponsibility
VitestRuns and reports tests
jsdomSupplies a simulated DOM
@testing-library/reactRenders React components
@testing-library/domProvides shared DOM query utilities
@testing-library/user-eventPerforms user-like actions
@testing-library/jest-domAdds DOM assertion matchers

Install and identify dependencies

Install the local dependencies

From your local Vite React + TypeScript project root, install these as development dependencies. Install @testing-library/dom explicitly because React Testing Library uses it as a peer dependency.

Terminal command

Run this command from the project root.

bash
npm install -D @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom jsdom
Expected output
added 78 packages, and audited 279 packages in 4s

45 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Package counts, audit messages, funding notices, vulnerability results, and timing vary by project and npm version. A successful installation typically ends without an npm error and reports added packages.

Match the dependencies

Match each dependency to its primary responsibility.

Tap an item and then its matching pair.

Configure jsdom, setup, and matchers

Scope jsdom to component tests

Keep ordinary logic tests in Vitest’s default Node environment. Instead of changing the environment globally, put a jsdom annotation on the first line of each TSX component test that needs a document. This scopes DOM simulation to component tests.

Component-test environment annotation

Place this as the first line of src/ProfileForm.test.tsx.

tsx
/** @vitest-environment jsdom */

vite.config.ts

Use this complete vite.config.ts in a standard Vite React project. If your file has unrelated options, retain them while adding the test section.

typescript
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: false,
    setupFiles: ['./src/test/setup.ts'],
  },
});

Shared setup file

Create src/test/setup.ts. A standard Vite TypeScript configuration includes src, so this setup file is within the project’s TypeScript scope. If your project has a narrower explicit include list, retain its existing entries and add this file’s path.

The explicit cleanup registration is intentional; its lifecycle is examined later.

src/test/setup.ts

Save this complete content in src/test/setup.ts.

typescript
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';

afterEach(cleanup);

Warning

Diagnose the missing layer

Two errors point to different missing layers:

  • An error such as document is not defined means the component test lacks a DOM environment. Check that its first line selects jsdom.
  • An error such as an unknown toBeInTheDocument property means the matcher integration is unavailable. Check the Vitest-specific jest-dom import and the configured setup-file path.

Do not solve a missing matcher by changing the test environment, or a missing DOM by importing matchers.

Practice the environment configuration

Complete the component-test environment

Complete the three missing environment details across these configuration fragments.

typescript
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: false,
    setupFiles: ['./src/test/'],
  },
});



import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import '';

afterEach(cleanup);

Step 2 of 9

Render the Form and Find Its Controls

Render the local ProfileForm and locate its visible controls through accessible roles and names.

Start with the rendered experience

The form's user-facing contract

ProfileForm is the running component for this tutorial. A person enters a display name, saves it locally, sees confirmation, and can reset the form.

Its observable contract is:

  • Display name is a labeled textbox.
  • Save profile is the submit button.
  • Reset form clears the field and any feedback.
  • A successful save can display a status message.

A component test starts from what a person can find and operate in the rendered document—not from Hook state, component instances, or CSS structure.

From visible controls to accessible queries

Diagram mapping the Display name label to a textbox and the two visible button labels to button queries by accessible name.

Associated labels and button text provide the accessible names that tests can use.

Create the component locally

Build the local form: state and actions

Create src/ProfileForm.tsx. Enter these two consecutive code sections in the same file. The save helper is local: it has no network request, timer, or persistence layer.

ProfileForm.tsx — part 1

First section of src/ProfileForm.tsx: imports, local save helper, and component actions.

tsx
import { useRef, useState, type FormEvent } from 'react';

async function saveProfile(displayName: string): Promise<string> {
  return displayName.trim();
}

export function ProfileForm() {
  const [displayName, setDisplayName] = useState('');
  const [feedback, setFeedback] = useState('');
  const nameInput = useRef<HTMLInputElement>(null);
  const trimmedName = displayName.trim();

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const savedName = await saveProfile(displayName);
    setFeedback(`Saved profile for ${savedName}.`);
  }

  function handleReset() {
    setDisplayName('');
    setFeedback('');
    nameInput.current?.focus();
  }

ProfileForm.tsx — part 2

Second section of src/ProfileForm.tsx: append this directly after part 1.

tsx
  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="display-name">Display name</label>
      <input
        ref={nameInput}
        id="display-name"
        value={displayName}
        onChange={(event) => setDisplayName(event.target.value)}
      />
      <button type="submit" disabled={trimmedName === ''}>
        Save profile
      </button>
      <button type="button" onClick={handleReset}>
        Reset form
      </button>
      {feedback && <p role="status">{feedback}</p>}
    </form>
  );
}

Select controls by what they mean

Render once; inspect through screen

render(<ProfileForm />) places the actual component into the DOM simulation. screen then searches that rendered document as a user would encounter it.

Use a role and an accessible name for interactive controls. The label associated with id="display-name" names the textbox. Each button's text names that button.

For this particular field, screen.getByLabelText('Display name') is also an appropriate label-based alternative. Prefer getByRole when you want the test to state both the control type and its name.

A render-and-query component test

Create src/ProfileForm.test.tsx. This first test establishes that the initial controls are present.

tsx
/** @vitest-environment jsdom */

import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ProfileForm } from './ProfileForm';

describe('ProfileForm', () => {
  it('renders its named controls', () => {
    render(<ProfileForm />);

    const nameInput = screen.getByRole('textbox', {
      name: 'Display name'
    });
    const saveButton = screen.getByRole('button', {
      name: 'Save profile'
    });
    const resetButton = screen.getByRole('button', {
      name: 'Reset form'
    });

    expect(nameInput).toBeInTheDocument();
    expect(saveButton).toBeInTheDocument();
    expect(resetButton).toBeInTheDocument();
  });
});

Tip

Test the interface, not its internals

Avoid queries such as .profile-form button, #display-name, or a component's state and props. They couple a component test to implementation choices rather than the control a person can identify.

Role-and-name queries also make an unclear label or an unexpected control type visible in the test.

Apply the control mapping

Practice: complete the render-and-query test

Complete this component test so it renders the form and maps the textbox and both buttons to their role-and-name queries.

tsx
import { expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ProfileForm } from './ProfileForm';

it('renders its named controls', () => {
  render(< />);

  const nameInput = screen.getByRole('textbox', {
    name: ''
  });
  const saveButton = screen.getByRole('button', {
    name: ''
  });
  const resetButton = screen.getByRole('button', {
    name: ''
  });

  expect(nameInput).toBeInTheDocument();
  expect(saveButton).toBeInTheDocument();
  expect(resetButton).toBeInTheDocument();
});

Step 3 of 9

Choose Queries for Presence, Absence, and Appearance

Choose accessible React Testing Library queries based on whether a form element is present now, absent now, or expected to appear later.

Choose by timing and expectation

Match the query to the UI state

A component test chooses its query from the UI condition—not from a habit.

  • Use getBy... when one element should exist now.
  • Use queryBy... when an element should be absent now.
  • Use findBy... when an element is expected to appear later.

All three can start from screen and use accessible roles. The difference is how they respond when the element is not currently available.

Three timing conditions

A three-panel diagram showing an immediately present form control, absent feedback, and feedback appearing after a short timeline.

A query should reflect whether the intended element exists now, is absent now, or is expected to appear.

Assert presence, absence, and later feedback

Query behavior at a glance

These are single-element queries: each targets one intended element. Use the matcher to express whether that element belongs in the rendered document.

Use accessible roles and names to identify the intended control.
QueryWhen to use itResult when no match existsTypical assertion
`getBy...`Expected immediatelyThrows an error`toBeInTheDocument()`
`queryBy...`Expected to be absentReturns `null``not.toBeInTheDocument()`
`findBy...`Expected to appear laterReturns a Promise that waits`await` then `toBeInTheDocument()`

Express the expected state

These fragments assume ProfileForm has already been rendered. The final check represents a test after an operation has started the local save flow.

tsx
const nameInput = screen.getByRole('textbox', {
  name: 'Display name',
});

expect(nameInput).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();

const feedback = await screen.findByRole('status');
expect(feedback).toHaveTextContent('Saved profile for Maya.');

Make the expectation precise

Let ambiguity reveal a weak query

A single-element query must identify exactly one element. If a getBy..., queryBy..., or findBy... query finds multiple matches, it fails instead of choosing one arbitrarily. That failure is useful: refine the accessible role, accessible name, or test setup so the intended element is unambiguous.

Use findBy... only for an element that genuinely may appear later. A control that is already part of the initial form should use getBy....

Warning

Avoid arbitrary sleeps

Do not pause a test for a guessed amount of time before checking eventual feedback. A fixed sleep is either too short on one run or unnecessarily slow on another. findBy... states what the test actually expects: the element should become available.

Practice query selection

Select queries for form states

Complete the three query methods so the test matches the UI conditions described in the code.

tsx
const nameInput = screen.('textbox', {
  name: 'Display name',
});
expect(nameInput).toBeInTheDocument();

// No submitted feedback exists in the initial form.
expect(screen.('status')).not.toBeInTheDocument();

// Feedback appears later after the save flow begins.
const feedback = await screen.('status');
expect(feedback).toBeInTheDocument();

Step 4 of 9

Type, Clear, and Check Control Availability

Use awaited user interactions to verify a controlled textbox value and the enabled or disabled state of the Save button.

Follow observable form states

Observe the form through user actions

A component test should observe what a person can do and what the DOM then shows. For this form, start with the empty field, type into the textbox, and check both the rendered value and the Save button state.

Create a new user-event session inside each test:

const user = userEvent.setup();

Await every interaction. user.type, user.clear, and user.click model a sequence of user actions, so the assertion belongs after the awaited action. Read a controlled input with toHaveValue; do not inspect React state. Read availability with toBeDisabled or toBeEnabled.

The observable state transition

A three-state form diagram: empty input with disabled save button, filled input with enabled save button, then cleared input with disabled save button.

Assertions follow each awaited interaction and check the rendered control value and availability.

Tip

Keep each test easy to read

Use a readable Arrange, Act, Assert flow:

  • Arrange: create user, render ProfileForm, and locate nameInput and the Save button.
  • Act: await one interaction.
  • Assert: check the visible DOM property that changed.

A value containing only spaces is still the textbox value, but its trimmed value is empty. Therefore, it must leave Save profile disabled.

Check the whitespace boundary

Test whitespace as a user would enter it

This test checks the whitespace-only boundary. Its own user-event session is new for this test, and the assertions concern the field and native button—not component state.

A whitespace-only name cannot be saved

Add this case to src/ProfileForm.test.tsx. The shared setup file already loads the DOM matchers and performs cleanup after each test.

tsx
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProfileForm } from './ProfileForm';

describe('ProfileForm', () => {
  it('keeps Save profile disabled for a whitespace-only name', async () => {
    const user = userEvent.setup();

    render(<ProfileForm />);

    const nameInput = screen.getByRole('textbox', {
      name: 'Display name'
    });
    const saveButton = screen.getByRole('button', {
      name: 'Save profile'
    });

    await user.type(nameInput, '   ');

    expect(nameInput).toHaveValue('   ');
    expect(saveButton).toBeDisabled();
  });
});

Tip

Assert the outcome, not the implementation

A disabled native button is an observable control property. Testing that property protects the rule that a blank or whitespace-only display name cannot be submitted, without needing to know how ProfileForm stores its state.

Practice the type-and-clear flow

Complete the interaction test

Complete the test so it verifies the form’s value and Save button availability as the user types and then clears the textbox.

tsx
it('updates Save profile as the name changes', async () => {
  const user = userEvent.setup();

  render(<ProfileForm />);

  const nameInput = screen.getByRole('textbox', {
    name: 'Display name'
  });
  const saveButton = screen.getByRole('button', {
    name: 'Save profile'
  });

  await user.type(nameInput, );

  expect(nameInput).('Maya');
  expect(saveButton).();

  await user.clear(nameInput);

  expect(saveButton).();
});

Step 5 of 9

Submit the Form and Assert Its Feedback

Submit ProfileForm through its real controls, assert its eventual status feedback, and verify that Reset restores the visible initial state.

Follow the rendered state, not internals

Observe the user-visible flow

A component test should submit the form through the same visible control a user uses. It should then inspect the rendered result—not call saveProfile directly and not inspect Hook state.

For this form, feedback does not exist at first. After a successful save, ProfileForm renders a status element. Reset removes that feedback and restores the empty field.

Save and reset change the rendered DOM

Diagram showing a profile form moving from an empty field to a visible success feedback panel after saving, then back to an empty field with no feedback after reset.

Assert each observable state: no feedback initially, status feedback after saving, then an empty field and no feedback after reset.

Assert save feedback, then reset

Save and locate eventual feedback

Start by proving that feedback is absent. queryByRole is appropriate because an absent status returns null instead of making the test fail before the absence assertion runs.

After the awaited Save click, use findByRole('status'). The local saveProfile helper returns a Promise, so the status is an eventual UI result. findByRole may resolve immediately if the awaited user-event session has already allowed React to render it; it still expresses the correct expectation without an arbitrary sleep.

Assert the successful save

Save feedback through the rendered status element

tsx
expect(screen.queryByRole('status')).not.toBeInTheDocument();

await user.type(nameInput, 'Maya');
await user.click(
  screen.getByRole('button', { name: 'Save profile' }),
);

const status = await screen.findByRole('status');
expect(status).toHaveTextContent('Saved profile for Maya.');
expect(status).toBeVisible();

Return to the initial UI

Reset is another user action with observable outcomes. Click the real Reset form button, check the controlled textbox value, and assert that the status no longer exists in the document.

This example covers a local Promise only. Network loading, failures, and recovery need controlled response tests later.

Assert reset outcomes

Continue after the successful-save assertions

tsx
await user.click(
  screen.getByRole('button', { name: 'Reset form' }),
);

expect(nameInput).toHaveValue('');
expect(screen.queryByRole('status')).not.toBeInTheDocument();

Practice the complete observable flow

Complete the save-and-reset test

Complete the component test for the complete save-and-reset flow.

tsx
it('saves and resets a profile', async () => {
  const user = userEvent.setup();
  render(<ProfileForm />);
  const nameInput = screen.getByRole('textbox', {
    name: 'Display name',
  });

  expect(screen.('status')).not.toBeInTheDocument();

  await user.(nameInput, 'Maya');
  await user.click(
    screen.getByRole('button', { name: 'Save profile' }),
  );

  const status = await screen.('status');
  expect(status).('Saved profile for Maya.');
  expect(status).toBeVisible();

  await user.click(
    screen.getByRole('button', { name: 'Reset form' }),
  );

  expect(nameInput).('');
  expect(status).;
});

Step 6 of 9

Verify Keyboard Actions and Focus

Use awaited keyboard interactions and DOM focus assertions to verify keyboard submission and reset behavior in ProfileForm.

Follow the Native Focus Order

Follow the keyboard path

A keyboard-focused component test follows the same native path a keyboard user takes. In this form, after a name makes Save profile available, the sequence is:

  1. Tab to the Display name textbox.
  2. Type a valid name.
  3. Tab to Save profile and activate it with Enter.
  4. Tab to Reset form and activate it with Enter.

Use the user-event session for each movement and activation, and await every interaction. Assert the focused DOM element with toHaveFocus; do not inspect the component ref.

Keyboard flow through the form

A three-stage Arrange, Act, Assert diagram shows focus moving through the profile form and returning to the textbox after reset.

The focus path is part of the form's observable behavior. This diagram is conceptual; the assertion checks DOM focus.

What this assertion proves

A component test in jsdom can establish that DOM focus moved to the expected control. It cannot establish that the focus indicator is visibly painted or that every keyboard behavior matches a real device and browser.

Choose the evidence source that matches the claim.
Evidence sourceCan establishDoes not establish
jsdomDOM focus and control statePainted focus indicator or layout
real browserRendered focus and browser interactionScreen-reader announcement
assistive technologyObserved announcements in a chosen setupBehavior in every assistive setup

Write an Observable Keyboard Test

Test keyboard submission and reset

Place this test in src/ProfileForm.test.tsx beneath the imports and existing tests. It starts from a fresh render, follows the native controls with Tab, and uses Enter on the currently focused button. findByRole('status') keeps the save assertion compatible with the Promise-based save operation.

A complete keyboard-flow test

Add this it block to src/ProfileForm.test.tsx.

tsx
it('submits and resets with the keyboard', async () => {
  const user = userEvent.setup();

  render(<ProfileForm />);

  const nameInput = screen.getByRole('textbox', {
    name: 'Display name'
  });
  const saveButton = screen.getByRole('button', {
    name: 'Save profile'
  });
  const resetButton = screen.getByRole('button', {
    name: 'Reset form'
  });

  await user.tab();
  expect(nameInput).toHaveFocus();

  await user.type(nameInput, 'Maya');
  await user.tab();
  expect(saveButton).toHaveFocus();

  await user.keyboard('{Enter}');
  const status = await screen.findByRole('status');
  expect(status).toHaveTextContent('Saved profile for Maya.');

  await user.tab();
  expect(resetButton).toHaveFocus();

  await user.keyboard('{Enter}');
  expect(nameInput).toHaveValue('');
  expect(nameInput).toHaveFocus();
});

Tip

Keep action and observation separate

user.keyboard('{Enter}') activates the focused native button. After Save profile completes, focus remains on that button, so the next Tab reaches Reset form. Reset clears the form and deliberately sends focus back to Display name.

Practice the Tab-and-Enter Sequence

Complete the keyboard reset check

Complete the awaited keyboard sequence and the final DOM-focus assertion.

tsx
const user = userEvent.setup();

render(<ProfileForm />);

const nameInput = screen.getByRole('textbox', {
  name: 'Display name'
});

const moveToNextControl = () => user.();
const activateFocusedControl = () => user.keyboard();

await moveToNextControl();
await user.type(nameInput, 'Maya');
await moveToNextControl();
await activateFocusedControl();
await screen.findByRole('status');
await moveToNextControl();
await activateFocusedControl();

expect(nameInput).();

Step 7 of 9

Keep Every Component Test Independent

Organize repeatable setup and explicit cleanup so each test starts with a fresh form and user-event session.

Start every test from the same UI state

Repeat the setup, not the state

A component test should repeat setup instructions, not reuse a document left behind by another test. Put common arrangement in beforeEach: create a new user-event session and render a new ProfileForm.

Each it owns only its actions and assertions. A case that saves a profile and a case that checks the initial form must both pass independently, regardless of which case runs first.

A fresh lifecycle for each test

Two independent test flows each start with a new user-event session and an empty rendered form, with cleanup after each completed test.

Arrange creates fresh UI for every case; Act and Assert remain inside that case.

Move duplicated arrangement into beforeEach

Create the session and render per test

Keep the shared arrangement near the top of src/ProfileForm.test.tsx. beforeEach runs before every individual test, so the first case can edit and submit the form while the next case can still verify its initial state.

The variable user is declared once for the test file, but it receives a new user-event session for every case. The form is rendered again for every case as well. That is reusing setup instructions—not reusing mutated UI.

Fresh setup with focused test cases

src/ProfileForm.test.tsx

tsx
/** @vitest-environment jsdom */

import { beforeEach, describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProfileForm } from './ProfileForm';

let user: ReturnType<typeof userEvent.setup>;

beforeEach(() => {
  user = userEvent.setup();
  render(<ProfileForm />);
});

describe('ProfileForm', () => {
  it('saves a display name', async () => {
    const nameInput = screen.getByRole('textbox', {
      name: 'Display name',
    });

    await user.type(nameInput, 'Maya');
    await user.click(
      screen.getByRole('button', { name: 'Save profile' }),
    );

    const status = await screen.findByRole('status');
    expect(status).toHaveTextContent('Saved profile for Maya.');
  });

  it('starts with an empty, disabled form', () => {
    const nameInput = screen.getByLabelText('Display name');

    expect(nameInput).toHaveValue('');
    expect(
      screen.getByRole('button', { name: 'Save profile' }),
    ).toBeDisabled();
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
  });
});

Use cleanup, but know its boundary

Clean up rendered UI after each test

The shared setup file handles teardown after every test. It explicitly imports afterEach, loads DOM-oriented matchers, and registers cleanup.

cleanup unmounts rendered React components and removes their test containers from the DOM simulation. A later test therefore cannot query or interact with UI rendered by an earlier test.

Explicit shared cleanup

src/test/setup.ts

typescript
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';

afterEach(cleanup);

What each lifecycle action covers

Cleanup has a deliberate boundary: it resets rendered UI, not every mutable value that might exist outside that UI. Keep outside state out of these component tests unless it has its own explicit lifecycle.

Use both hooks: one establishes a clean starting point, and one removes rendered UI afterward.
Lifecycle actionWhat it doesScope
beforeEachCreates a user-event session and renders ProfileFormEach individual test
afterEach(cleanup)Unmounts components and removes test containersRendered React UI
Not guaranteed by cleanupResets unrelated mutable state outside rendered UIOutside the rendered UI boundary

Warning

Avoid shared mutated renders

Do not render once outside the test cases and let several cases reuse that document. A prior interaction can leave an input value, feedback, focus, or enabled state that changes what a later test observes.

Practice the lifecycle hooks

Refactor repeated arrangement

Complete the two lifecycle hooks that preserve explicit rendered-UI cleanup and move repeated arrangement into a per-test hook.

typescript
/* src/test/setup.ts */
(cleanup);

/* src/ProfileForm.test.tsx */
let user: ReturnType<typeof userEvent.setup>;

(() => {
  user = userEvent.setup();
  render(<ProfileForm />);
});

Check the independence rule

Identify the shared-render risk

Why can rendering ProfileForm once outside several test cases make the cases order-dependent?

Step 8 of 9

Recognize What jsdom Cannot Prove

Identify what a jsdom component test can verify and when a real browser or assistive technology is necessary.

Understand the test boundary

DOM simulation has a useful boundary

A component test running in jsdom checks a DOM simulation, not a complete browser rendering engine. For ProfileForm, it can establish observable DOM behavior: content appears, the textbox value changes, buttons become disabled or enabled, and focus moves to a control.

That is valuable evidence about component behavior. It is not evidence that the page has been laid out, painted, or announced by a screen reader.

Three kinds of evidence

A diagram contrasting a virtual DOM simulation, a rendered browser viewport, and assistive technology output.

jsdom can inspect DOM state. A real browser is needed for rendered appearance, and assistive technology is needed to check announcements.

Separate DOM evidence from rendered and announced behavior

Match the claim to its evidence

Choose the check that can actually support the claim. A DOM matcher answers a DOM question; it does not turn jsdom into a layout engine or a screen reader.

What each check can establish

Evidence appropriate for common ProfileForm claims

Use the smallest check that provides the needed evidence.
ClaimAppropriate evidenceReason
Typed value is in the textboxComponent test in jsdomChecks a DOM control value
Reset moves focus to the textboxComponent test in jsdomChecks simulated active focus
Status message is renderedComponent test in jsdomChecks DOM content and role
Controls do not overlap at a narrow widthReal browser checkRequires layout and painting
Focus indicator can be seenReal browser checkRequires painted visual output
Save feedback is announcedAssistive-technology checkDepends on assistive technology behavior

Warning

Do not overstate DOM assertions

toBeVisible() is still a DOM-oriented assertion. It does not prove that an element is unobscured, within the viewport, responsive at a given width, or visibly outlined when focused.

Likewise, an accessible query and a role="status" assertion support a semantic DOM contract, but they do not prove screen-reader output or full accessibility conformance.

Practice choosing the right evidence

Classify the verification needed

Match each claim to the evidence needed to verify it.

Tap an item and then its matching pair.

Step 9 of 9

Complete and Run the Interaction Test Suite

Complete, run, and review a compact component-test suite that protects ProfileForm behavior through accessible queries and realistic user interactions.

1. Define the Regression Boundary

Protect Observable Form Behavior

A compact regression suite protects what a person can observe in ProfileForm: its initial controls, a pointer-driven save and reset, and keyboard-driven focus behavior.

Each case follows the same discipline:

  1. Arrange: beforeEach creates a fresh user-event session and renders a fresh form.
  2. Act: await realistic interactions with native controls.
  3. Assert: inspect accessible, user-visible DOM outcomes.

The shared setup file’s explicit afterEach(cleanup) removes each rendered form after its test. This prevents a saved message, a typed value, or a focused control from leaking into the next case.

One Form, Three Observable Flows

A three-panel diagram showing the form move from its empty initial state to a saved state and then back to a cleared, focused state after reset.

The suite checks visible state transitions and focus in the DOM, not Hook state or CSS structure.

Summary

Final Suite Review

Use this review list while reading the final suite.

  • Create a fresh user-event session and fresh rendered form for every test.
  • Find controls through accessible roles and names; use the status role for submitted feedback.
  • Await typing, clicks, tabbing, keyboard activation, and eventual feedback.
  • Assert values, enabled state, content, visibility, absence, and focus in the rendered DOM.
  • A passing jsdom component test does not establish real layout, painted focus visibility, or assistive-technology output.

2. Run the Complete Independent Suite

Assemble the Test File

Replace the contents of src/ProfileForm.test.tsx with the two consecutive parts below. The existing vite.config.ts, src/test/setup.ts, and src/ProfileForm.tsx from the earlier steps remain unchanged.

Part 1 imports the explicit APIs, creates a new user-event session for each test, and covers the initial and pointer-driven flows.

src/ProfileForm.test.tsx — Part 1

tsx
/** @vitest-environment jsdom */
import { beforeEach, describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProfileForm } from './ProfileForm';

let user: ReturnType<typeof userEvent.setup>;

beforeEach(() => {
  user = userEvent.setup();
  render(<ProfileForm />);
});

describe('ProfileForm', () => {
  it('shows the initial controls and no feedback', () => {
    const nameInput = screen.getByRole('textbox', { name: 'Display name' });

    expect(nameInput).toHaveValue('');
    expect(screen.getByRole('button', { name: 'Save profile' })).toBeDisabled();
    expect(screen.getByRole('button', { name: 'Reset form' })).toBeEnabled();
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
  });

  it('saves and resets through pointer actions', async () => {
    const nameInput = screen.getByRole('textbox', { name: 'Display name' });

    await user.type(nameInput, 'Maya');
    expect(screen.getByRole('button', { name: 'Save profile' })).toBeEnabled();

    await user.click(screen.getByRole('button', { name: 'Save profile' }));
    const feedback = await screen.findByRole('status');

    expect(feedback).toHaveTextContent('Saved profile for Maya.');
    expect(feedback).toBeVisible();

    await user.click(screen.getByRole('button', { name: 'Reset form' }));
    expect(nameInput).toHaveValue('');
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
  });

src/ProfileForm.test.tsx — Part 2

tsx
  it('submits and resets through the keyboard', async () => {
    const nameInput = screen.getByLabelText('Display name');

    await user.type(nameInput, 'Maya');
    await user.tab();
    expect(screen.getByRole('button', { name: 'Save profile' })).toHaveFocus();

    await user.keyboard('{Enter}');
    const feedback = await screen.findByRole('status');
    expect(feedback).toHaveTextContent('Saved profile for Maya.');

    await user.tab();
    expect(screen.getByRole('button', { name: 'Reset form' })).toHaveFocus();

    await user.keyboard('{Enter}');
    expect(nameInput).toHaveValue('');
    expect(nameInput).toHaveFocus();
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
  });
});

Run the Component Suite

Run this command from the Vite project root after saving the file.

bash
npx vitest run src/ProfileForm.test.tsx
Expected output
 RUN  v2.1.0 /path/to/project

 ✓ src/ProfileForm.test.tsx (3 tests) 94ms

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  10:00:00
   Duration  1.21s

Vitest version numbers, durations, and the project path vary. The important result is one passed test file and three passed tests.

3. Apply the Testing Pattern

Complete the Save Case

Complete this focused save case using the suite conventions.

tsx
(() => {
  user = userEvent.();
  render(<ProfileForm />);
});

it('saves a display name', async () => {
  const nameInput = screen.('textbox', { name: 'Display name' });

  await user.(nameInput, 'Maya');
  await user.click(screen.getByRole('button', { name: 'Save profile' }));

  const feedback = await screen.('status');
  expect(feedback).('Saved profile for Maya.');
});

4. Review the Result and Its Boundary

Record Your Local Evidence

After completing the code and running the command on your computer, write 2–4 sentences. State the observed passed or failed test-file and test counts. Then identify one claim about the form that a passing jsdom component test cannot establish.

Write at least 80 characters (0/80).

Component-Test Suite Complete

Congratulations! You completed: Test React Components Through User Interactions

You completed an independent component-test suite for the form’s initial state, save/reset flow, and keyboard focus behavior. Use real-browser and assistive-technology checks for claims outside DOM simulation.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate