This is Part 8 of the “Frontend Testing, Done Right” series. Browse the full series · Glossary

A button isn’t really tested until you press it; an input, until you type into it. user-event is the member of the Testing Library family in charge of reproducing interaction — even a single click walks through focus changes and key-event order just like a real browser, closing the gap where “it works when a user does it, but not in the test.” In this article we build typing and clicking tests from scratch.

If you’re following the series, the search component from last time is today’s stage — and we’ll even upgrade it to a version that loads real data. Landed here from a search? No problem. The code flow reads within this article; for Testing Library basics see the philosophy part, and for environment setup the setup part.

Practice code: this installment’s exact state lives in the step-08 tag. git checkout step-08 to follow along, diff it against step-07 to see just what this part added, or open it in StackBlitz with one click.

Our hands are busy today. Three things to take away.

  • Reproducing typing and clicking with user-event
  • Verifying one flow all the way: input → state → screen
  • Why user-event instead of fireEvent
A diagram of the interaction-test flow from a user typing a query and pressing a button to the result appearing, laid out as four steps — setup, interact, screen, assert — with a lower panel contrasting fireEvent, which injects a single event, against user.type, which acts out focus and every keystroke like a real user
A diagram of the interaction-test flow from a user typing a query and pressing a button to the result appearing, laid out as four steps — setup, interact, screen, assert — with a lower panel contrasting fireEvent, which injects a single event, against user.type, which acts out focus and every keystroke like a real user

First, give the component real data

Last time’s UserSearch searched a fixed array. Let’s raise it to something app-like that loads the list from a server. We have fetchUsers and the MSW mock from the async part, so there’s no server to worry about. (MSW is a tool that intercepts network requests during tests and returns fake responses — the setup was done back in the async part, and we’ll cover the mechanics properly next time. For now, “a fake server is on standby” is all you need to know.)

The key changes come in two chunks. First the data — from a server instead of a fixed array:

tsx
// src/components/UserSearch.tsx — upgrade point (1): data
const [users, setUsers] = useState<User[]>([])
const [status, setStatus] = useState<'loading' | 'error' | 'ready'>('loading')

useEffect(() => {
  fetchUsers()
    .then((data) => { setUsers(data); setStatus('ready') })
    .catch(() => setStatus('error'))
}, [])

And the markup — per-status display, plus a new clear button we’ll test in a moment:

tsx
// upgrade point (2): markup (below the input)
const clearQuery = () => {
  setQuery('')
  inputRef.current?.focus()  // the button vanishes, so send focus back to the input (explained soon)
}

{query && (
  <button type="button" aria-label="검색어 지우기" onClick={clearQuery}>
    
  </button>
)}

{status === 'loading' && <p>불러오는 중…</p>}
{status === 'error' && <p role="alert">사용자를 불러오지 못했습니다.</p>}
{status === 'ready' && /* the existing list rendering, unchanged */}

(inputRef is a reference made with useRef and wired to the input via ref={inputRef}. The UI strings stay Korean — the demo app’s language — so 검색어 지우기 is the “Clear search” label the button carries.)

The button’s body is a single ✕ character, but thanks to aria-label it gets a name: “검색어 지우기” (Clear search) — a screen reader announces this name, and in a moment the test finds the button by this same name. It’s exactly the “role and name” idea from last time. (Full code is in the repo.)

And the instant you save — last time’s test goes red.

bash
 × UserSearch › shows the heading, search box, and full list
   Unable to find an accessible element with the role "listitem"

Of course. The list is now drawn only after the server response arrives, but the test looked for it right after render. Of the trio from the philosophy part, findBy (waits until it appears) is exactly the answer here.

tsx
// right after render → wait for loading to finish, then begin
await screen.findByText('Alice Kim')

The moment the UI turned async, the red light itself taught us how the test has to follow along.

A diagram of how upgrading the component to async turns the existing test red - the top timeline shows the previous version having the list immediately at render, while the server version is still loading right after render so the list is absent and getAllByRole fails on the spot. The bottom shows the fix, where await findByText waits until the server response draws the list and then passes
A diagram of how upgrading the component to async turns the existing test red - the top timeline shows the previous version having the list immediately at render, while the server version is still loading right after render so the list is absent and getAllByRole fails on the spot. The bottom shows the fix, where await findByText waits until the server response draws the list and then passes

The whole search flow

Now today’s main event: interaction. We verify the entire flow where typing filters the list. (src/components/UserSearch.test.tsx)

tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserSearch } from './UserSearch'

it('filters the list by query', async () => {
  const user = userEvent.setup()
  render(<UserSearch />)
  await screen.findByText('Alice Kim')      // wait for async loading to finish

  await user.type(screen.getByLabelText('검색'), 'bob')

  expect(screen.getByText('Bob Lee')).toBeInTheDocument()
  expect(screen.queryByText('Alice Kim')).not.toBeInTheDocument()
})

Let’s point out the new faces.

  • userEvent.setup() — creates an interaction session. Once at the top of each test, then use it as user.something().
  • user.type(element, 'bob') — for each character it fires keydown → keypress → input just like the real thing. It doesn’t quietly swap the value; it acts out the typing. Not a rough imitation, but method acting — pressing each key, fully immersed in the role.
  • Wait for loading with findByText before starting, and check “it’s gone” with queryBy — every tool from earlier parts works right where it belongs.

“Wait, you just said to wait — so why grab getByText('Bob Lee') directly here?” — great question. Loading the list is async (a round trip to the server), but filtering the loaded list is synchronous logic that finishes right there, no server involved. The sense for telling where a wait is needed (findBy) from where it isn’t (getBy) is this chapter’s fine motor skill.


Down to the clear button

Clicking the clear button after input to reset — again, the user flow as-is.

tsx
it('resets the search with the clear button', async () => {
  const user = userEvent.setup()
  render(<UserSearch />)
  await screen.findByText('Alice Kim')
  await user.type(screen.getByLabelText('검색'), 'bob')

  await user.click(screen.getByRole('button', { name: '검색어 지우기' }))

  expect(screen.getByLabelText('검색')).toHaveValue('')
  expect(screen.getByText('Alice Kim')).toBeInTheDocument()
  // the button is gone, so focus should return to the input
  expect(screen.getByLabelText('검색')).toHaveFocus()
})

The last line is the highlight of this test. When you clear the query, the button itself disappears (its condition is query && ...), and at that instant the keyboard user’s focus loses its home. So the repo’s implementation sends focus back to the input right after clearing, and the test nails it down with toHaveFocus(). A problem the mouse can never feel, guarded by the test.

An anatomy diagram of the clear button's accessibility - on the left is what the eye sees, a small x button beside the input; in the middle is the role-and-name world where an aria-label gives it the name button Clear search; on the right is the moment after a click where the button disappears and focus returns to the input, verified with toHaveFocus
An anatomy diagram of the clear button's accessibility - on the left is what the eye sees, a small x button beside the input; in the middle is the role-and-name world where an aria-label gives it the name button Clear search; on the right is the moment after a click where the button disappears and focus returns to the input, verified with toHaveFocus

By the way, why didn’t we test the error state (role="alert") shown in the markup above? You’d have to make the server fail on purpose, and that’s the specialty of the next part, which bends the network to its will. Not homework left behind — we handle it the moment the tool is ready.


Why not fireEvent

fireEvent is Testing Library’s low-level event-dispatching tool (the generation before user-event). fireEvent.change ‘injects’ a single event, whereas user-event reproduces focus changes and key-input order like a real browser. That’s why keydown handlers and keyboard-accessibility problems surface alongside. And since it’s all async, always await.

Skip the await on user.click and the test fails now and then, like a ghost. user-event is all async. Always await!


One-page summary

  • user-event = a tool that reproduces interaction like a real user (fireEvent, which injects a single event, is the older generation)
  • Start each test with const user = userEvent.setup(), and always await every action
  • When the UI goes async, the test starts by waiting for loading with findBy
  • Type with user.type, click with user.click — assert on visible results (toHaveValue, toBeInTheDocument)
  • The red light your existing test throws when you make a component async is a sign the test is doing its job

Before moving on

We’ve reproduced interaction, all the way down. But what about when an API call is in the mix? The next part is MSW, which isolates the network.

Level up: you can write tests that reproduce real interactions like typing and clicking.

Next up: mocking the network with MSW — isolating the API

Bumped into an unfamiliar term? The glossary has them all, one line each.