Testing Library로 사용자 관점 테스트: 쿼리 우선순위, User Events, 비동기 대기, 폼, Vue
이 글의 핵심
구현 세부가 아니라 사용자가 보는 것으로 테스트하는 원칙, getBy·queryBy·findBy 차이와 쿼리 우선순위, 실제 입력에 가까운 user-event, 비동기 UI 대기, Vue에서 쓰는 법을 다룹니다.
이 글의 핵심
Testing Library로 사용자 중심 테스트를 구현하는 글입니다. Queries, User Events, Async Utils, React/Vue 통합까지 실전 예제로 정리했습니다.
실무에서 마주치는 문제들
리팩토링 시 테스트가 깨져요
컴포넌트의 내부 state 이름이나 자식 컴포넌트 구조를 검사하는 테스트는, 화면이 똑같이 동작해도 useState를 useReducer로 바꾸거나 컴포넌트를 둘로 나누는 순간 깨집니다. 반대로 실제 버그가 생겨도 내부 값은 맞아서 통과하는 경우도 있습니다. Testing Library는 컴포넌트 인스턴스에 접근하는 API를 아예 제공하지 않고, 렌더링된 DOM만 보게 해서 이런 테스트를 쓰기 어렵게 만듭니다.
테스트가 실제 사용과 달라요
사용자는 state를 보지 않고 화면의 글자와 버튼을 봅니다. Testing Library의 원칙은 “테스트가 소프트웨어가 사용되는 방식과 닮을수록 더 큰 신뢰를 준다”이며, 그래서 쿼리도 사용자가 요소를 찾는 방식(역할, 레이블, 보이는 텍스트)을 기준으로 설계되어 있습니다.
접근성을 고려하지 못해요
#submit-btn이나 .primary 같은 선택자는 스타일을 바꾸면 깨지고, 스크린 리더 사용자가 그 버튼을 찾을 수 있는지와는 무관합니다. Role과 Label로 요소를 찾으면 테스트가 통과한다는 사실 자체가 “보조 기술로도 이 요소를 식별할 수 있다”는 최소한의 확인이 됩니다.
Testing Library란?
핵심 특징
Testing Library는 사용자 중심 테스팅 라이브러리입니다. 주요 장점:
- 사용자 중심: 구현이 아닌 동작 테스트
- 접근성: Role, Label 기반
- 견고함: 리팩토링에 안전
- 프레임워크 지원: React, Vue, Angular
- Jest/Vitest 통합: 테스트 러너와 독립적
Testing Library는 테스트 러너가 아니라 DOM 쿼리와 상호작용 도구입니다. 테스트를 실행하고 expect를 제공하는 것은 Jest나 Vitest이고, 브라우저 대신 DOM을 흉내 내는 것은 jsdom이나 happy-dom입니다. 이 구분을 알아 두면 에러 메시지를 읽기 쉬워집니다. document is not defined는 테스트 환경이 node로 설정된 것이고(testEnvironment: 'jsdom' 또는 Vitest의 environment: 'jsdom'이 필요), toBeInTheDocument is not a function은 jest-dom 매처를 등록하지 않은 것입니다.
jsdom은 레이아웃을 계산하지 않는다는 한계도 있습니다. 요소의 크기나 위치, CSS로 숨겼는지 여부, 스크롤 같은 것은 실제 브라우저처럼 동작하지 않으므로, 그런 동작에 의존하는 기능은 Playwright나 Cypress 같은 실제 브라우저 테스트가 맡아야 합니다.
설치 및 설정
React
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
Vue
npm install -D @testing-library/vue
@testing-library/react 16 버전부터는 @testing-library/dom이 직접 의존성이 아니라 peer 의존성으로 바뀌어 함께 설치해야 합니다. 빠뜨리면 테스트 실행 시 Cannot find module '@testing-library/dom' 에러가 납니다. npm install -D @testing-library/dom을 추가하십시오.
jest-dom 매처(toBeInTheDocument, toHaveValue 등)는 테스트 파일마다 import하기보다 설정 파일에서 한 번 등록합니다. Jest라면 setupFilesAfterEnv에 import '@testing-library/jest-dom'이 든 파일을 지정하고, Vitest라면 setupFiles에서 import '@testing-library/jest-dom/vitest'를 불러와야 타입 정의까지 Vitest의 expect에 붙습니다. Jest용 경로를 Vitest에서 쓰면 ReferenceError: expect is not defined나 타입 에러가 나는 경우가 많습니다. 또 Vitest는 기본적으로 전역 afterEach가 없어 테스트 사이에 렌더링된 DOM이 자동으로 정리되지 않으므로, globals: true를 켜거나 설정 파일에서 afterEach(cleanup)을 직접 등록해야 이전 테스트의 버튼이 다음 테스트에서 “여러 개 발견” 에러를 일으키지 않습니다.
기본 테스트 (React)
// src/components/Button.tsx
interface ButtonProps {
label: string;
onClick?: () => void;
}
export default function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}
// src/components/Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import Button from './Button';
test('renders button', () => {
render(<Button label="Click me" />);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
test('handles click', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button label="Click me" onClick={handleClick} />);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
두 테스트 모두 render의 반환값 대신 screen에서 요소를 찾습니다. screen은 document.body 전체에 묶인 쿼리 모음이라, 무엇을 구조 분해해야 할지 고민할 필요가 없고 에디터 자동 완성도 잘 됩니다. Testing Library 관리자들이 권하는 방식이기도 합니다.
getByRole('button', { name: 'Click me' })의 name은 접근 가능한 이름(accessible name)입니다. 버튼의 텍스트, aria-label, 연결된 <label> 등에서 계산되며, 스크린 리더가 읽어 주는 이름과 같습니다. 그래서 아이콘만 있는 버튼에 aria-label을 빠뜨리면 getByRole('button', { name: '닫기' })가 실패하는데, 이것은 테스트가 까다로운 것이 아니라 실제로 접근성 문제가 있다는 신호입니다.
userEvent.setup()을 테스트마다 호출하고 await user.click()처럼 기다리는 것은 user-event 14의 방식입니다. 13 이전에는 userEvent.click()을 직접 호출했고 동기 함수였습니다. 14에서 await를 빠뜨리면 클릭 처리가 끝나기 전에 expect가 실행되어 간헐적으로 실패하므로, 오래된 예제를 복사할 때 특히 주의해야 합니다. 이 예제의 jest.fn()은 Vitest에서는 vi.fn()으로 바꿔야 합니다.
Queries
우선순위
- getByRole: 접근성 기반 (권장)
- getByLabelText: Form 요소
- getByPlaceholderText: Placeholder
- getByText: 텍스트 내용
- getByDisplayValue: Input 값
- getByAltText: 이미지
- getByTitle: Title 속성
- getByTestId: data-testid (최후)
예제
// getByRole
screen.getByRole('button', { name: 'Submit' });
screen.getByRole('textbox', { name: 'Email' });
screen.getByRole('heading', { level: 1 });
// getByLabelText
screen.getByLabelText('Email');
// getByPlaceholderText
screen.getByPlaceholderText('Enter email');
// getByText
screen.getByText('Hello World');
screen.getByText(/hello/i);
// getByTestId
screen.getByTestId('custom-element');
우선순위는 “사용자가 요소를 어떻게 인식하는가”의 순서입니다. getByRole이 맨 위인 이유는 역할과 이름이 시각 사용자와 스크린 리더 사용자 모두에게 공통된 기준이기 때문입니다. placeholder는 입력하면 사라지고 레이블을 대체하지 못하므로 레이블보다 아래에 있고, data-testid는 사용자가 절대 볼 수 없는 값이라 맨 마지막입니다. data-testid는 차트 캔버스나 동적으로 생성된 목록의 특정 칸처럼 역할과 텍스트로 구분할 방법이 정말 없을 때만 씁니다.
getByRole에서 자주 막히는 부분은 암시적 역할입니다. <input type="text">는 textbox, <input type="checkbox">는 checkbox, <a href>는 link, <ul>의 <li>는 listitem, <h1>~<h6>은 heading입니다. 반면 href 없는 <a>나 <div onClick>에는 역할이 없어서 getByRole로 찾을 수 없습니다. 어떤 역할로 찾아야 할지 모르겠다면 getByRole이 실패할 때의 에러 메시지를 읽어 보십시오. “Here are the accessible roles:” 아래에 현재 DOM에서 찾을 수 있는 역할과 각 요소의 이름 목록이 출력됩니다. screen.logTestingPlaygroundURL()을 호출하면 추천 쿼리를 보여 주는 Testing Playground 링크도 얻을 수 있습니다.
getByRole은 모든 요소의 접근 가능한 이름을 계산하므로 DOM이 큰 테스트에서는 다른 쿼리보다 눈에 띄게 느릴 수 있습니다. 수천 개의 행이 있는 테이블을 테스트한다면 within(row).getByRole(...)으로 검색 범위를 좁히는 것이 도움이 됩니다.
Query Variants
// getBy: 즉시 찾음 (없으면 에러)
screen.getByRole('button');
// queryBy: 즉시 찾음 (없으면 null)
screen.queryByRole('button');
// findBy: 비동기 대기 (없으면 에러)
await screen.findByRole('button');
// getAllBy: 여러 개
screen.getAllByRole('listitem');
세 변형은 용도가 분명히 나뉩니다. getBy는 요소가 지금 있어야 할 때 씁니다. 없거나 두 개 이상이면 즉시 에러를 던지고, 에러 메시지에 현재 DOM 전체가 출력되어 무엇이 렌더링되었는지 바로 볼 수 있습니다. queryBy는 요소가 없음을 확인할 때만 씁니다. expect(screen.queryByText('에러')).not.toBeInTheDocument()처럼요. getBy로 없음을 확인하려 하면 expect에 도달하기 전에 에러가 나 버립니다. 반대로 요소가 있어야 하는 곳에 queryBy를 쓰면 실패 시 null만 돌아와 원인을 알려 주는 DOM 출력을 잃습니다. findBy는 getBy + waitFor로, 비동기로 나타날 요소를 기다립니다(기본 1초).
getAllBy는 하나 이상일 때 배열을 돌려주고, 하나도 없으면 에러를 던집니다. 목록 개수를 검사할 때는 expect(screen.getAllByRole('listitem')).toHaveLength(3)처럼 쓰고, 0개일 수 있다면 queryAllBy가 빈 배열을 돌려줍니다.
User Events
import userEvent from '@testing-library/user-event';
test('user interactions', async () => {
const user = userEvent.setup();
render(<LoginForm />);
// 타이핑
await user.type(screen.getByLabelText('Email'), '[email protected]');
// 클릭
await user.click(screen.getByRole('button', { name: 'Submit' }));
// 선택
await user.selectOptions(screen.getByLabelText('Country'), 'US');
// 체크박스
await user.click(screen.getByRole('checkbox', { name: 'Agree' }));
// 파일 업로드
const file = new File(['hello'], 'hello.png', { type: 'image/png' });
const input = screen.getByLabelText('Upload');
await user.upload(input, file);
});
user-event가 fireEvent보다 권장되는 이유는 실제 사용자 입력의 전체 이벤트 순서를 재현하기 때문입니다. fireEvent.click은 click 이벤트 하나만 발생시키지만, user.click은 pointerdown, mousedown, focus, pointerup, mouseup, click을 순서대로 발생시키고 비활성화된 버튼이나 pointer-events: none인 요소는 클릭하지 않습니다. user.type도 글자마다 keydown/keypress/input/keyup을 발생시키므로, 입력마다 검증하거나 자동 완성을 띄우는 컴포넌트가 실제와 같게 동작합니다. fireEvent.change로 값을 한 번에 바꾸면 이런 중간 동작이 모두 건너뛰어져, 실제로는 버그가 있는데 테스트는 통과하는 경우가 생깁니다.
user.type은 기존 값 뒤에 이어서 입력합니다. 이미 값이 있는 입력란을 바꾸려면 먼저 await user.clear(input)을 호출하십시오. selectOptions의 'US'는 옵션의 value나 보이는 텍스트 중 하나와 일치하면 선택됩니다. upload는 <input type="file">에 accept 속성이 있으면 기본적으로 맞지 않는 파일을 걸러 내므로, 거부 동작을 테스트할 때는 이 점을 감안해야 합니다.
비동기 테스트
waitFor
import { render, screen, waitFor } from '@testing-library/react';
test('loads users', async () => {
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
});
});
findBy
test('loads users', async () => {
render(<UserList />);
expect(await screen.findByText('John')).toBeInTheDocument();
});
두 방식은 결과가 같지만, 요소 하나가 나타나기를 기다리는 경우라면 findBy가 더 짧고 의도가 분명합니다. waitFor는 콜백을 기본 50ms 간격으로 반복 실행하며 에러를 던지지 않을 때까지(기본 1초) 기다립니다. 여기서 흔히 하는 실수가 몇 가지 있습니다.
- 콜백 안에 부수 효과를 넣는 것:
waitFor(() => { user.click(button); expect(...) })처럼 쓰면 조건을 확인할 때마다 클릭이 반복됩니다. 콜백에는 확인(assertion)만 두고, 상호작용은waitFor밖에서 합니다. - 콜백 안에 여러 assertion을 넣는 것: 첫 번째가 통과하고 두 번째가 실패하면 전체를 계속 재시도하므로 실패 원인이 흐려지고 타임아웃까지 기다리게 됩니다. 기다리는 조건 하나만 넣고, 나머지는
waitFor다음에 검사합니다. - 빈 콜백으로 기다리는 것:
await waitFor(() => {})는 “한 틱 기다리기”처럼 쓰이지만 무엇을 기다리는지 알 수 없습니다.
비동기 테스트를 쓰다 보면 Warning: An update to UserList inside a test was not wrapped in act(...) 경고를 만나게 됩니다. 테스트가 끝난 뒤에도 컴포넌트가 상태를 갱신했다는 뜻으로, 대개 마지막 비동기 업데이트(로딩 끝, 두 번째 fetch 완료 등)를 기다리지 않고 테스트가 끝났기 때문입니다. act로 억지로 감싸기보다, 그 업데이트의 결과로 화면에 나타나는 것을 findBy로 기다리는 것이 올바른 해결입니다. 제가 이 경고를 볼 때 가장 먼저 확인하는 것도 “로딩 표시가 사라지는 것까지 기다렸는가”입니다. 또 네트워크 요청은 실제로 보내지 말고 MSW(Mock Service Worker) 같은 도구로 가로채야 테스트가 외부 서버 상태에 따라 흔들리지 않습니다.
Form 테스트
// src/components/LoginForm.tsx
export default function LoginForm({ onSubmit }: { onSubmit: (data: any) => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input id="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<label htmlFor="password">Password</label>
<input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Submit</button>
</form>
);
}
// src/components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('submits form', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('Email'), '[email protected]');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Submit' }));
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'password123',
});
});
LoginForm.tsx 예제는 지면을 줄이느라 import { useState } from 'react';를 생략했으므로, 그대로 복사하면 useState is not defined 에러가 납니다.
이 테스트가 잘 짜인 이유는 사용자가 하는 일만 하기 때문입니다. 레이블로 입력란을 찾아 타이핑하고, 이름으로 버튼을 찾아 누른 뒤, 컴포넌트 밖으로 나가는 결과(onSubmit 호출 인자)만 확인합니다. email state 값을 직접 읽지 않으므로 내부를 useReducer나 폼 라이브러리로 바꿔도 테스트는 그대로 통과합니다. getByLabelText('Email')이 동작하는 것은 <label htmlFor="email">과 <input id="email">이 연결되어 있기 때문이며, htmlFor를 빠뜨리면 화면에는 레이블이 보이는데 테스트는 Found a label with the text of: Email, however no form control was found associated to that label 에러로 실패합니다. 이 에러 역시 실제 접근성 결함(레이블을 클릭해도 입력란에 포커스가 가지 않음)을 알려 줍니다.
버튼을 클릭하는 대신 fireEvent.submit(form)으로 폼을 직접 제출하면 버튼이 비활성화되었거나 type="button"으로 잘못 지정된 버그를 놓칩니다. 사용자가 엔터 키로 제출하는 경로도 중요하다면 await user.type(passwordInput, 'password123{enter}')로 따로 테스트할 수 있습니다.
Vue 통합
// src/components/Button.vue
<script setup lang="ts">
defineProps<{
label: string;
}>();
const emit = defineEmits<{
click: [];
}>();
</script>
<template>
<button @click="emit('click')">{{ label }}</button>
</template>
// src/components/Button.test.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import Button from './Button.vue';
test('renders button', () => {
render(Button, { props: { label: 'Click me' } });
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
test('handles click', async () => {
const user = userEvent.setup();
const { emitted } = render(Button, { props: { label: 'Click me' } });
await user.click(screen.getByRole('button'));
expect(emitted().click).toHaveLength(1);
});
Vue용 Testing Library는 내부적으로 @vue/test-utils의 mount를 감싸고 있어, render의 두 번째 인자로 props, slots, global(플러그인, 스텁 등) 같은 마운트 옵션을 그대로 넘길 수 있습니다. 쿼리와 user-event는 React와 완전히 같으므로, 두 프레임워크를 함께 쓰는 팀에서도 테스트 작성 방식이 통일됩니다.
emitted()는 컴포넌트가 발생시킨 이벤트를 이름별 배열로 돌려줍니다. React 예제에서 onClick 목 함수를 넘겨 호출 여부를 확인한 것과 같은 역할이며, 이벤트 인자는 emitted().click[0]처럼 꺼내 확인할 수 있습니다. Pinia나 Vue Router를 쓰는 컴포넌트라면 global: { plugins: [createTestingPinia(), router] }처럼 테스트용 인스턴스를 주입해야 injection "Symbol(pinia)" not found 같은 에러가 나지 않습니다. .vue 파일을 테스트하려면 Vitest라면 @vitejs/plugin-vue, Jest라면 @vue/vue3-jest 변환기가 설정되어 있어야 합니다.
같이 보면 좋은 글
- Jest 실전 테스트 가이드 | 플레이키 테스트·Mock·Snapshot·Coverage 트러블슈팅
- Cypress E2E 테스트 운영: 안정적인 셀렉터, 로그인 세션 재사용, 아키텍처와 커버리지
- React 기초 가이드
- Vitest 가이드
자주 묻는 질문 (FAQ)
Q. Enzyme과 비교하면 어떤가요?
A. Enzyme은 컴포넌트 인스턴스와 state에 직접 접근하는 방식이라 구현 세부에 묶인 테스트가 되기 쉬웠고, 공식 React 18 어댑터가 나오지 않아 최신 React에서는 사실상 쓸 수 없습니다. 새 프로젝트라면 Testing Library가 표준적인 선택입니다.
Q. E2E 테스트도 가능한가요?
A. Testing Library 자체는 jsdom 위의 컴포넌트·통합 테스트용입니다. 다만 같은 쿼리 철학을 Cypress(@testing-library/cypress)에서 쓸 수 있고, Playwright의 getByRole·getByLabel 로케이터도 같은 원칙을 따르므로, 실제 브라우저가 필요한 E2E는 그 도구들을 쓰면서 쿼리 습관은 그대로 가져갈 수 있습니다.
Q. 학습 곡선은 어떤가요?
A. 낮습니다. 사용자 관점으로 생각하면 됩니다.