대부분 실제 UI 는 비동기: 데이터 로드, optimistic 업데이트 정착, 토스트가 나타났다 사라짐. 동기 쿼리 (getBy, queryBy) 는 아직 안 나타난 element 못 봐. 세 도구가 처리:
findBy* — element 나타날 때까지 polling 하는 promise 반환 (default 1초 timeout, 50ms 간격). 특정 element 하나 기다릴 때.
waitFor(callback) — 콜백이 throw 안 할 때까지 (또는 timeout 만료) 반복 실행. 단순 존재가 아닌 단언에 (값 업데이트, mock 호출됨).
waitForElementToBeRemoved(element) — element 가 사라질 때까지 polling. 스피너, 토스트, 모달 닫힘에.
경험칙: waitFor(() => expect(getBy...).toBeInTheDocument()) 보다 findBy 선호. 같은 일 하는데 findBy 가 더 깔끔하게 읽히고 더 좋은 에러 메시지 생성.
흔한 패턴 — 로딩 → 로드 완료
페이지가 데이터 fetch 하고 스피너 보이고, 그 다음 데이터, 그 다음 에러일 수도. 테스트가 시나리오처럼 읽혀:
컴포넌트 렌더.
스피너 존재 단언 (getByRole('progressbar') 같은 거).
await screen.findByText(/loaded data/i) — 성공 state 나타나길 기다림.
선택적: expect(spinner).not.toBeInTheDocument() — 스피너 사라진 거 단언.
비동기 테스트 실패는 보통 stale 단언이지 느린 코드가 아냐.findBy timeout 후 "unable to find element" 로 실패하면, 기다리던 element 가 진짜로 안 나타나는 거 — mock 세팅 안 됐거나, 컴포넌트 로딩 로직 깨졌어. timeout 늘려서 "고치지" 마. 조사해.
커스텀 Render — provider 가 사는 곳
프로덕션 React 트리는 provider 로 wrap: theme, router, query client, auth context. Default render 는 이거 몰라 — 매 테스트가 매 render 수동 wrap 해야 해. 커스텀 render 헬퍼가 이걸 중앙화.
관례: src/test-utils.tsx 만들어서 @testing-library/react 의 모든 거 + 미리 wrap 하는 커스텀 render 를 re-export. 테스트는 @testing-library/react 대신 test-utils 에서 import.
Render 헬퍼 하나, 테스트별 옵션
커스텀 render 가 라우트, 초기 state, theme 모드 — 테스트가 다양화할 거 — 에 대한 선택적 config 받아. Default 값은 가장 흔한 케이스에 맞춰. theme="dark" 필요한 특정 테스트나 /dashboard 에서 시작하는 테스트가 나머지 suite 안 건드리고 그 옵션만 넘김.
@testing-library/react 에서 모든 거 re-export. 그럼 아무도 screen 을 @testing-library/react 에서 import 할지 test-utils 에서 import 할지 기억 안 해도 돼. 한 import 경로, 한 멘탈 모델.
Code
findBy — element 나타나길 기다림·tsx
// Async — findBy for elements that appear later
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UserProfile } from './user-profile';
describe('<UserProfile />', () => {
it('shows the spinner, then the name', async () => {
render(<UserProfile id={1} />);
// Immediately visible — synchronous query
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// Appears after fetch resolves — async query
const name = await screen.findByRole('heading', { name: /pippa choi/i });
expect(name).toBeInTheDocument();
// Optionally assert the spinner is gone
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});
<UserList /> 만들어 — React Query 통해 /api/users fetch 해서 리스트 렌더. MSW 가 사용자 셋 반환하도록 세팅. 테스트 짜: (1) 초기에 스피너 표시 (동기 쿼리), (2) fetch 해결 후 list item 세 개 (findAllByRole('listitem')), (3) MSW 가 500 반환할 때 에러 메시지. test-utils.tsx 에서 커스텀 render 만들어서 각 테스트가 fresh QueryClient 로 시작. 테스트가 개별로 AND 어떤 순서로든 통과 확인.
Hint
테스트가 혼자선 통과하는데 다른 거랑 같이 돌리면 실패하면, 어딘가 state 공유 중 — 보통 QueryClient 나 새는 MSW 핸들러 오버라이드. 커스텀 render + afterEach(() => server.resetHandlers()) 조합이 둘 다 방지.
Progress
Progress is local-only — sign in to sync across devices.