How to Test User Interactions in UI Components
Test UI components through accessible controls, realistic user actions, and visible outcomes. Learn when to use user-event, fireEvent, DOM tests, or browser component tests.
Test a UI interaction by finding the control the way a person would, performing a realistic action, and checking the result in the interface. In a React DOM test, that usually means rendering the component, creating a userEvent session, finding a button by role and accessible name, awaiting a click, and asserting on visible output. Use a DOM test for behavior the DOM environment can represent; use a browser component test when the behavior depends on a real browser.
This approach keeps ordinary tests focused on what users can do and observe instead of private state, methods, or lifecycle details. Testing Library summarizes the principle as: “The more your tests resemble the way your software is used, the more confidence they can give you.” See its guiding principles.
1. Start with the interaction and its visible result
Before writing a test, describe the scenario in user terms:
- What control does the person find?
- What action do they take?
- What should become visible, enabled, selected, or changed?
For example: “When a person activates Save, a confirmation appears.” The test should assert on that confirmation, not on a component state variable such as isSaved. If the implementation changes from local state to a reducer or a request, the behavioral test can remain valid as long as the user-visible result stays the same.
2. A complete React example with Testing Library
The following example uses Vitest, React Testing Library, user-event, and jest-dom. Install these packages in a project that already has React and a DOM test environment configured:
npm install --save-dev vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
Configure Vitest to use jsdom and load jest-dom matchers. For example, in vite.config.js:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test-setup.js'],
},
})
In src/test-setup.js:
import '@testing-library/jest-dom/vitest'
Component and test:
// SaveButton.jsx
import { useState } from 'react'
export function SaveButton() {
const [saved, setSaved] = useState(false)
return (
<div>
<button type="button" onClick={() => setSaved(true)}>
Save
</button>
{saved && <p role="status">Changes saved</p>}
</div>
)
}
// SaveButton.test.jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { SaveButton } from './SaveButton'
describe('SaveButton', () => {
it('shows a confirmation after the person saves', async () => {
const user = userEvent.setup()
render(<SaveButton />)
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(screen.getByRole('status')).toHaveTextContent('Changes saved')
})
})
Run it with npx vitest run. Use the setup shape documented by user-event: create the session before rendering, then await the interaction. This example checks what appears after the action, not how the component stores that result internally.
3. Find controls by accessible role, name, or label
Choose a query that reflects how the control is exposed to people and assistive technology.
| Query | Good fit | Example |
|---|---|---|
getByRole |
Buttons, links, checkboxes, headings, and other semantic elements | screen.getByRole('button', { name: 'Save' }) |
getByLabelText |
Form fields with a visible or accessible label | screen.getByLabelText('Email address') |
getByText |
Visible text when there is no more specific semantic query | screen.getByText('Changes saved') |
getByTestId |
A fallback when meaningful user-facing semantics are impractical | screen.getByTestId('chart-canvas') |
Prefer a role plus accessible name for controls when possible. A failed role query can point to a genuine accessibility problem, such as an unlabeled button, rather than just a brittle selector. Use a label query for form fields. Test IDs are useful for elements like a canvas that have no sensible user-facing name, but should not be the default for an ordinary button or input. See Testing Library’s query guidance and its React Testing Library introduction.
For example, a labeled form control can be tested like this:
render(<label>Email address <input type="email" /></label>)
const email = screen.getByRole('textbox', { name: 'Email address' })
await user.type(email, 'dev@example.com')
expect(email).toHaveValue('dev@example.com')
4. Choose user-event or fireEvent
user-event is the usual choice for common user actions. It models an interaction as a sequence and checks constraints such as whether the target can be interacted with. For example, typing text involves more than dispatching one input event.
const user = userEvent.setup()
render(<button onClick={handleClick}>Continue</button>)
await user.click(screen.getByRole('button', { name: 'Continue' }))
Use fireEvent when the needed interaction is not covered by user-event or when a specific low-level event is deliberately what the test needs. It is a lightweight wrapper around dispatchEvent, so it does not model all the steps or browser constraints of a person’s action.
import { fireEvent, render, screen } from '@testing-library/react'
render(<input aria-label="Search" />)
fireEvent.input(screen.getByRole('textbox', { name: 'Search' }), {
target: { value: 'component testing' },
})
Prefer an awaited user-event action for a normal click, typing, selection, or keyboard interaction. Use fireEvent for an event-level case with a clear reason. The user-event documentation explains the distinction and notes that the package works with any framework as long as a DOM is available.
5. Test asynchronous results without arbitrary delays
Many interactions trigger a promise, a request, or a delayed update. Await the action, then use a query suited to how the result appears. Avoid fixed sleeps such as await new Promise(resolve => setTimeout(resolve, 1000)): they slow every run and can still be too short on a busy machine.
await user.click(screen.getByRole('button', { name: 'Load profile' }))
const profile = await screen.findByRole('heading', { name: 'Taylor Lee' })
expect(profile).toBeVisible()
Testing Library query families have different absence and retry behavior:
| Query | When to use it | Behavior |
|---|---|---|
getBy… |
Element should already exist | Returns the match or throws if none (or more than one) match. |
queryBy… |
Checking that an element is absent | Returns null if none matches; throws for multiple matches. |
findBy… |
Element should appear asynchronously | Returns a promise that retries until it finds one match or times out. |
Use waitFor when the condition is not simply that one element appears, such as when you need to retry an assertion about a mock call. Keep the callback focused because it may run more than once:
await waitFor(() => {
expect(saveRequest).toHaveBeenCalledWith({ title: 'Notes' })
})
Do not put an action such as user.click inside waitFor; retries could repeat the action. Consult the query documentation for the current query behavior and configuration.
6. Cover the interaction’s important states
One happy-path click rarely covers the full behavior. Identify states the user can encounter and write focused tests for the relevant transitions.
- Before action: Is the control present, labeled, and in the expected enabled or disabled state?
- After action: Does the expected result appear, disappear, or change?
- Repeated action: Does clicking twice duplicate a submission, toggle a setting, or remain safe?
- Validation: Does invalid input show a useful message and associate it with the field?
- Async work: Is a pending indicator visible? Does success or failure produce an understandable outcome?
- Keyboard use: Can a person reach the control and activate it with the expected keyboard interaction?
- Disabled and hidden cases: Can an unavailable control actually be operated? Is content absent when it should be?
Test only states that represent the component’s behavior contract. Keep each test centered on a behavior so failures are easy to understand and do not repeat the same assertion in many places.
7. Apply the same idea across frameworks
The testing principle is framework-independent: render the component with its normal test tools, locate the control through user-facing semantics, act, and assert on the rendered result. Testing Library provides framework wrappers including React, Angular, and Vue; user-event can work with a DOM across frameworks.
For Vue, the official guide identifies @vue/test-utils as its low-level component testing library and recommends exercising interactions as a user would. See Vue’s testing guide. Use the framework’s rendering and event helpers while keeping queries and assertions centered on the outcome visible to a user.
8. Decide when a DOM test is enough and when to use a browser
A DOM-based component test is often a good fit for checking rendered structure, event-driven changes, validation messages, and many keyboard or form interactions. It is fast to run in a DOM-capable test environment, but that environment does not reproduce every browser feature.
Use a real-browser component test when the question depends on browser behavior such as layout, focus behavior tied to rendering, native controls, or browser APIs that a DOM simulation does not faithfully implement. Playwright’s component testing guide documents mounting components in a browser context and interacting through locators.
| Question | Suitable starting point |
|---|---|
| Does clicking this control show the right message? | DOM component test |
| Does the form reject invalid data and show an error? | DOM component test, plus browser coverage if browser-specific behavior matters |
| Does native focus, layout, or a browser API behave correctly? | Real-browser component test |
| Does the whole flow work across routes and integrated services? | Consider an end-to-end browser test rather than treating a component test as the only coverage |
These environments answer related questions; choose the one that can faithfully exercise the behavior you need to verify. The official documentation does not establish one as universally superior.
9. Troubleshoot common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| “Unable to find an accessible element” for a button | The accessible name differs from the assumed text, or the control lacks a semantic role/name. | Inspect the rendered accessibility roles in the error output. Give the control an appropriate native element and accessible name, then query that role and name. |
| A label query cannot find an input | The visible label is not programmatically associated with the field. | Wrap the input in a label or connect a label’s htmlFor with the input’s id. |
| The assertion runs before the result appears | The update is asynchronous but the test uses a synchronous query. | Use findBy… for an element that appears, or waitFor for another retryable condition. Do not add a guessed sleep. |
| Click fails because the target is not interactable | The element may be hidden or disabled, or the test is targeting the wrong control. | Check the rendered state and query for the actual user-facing control. If a disabled state is expected, assert that state instead of clicking. |
| “Not wrapped in act” or an update warning | An update may happen after the test finishes or an interaction was not awaited. | Await user-event actions and wait for the resulting visible update. Ensure pending asynchronous work is resolved or handled in the test. |
| Test passes in jsdom but fails in a browser | The simulated DOM does not reproduce a browser-specific behavior or API. | Move that assertion to a browser component or end-to-end test for the behavior that needs a real browser. |
| Test breaks after refactoring despite unchanged behavior | It may depend on component internals, a class name, or a test ID used for an ordinary control. | Query by role/name or label and assert on the visible result. Keep test IDs for cases without practical user-facing semantics. |
| Tests are slow or flaky around async changes | Fixed delays, overly broad waits, or unawaited actions make timing unpredictable. | Await each interaction; wait for the specific expected element or condition; keep waits scoped and avoid arbitrary timeouts. |
10. Reliability, speed, and maintenance
- Reliability: Use accessible queries and await interactions. Wait for the actual outcome rather than elapsed time. Isolate network-dependent behavior with the project’s chosen test strategy so component interaction tests remain repeatable.
- Speed: DOM tests avoid launching a browser and are a practical fit for many component behaviors. Browser tests provide higher environment fidelity for browser-dependent behavior, with the additional setup and execution cost that comes with running a browser.
- Maintenance: Assertions about user-visible results usually tolerate internal refactors better than tests that inspect state, call private methods, or depend on incidental markup.
- Coverage: Use a small number of focused tests for meaningful interaction states. Neither a passing component test nor a coverage percentage alone proves a whole application flow works.
11. Or skip the browser setup
If what you need is a rendered screenshot of a page or component state, ScreenshotNeo can capture a URL with one GET request. The ScreenshotNeo website describes its website screenshot API and MCP server for developers. See the API documentation for request options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' })
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`)
ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before capture; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots. A screenshot is useful for visual review, but it does not replace an interaction assertion: use component tests to verify that a click, keyboard action, or form submission produces the right behavior.
Sign up free for 1,000 screenshots a month, with no card required.
12. FAQ
Should every button have a component interaction test?
Test controls where behavior is meaningful or could regress. A purely static decorative element does not need an interaction test; a button that submits, changes data, or reveals content usually does.
Can a screenshot prove an interaction works?
A screenshot shows a rendered state. It does not by itself prove that a user action caused that state or that keyboard and accessibility behavior work. Pair visual review with behavioral assertions for those questions.
Do tests need to reproduce every possible user action?
No. Cover the important behavior contract and meaningful edge states. Keep tests focused so they explain what the component promises to a user.
Are DOM tests and browser tests interchangeable?
No. They overlap, but differ in environment fidelity. Use the environment that can exercise the behavior under test.


