버튼은 눌러봐야, 입력창은 타이핑해봐야 진짜 테스트예요. user-event는 Testing Library 가족 중 상호작용 재현을 맡는 도구입니다 — 클릭 한 번에도 포커스 이동과 키 이벤트 순서까지 실제 브라우저처럼 밟아서, “사용자가 하면 되는데 테스트에선 안 되는” 간극을 없애주죠. 이 글에서 타이핑·클릭 테스트를 처음부터 완성합니다.
시리즈로 따라오셨다면 지난 편에서 만든 검색 컴포넌트가 오늘의 무대예요 — 심지어 오늘 이 컴포넌트를 진짜 데이터를 불러오는 버전으로 업그레이드까지 합니다. 검색으로 처음 오셨어도 괜찮습니다. 코드 흐름은 이 글 안에서 읽히고, Testing Library 기초가 필요하면 철학 편을, 환경 세팅은 세팅 편을 참고하세요.
실습 코드: 이 편의 상태가
step-08태그에 그대로 담겨 있습니다.git checkout step-08으로 따라오시거나, step-07과 비교로 이 편이 더한 것만 볼 수도 있고, StackBlitz에서 바로 열기도 됩니다.
오늘은 손이 바쁩니다. 챙겨갈 것 세 가지.
- user-event로 타이핑·클릭 재현
- 입력→상태→화면 반영까지 한 흐름 검증
- 왜 fireEvent가 아니라 user-event인가

먼저, 컴포넌트를 진짜 데이터로#
지난 편의 UserSearch는 고정 배열을 검색했죠. 실제 앱답게 서버에서 목록을 불러오는 버전으로 올려봅니다. 비동기 편에서 만든 fetchUsers와 MSW 목이 있으니 서버 걱정은 없어요. (MSW는 테스트 중의 네트워크 요청을 가로채 가짜 응답을 주는 도구입니다 — 세팅은 비동기 편에서 이미 마쳤고, 원리는 다음 편에서 본격적으로 다룹니다. 지금은 “가짜 서버가 대기 중"이라고만 알면 충분해요.)
핵심 변경은 두 덩어리입니다. 먼저 데이터 — 고정 배열 대신 서버에서:
// 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'))
}, [])그리고 화면 — 상태별 표시와, 이따 테스트할 지우기 버튼이 새로 생깁니다:
// 업그레이드 요점 ② 마크업 (입력창 아래)
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에 있어요.)
그리고 저장하는 순간 — 지난 편의 테스트가 빨간불이 됩니다.
× UserSearch › 제목·검색창·전체 목록을 보여준다
Unable to find an accessible element with the role "listitem"당연해요. 이제 목록은 서버 응답이 도착한 뒤에야 그려지는데, 테스트는 렌더 직후에 찾았으니까요. 철학 편에서 배운 3형제 중 findBy(나타날 때까지 기다림)가 정확히 이 상황의 답입니다.
// 렌더 직후 → 로딩이 끝나기를 기다렸다가 시작
await screen.findByText('Alice Kim')비동기 UI로 바뀌는 순간 테스트가 어떻게 따라가야 하는지, 빨간불이 직접 가르쳐준 셈이에요.

검색 흐름을 통째로#
이제 오늘의 본론, 상호작용입니다. 타이핑하면 목록이 걸러지는 흐름 전체를 검증해요. (src/components/UserSearch.test.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)을 구분하는 감각, 이게 이번 챕터의 잔근육입니다.
지우기 버튼까지#
입력 후 지우기 버튼을 눌러 초기화되는 것도 사용자 흐름 그대로.
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()로 그걸 못박습니다. 마우스로는 절대 못 느끼는 문제를 테스트가 지켜주는 거예요.

참, 위 마크업에 있던 에러 상태(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를 격리하기
낯선 용어가 있었다면 — 용어집에 전부 한 줄씩 정리돼 있어요.
