# 폼·상호작용·상태 테스트 — user-event로

> 타이핑, 클릭, 선택… 실제 사용자의 상호작용을 재현하는 user-event로 폼과 상태 변화를 테스트하는 법을 다룹니다.

**Published:** 2026-07-13 | **Updated:** 2026-07-13

---



> 「프론트엔드 테스트 제대로 하기」 시리즈의 **8편**입니다. [전체 목차 보기](/series/프론트엔드-테스트-제대로-하기/) · [용어집](/posts/frontend-testing-glossary/)

버튼은 눌러봐야, 입력창은 타이핑해봐야 진짜 테스트예요. **user-event**는 Testing Library 가족 중 상호작용 재현을 맡는 도구입니다 — 클릭 한 번에도 포커스 이동과 키 이벤트 순서까지 실제 브라우저처럼 밟아서, "사용자가 하면 되는데 테스트에선 안 되는" 간극을 없애주죠. 이 글에서 타이핑·클릭 테스트를 처음부터 완성합니다.

시리즈로 따라오셨다면 [지난 편](/posts/frontend-testing-testing-library/)에서 만든 검색 컴포넌트가 오늘의 무대예요 — 심지어 오늘 이 컴포넌트를 진짜 데이터를 불러오는 버전으로 업그레이드까지 합니다. 검색으로 처음 오셨어도 괜찮습니다. 코드 흐름은 이 글 안에서 읽히고, Testing Library 기초가 필요하면 [철학 편](/posts/frontend-testing-testing-library/)을, 환경 세팅은 [세팅 편](/posts/frontend-testing-setup-vitest/)을 참고하세요.

> 실습 코드: 이 편의 상태가 [`step-08` 태그](https://github.com/IsaacEryn/frontend-testing-lab/tree/step-08)에 그대로 담겨 있습니다. `git checkout step-08`으로 따라오시거나, [step-07과 비교](https://github.com/IsaacEryn/frontend-testing-lab/compare/step-07...step-08)로 이 편이 더한 것만 볼 수도 있고, [StackBlitz에서 바로 열기](https://stackblitz.com/github/IsaacEryn/frontend-testing-lab/tree/step-08)도 됩니다.

오늘은 손이 바쁩니다. 챙겨갈 것 세 가지.

- user-event로 타이핑·클릭 재현
- 입력→상태→화면 반영까지 한 흐름 검증
- 왜 fireEvent가 아니라 user-event인가

{{< img src="images/contents/user-event-flow.png" alt="사용자가 검색어를 입력하고 버튼을 눌러 결과가 나타나기까지의 상호작용 테스트 흐름 다이어그램" >}}

---

## 먼저, 컴포넌트를 진짜 데이터로

지난 편의 `UserSearch`는 고정 배열을 검색했죠. 실제 앱답게 **서버에서 목록을 불러오는** 버전으로 올려봅니다. [비동기 편](/posts/frontend-testing-async/)에서 만든 `fetchUsers`와 MSW 목이 있으니 서버 걱정은 없어요. (MSW는 테스트 중의 네트워크 요청을 가로채 가짜 응답을 주는 도구입니다 — 세팅은 비동기 편에서 이미 마쳤고, 원리는 다음 편에서 본격적으로 다룹니다. 지금은 "가짜 서버가 대기 중"이라고만 알면 충분해요.)

핵심 변경은 두 덩어리입니다. 먼저 데이터 — 고정 배열 대신 서버에서:

```tsx
// src/components/UserSearch.tsx — 업그레이드 요점 ① 데이터
const [users, setUsers] = useState<User[]>([])
const [status, setStatus] = useState<'loading' | 'error' | 'ready'>('loading')

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

그리고 화면 — 상태별 표시와, 이따 테스트할 **지우기 버튼**이 새로 생깁니다:

```tsx
// 업그레이드 요점 ② 마크업 (입력창 아래)
const clearQuery = () => {
  setQuery('')
  inputRef.current?.focus()  // 버튼이 사라지므로 포커스를 입력창으로 (이따 설명해요)
}

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

{status === 'loading' && <p>불러오는 중…</p>}
{status === 'error' && <p role="alert">사용자를 불러오지 못했습니다.</p>}
{status === 'ready' && /* 기존 목록 렌더링 그대로 */}
```

(`inputRef`는 `useRef`로 만들어 입력창에 `ref={inputRef}`로 연결해둔 참조입니다.)

버튼 속은 ✕ 한 글자지만, `aria-label` 덕분에 **"검색어 지우기"라는 이름**을 가집니다 — 스크린 리더가 이 이름을 읽어주고, 잠시 뒤 테스트도 이 이름으로 버튼을 찾아요. 지난 편에서 본 "role과 name" 그대로입니다. (전체 코드는 repo에 있어요.)

그리고 저장하는 순간 — **지난 편의 테스트가 빨간불이 됩니다.**

```bash
 × UserSearch › 제목·검색창·전체 목록을 보여준다
   Unable to find an accessible element with the role "listitem"
```

당연해요. 이제 목록은 **서버 응답이 도착한 뒤에야** 그려지는데, 테스트는 렌더 직후에 찾았으니까요. 철학 편에서 배운 3형제 중 **findBy**(나타날 때까지 기다림)가 정확히 이 상황의 답입니다.

```tsx
// 렌더 직후 → 로딩이 끝나기를 기다렸다가 시작
await screen.findByText('Alice Kim')
```

비동기 UI로 바뀌는 순간 테스트가 어떻게 따라가야 하는지, 빨간불이 직접 가르쳐준 셈이에요.

{{< img src="images/contents/async-red-findby.png" alt="컴포넌트를 비동기로 업그레이드하자 기존 테스트가 빨간불이 되는 과정 다이어그램 - 위쪽 타임라인에서 이전 버전은 렌더 즉시 목록이 있지만, 서버 버전은 렌더 직후 로딩 중이라 목록이 없어 getAllByRole이 그 자리에서 실패합니다. 아래쪽은 해법으로, await findByText가 서버 응답으로 목록이 그려질 때까지 기다렸다가 통과하는 흐름이 그려져 있습니다" >}}

---

## 검색 흐름을 통째로

이제 오늘의 본론, 상호작용입니다. 타이핑하면 목록이 걸러지는 흐름 전체를 검증해요. (`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('검색어로 목록을 필터링한다', async () => {
  const user = userEvent.setup()
  render(<UserSearch />)
  await screen.findByText('Alice Kim')      // 비동기 로딩 완료 대기

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

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

처음 보는 것들을 짚어볼게요.

- `userEvent.setup()` — 상호작용 세션을 만듭니다. 테스트마다 첫 줄에 한 번, 그 뒤로는 `user.무엇()`으로 사용해요.
- `user.type(요소, 'bob')` — 글자 하나하나에 대해 keydown→keypress→input을 실제처럼 발생시킵니다. 값만 슬쩍 바꾸는 게 아니라 **타이핑을 연기**하는 거예요. 대충 흉내만 내는 게 아니라 키 하나하나 눌러가며 배역에 몰입하는, 이를테면 메소드 연기랄까요.
- 시작 전 `findByText`로 로딩 완료를 기다리고, "사라졌다" 검증은 `queryBy` — 앞 편들의 도구가 전부 제자리에서 일합니다.

"어라, 아까는 기다리라더니 여기선 왜 `getByText('Bob Lee')`로 바로 찾지?" — 좋은 질문이에요. 목록 **불러오기**는 서버를 다녀오는 비동기지만, 불러온 목록을 **거르는 것**은 서버 없이 그 자리에서 끝나는 동기 로직이거든요. 기다림이 필요한 곳(findBy)과 아닌 곳(getBy)을 구분하는 감각, 이게 이번 챕터의 잔근육입니다.

---

## 지우기 버튼까지

입력 후 지우기 버튼을 눌러 초기화되는 것도 사용자 흐름 그대로.

```tsx
it('지우기 버튼으로 검색을 초기화한다', 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()
  // 버튼이 사라졌으니 포커스는 입력창으로 돌아와야 한다
  expect(screen.getByLabelText('검색')).toHaveFocus()
})
```

마지막 줄이 이 테스트의 백미입니다. 검색어를 지우면 버튼 자신도 사라지는데(`query && ...` 조건이니까), 그 순간 **키보드 사용자의 포커스는 갈 곳을 잃어요.** 그래서 repo의 구현은 지우기 직후 포커스를 입력창으로 돌려보내고, 테스트는 `toHaveFocus()`로 그걸 못박습니다. 마우스로는 절대 못 느끼는 문제를 테스트가 지켜주는 거예요.

{{< img src="images/contents/clear-button-a11y.png" alt="지우기 버튼의 접근성 해부 다이어그램 - 왼쪽은 눈에 보이는 모습으로 입력창 옆의 작은 x 버튼이고, 가운데는 role과 name의 세계에서 aria-label 덕분에 button 검색어 지우기라는 이름을 가진 모습이며, 오른쪽은 클릭 후 버튼이 사라지면서 포커스가 입력창으로 돌아오고 toHaveFocus로 검증하는 장면입니다" >}}

참, 위 마크업에 있던 에러 상태(`role="alert"`)는 왜 테스트하지 않았을까요? 서버를 **일부러 실패**시켜야 하는데, 그건 네트워크를 마음대로 주무르는 다음 편의 특기라서요. 숙제로 남겨두는 게 아니라, 도구가 갖춰지면 바로 다룹니다.

---

## 왜 fireEvent가 아니라

`fireEvent`는 Testing Library가 제공하는 **저수준 이벤트 발생 도구**(user-event 이전 세대)예요. `fireEvent.change`는 이벤트 하나를 '주입'하지만, `user-event`는 포커스 이동·키 입력 순서까지 **실제 브라우저처럼** 재현합니다. 그래서 keydown 핸들러나 키보드 접근성 문제도 함께 드러나요. 그리고 전부 비동기이므로 **반드시 `await`**.

> `await`를 빼먹고 `user.click`을 쓰면 테스트가 유령처럼 가끔 실패합니다. user-event는 전부 비동기예요. 반드시 `await`!

---

## 한 장 요약

- user-event = 상호작용을 **실제 사용자처럼** 재현하는 도구 (fireEvent는 이벤트 하나를 주입하는 구세대)
- 매 테스트 `const user = userEvent.setup()`으로 시작, 모든 동작은 **반드시 `await`**
- 비동기 UI가 되면 테스트는 `findBy`로 로딩 완료를 기다렸다가 시작
- 타이핑은 `user.type`, 클릭은 `user.click` — 검증은 보이는 결과(`toHaveValue`, `toBeInTheDocument`)로
- 컴포넌트를 비동기로 바꿨을 때 기존 테스트가 내는 빨간불은, 테스트가 제대로 일하고 있다는 신호

---

## 다음으로 가기 전에

상호작용까지 재현했습니다. 그런데 API 호출이 끼면? 다음 편은 네트워크를 격리하는 MSW입니다.

> **레벨업**: 타이핑·클릭 같은 실제 상호작용을 재현하는 테스트를 쓸 수 있습니다.

> **다음 편**: MSW로 네트워크 모킹 — API를 격리하기

> 낯선 용어가 있었다면 — [용어집](/posts/frontend-testing-glossary/)에 전부 한 줄씩 정리돼 있어요.

