C.W.K.
Stream
Lesson 04 of 04 · published

비동기 UI 와 커스텀 Render 헬퍼

~18 min · vitest-components, async, waitFor, custom-render

Level 0테스트 호기심
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"비동기 UI 는 기다리는 쿼리가 필요. 비동기 테스트는 도착하는 provider 가 필요."

비동기 쿼리 — findBy, waitFor, waitForElementToBeRemoved

대부분 실제 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 하고 스피너 보이고, 그 다음 데이터, 그 다음 에러일 수도. 테스트가 시나리오처럼 읽혀:

  1. 컴포넌트 렌더.
  2. 스피너 존재 단언 (getByRole('progressbar') 같은 거).
  3. await screen.findByText(/loaded data/i) — 성공 state 나타나길 기다림.
  4. 선택적: 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();
  });
});
waitFor + waitForElementToBeRemoved — non-presence 비동기에·tsx
// waitFor — for assertions that aren't just 'element appeared'
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './counter';

it('eventually persists the count to localStorage', async () => {
  const setItem = vi.spyOn(Storage.prototype, 'setItem');
  const user = userEvent.setup();

  render(<Counter />);
  await user.click(screen.getByRole('button', { name: /increment/i }));

  // The persistence is debounced — wait until it actually fires.
  await waitFor(() => {
    expect(setItem).toHaveBeenCalledWith('count', '1');
  });
});

// waitForElementToBeRemoved — for spinners, toasts, modals closing
import { waitForElementToBeRemoved } from '@testing-library/react';

it('hides the loading toast after the request completes', async () => {
  render(<UploadForm />);
  // ... trigger upload
  await waitForElementToBeRemoved(() => screen.queryByText(/uploading/i));
  expect(screen.getByText(/upload complete/i)).toBeInTheDocument();
});
Provider 가진 커스텀 render — `src/test-utils.tsx`·tsx
// src/test-utils.tsx — your custom render that knows about providers
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement, ReactNode } from 'react';
import { ThemeProvider } from './theme';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

type CustomRenderOptions = Omit<RenderOptions, 'wrapper'> & {
  initialRoute?: string;
  theme?: 'light' | 'dark';
};

function AllProviders({
  children,
  initialRoute = '/',
  theme = 'light',
}: { children: ReactNode } & CustomRenderOptions) {
  // A fresh QueryClient per test prevents cache bleed between tests.
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } }, // tests shouldn't retry
  });

  return (
    <ThemeProvider mode={theme}>
      <QueryClientProvider client={queryClient}>
        <MemoryRouter initialEntries={[initialRoute]}>{children}</MemoryRouter>
      </QueryClientProvider>
    </ThemeProvider>
  );
}

export function renderWithProviders(
  ui: ReactElement,
  { initialRoute, theme, ...rtlOptions }: CustomRenderOptions = {}
) {
  return render(ui, {
    wrapper: ({ children }) => (
      <AllProviders initialRoute={initialRoute} theme={theme}>
        {children}
      </AllProviders>
    ),
    ...rtlOptions,
  });
}

// Re-export everything else so tests have one import path.
export * from '@testing-library/react';
export { renderWithProviders as render };
커스텀 render 사용 — 깔끔한 테스트, provider 보일러플레이트 없음·tsx
// Tests import from test-utils, get providers for free.
import { describe, it, expect } from 'vitest';
import { render, screen } from './test-utils';   // ← custom, not @testing-library/react
import { Dashboard } from './dashboard';

describe('<Dashboard />', () => {
  it('renders the welcome banner', async () => {
    render(<Dashboard />);
    // ThemeProvider, MemoryRouter, QueryClientProvider all already in scope.
    expect(await screen.findByText(/welcome back/i)).toBeInTheDocument();
  });

  it('shows the dark-mode style when the theme option is dark', () => {
    render(<Dashboard />, { theme: 'dark' });
    expect(screen.getByRole('main')).toHaveClass('theme-dark');
  });

  it('starts at /reports when initialRoute is set', async () => {
    render(<Dashboard />, { initialRoute: '/reports' });
    expect(
      await screen.findByRole('heading', { name: /monthly reports/i })
    ).toBeInTheDocument();
  });
});

External links

Exercise

<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.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.