TanStack Query 컴포넌트 테스트: QueryClient 격리와 실패·재시도·목록 갱신 검증

··설명 약 16분·작성: 해비·블로그 소개

TanStack Query 화면의 로딩·오류·재시도와 캐시 격리는 어떻게 테스트하나요?

TanStack Query 화면 테스트에서는 테스트마다 QueryClient를 새로 만들어 캐시가 다른 검사에 섞이지 않게 합니다. 응답 완료 순서를 제어해 로딩·오류·재시도·추가 후 목록 갱신을 확인하세요.

적용 조건·범위: 이 파일은 일반 순차 test를 사용합니다. test.concurrent로 바꾸면 공유 document와 afterEach의 전체 cleanup이 서로 간섭할 수 있습니다. 새 client만 만들었다고 같은 DOM의 동시 렌더까지 안전해지는 것은 아닙니다. 병렬화가 필요하면 파일 또는 실행 환경 단위의 DOM 격리를 먼저 확인하세요.

본문 근거: TanStack Query Testing · Invalidations from Mutations · Testing Library Async Methods

TanStack Query 컴포넌트 테스트: QueryClient 격리와 실패·재시도·목록 갱신 검증

테스트마다 QueryClient와 fixture를 새로 만들고 Promise 완료 순서를 제어합니다. pending, 오류, 의도적 retry, mutation 뒤 목록 갱신과 캐시 오염 방지를 jsdom 테스트 7개로 검증합니다.

테스트마다 QueryClient와 데이터를 새로 준비합니다. 사용자 동작을 실행한 뒤 화면 결과를 검증해 테스트 사이에 캐시가 섞이지 않게 합니다.
테스트마다 QueryClient와 데이터를 새로 준비합니다. 사용자 동작을 실행한 뒤 화면 결과를 검증해 테스트 사이에 캐시가 섞이지 않게 합니다.

목차

1. 화면 구현 대신 테스트의 계약 정하기

선수 학습은 React Testing Library 사용자 행동 테스트, 첫 useQuery와 Provider, queryKey 설계입니다. label로 입력을 찾고 userEvent로 제출할 수 있다는 전제에서 시작합니다. 이번에는 테스트 A가 만든 캐시가 테스트 B를 통과시키는 문제와 비동기 완료 시점을 다룹니다.

useMutation 저장 실습이 입력부터 저장 화면을 만드는 과정이라면, 이 글은 이미 주어진 작은 화면의 검증 방법을 설계합니다. 고급 비동기 테스트에서 다루는 HTTP 상태별 백오프와 취소 알고리즘을 다시 구현하지 않습니다. 실제 QueryClient·Provider·훅을 사용하고 데이터 함수만 교체하여 React와 캐시가 만나는 경계를 검사합니다.

계약 제어할 사건 검사할 관찰 결과
초기 읽기 응답을 보류한 뒤 resolve pending → 목록 및 캐시
일반 읽기 실패 reject 후 사용자가 다시 읽기 오류, 최초 1회, 회복
명시한 retry 실패 후 두 번째 응답 retry 1은 총 2회, 성공 또는 최종 오류
저장 후 갱신 저장 응답과 목록 응답을 따로 완료 후속 읽기 전에는 완료 표시 없음
저장 실패 저장 reject 입력 보존, 읽기 추가 호출 없음
캐시 격리 A의 캐시를 남기고 B 렌더링 B는 자기 fixture만 표시

2. 프로젝트와 실행 환경

ZIP은 기존 프로젝트 없이 실행 가능한 Vite·React JavaScript 프로젝트입니다. index.html, src/styles.css, src/main.jsx를 분리했습니다. Node.js 22.12 이상 또는 24 LTS와 npm을 준비한 뒤 압축을 푼 프로젝트 폴더에서 실행합니다. 테스트는 jsdom이며 브라우저를 띄우지 않습니다. npm run dev는 학습자가 직접 화면을 열 때 사용하는 선택 명령입니다.

npm install
npm test
npm run build
# 화면을 직접 살펴볼 때
npm run dev

package.json

package.json 전체 코드 보기

vite.config.js

vite.config.js 전체 코드 보기

src/test/setup.js

src/test/setup.js 전체 코드 보기

직접 의존성에는 ^와 ~를 쓰지 않았습니다. 최초 npm install 뒤 생성되는 package-lock.json을 보관하고 이후 npm ci를 사용하면 전이 의존성도 함께 고정할 수 있습니다. 이 배포본은 직접 버전을 고정한 소스이며 전체 의존성 그래프를 고정한 lockfile은 포함하지 않습니다. TypeScript를 사용하지 않아 별도 타입 검사 단계는 없습니다.

3. QueryClient·fixture·DOM 수명 맞추기

QueryClient를 테스트 파일 최상단에 하나만 만들면 DOM을 지워도 캐시는 남습니다. 반대로 컴포넌트 함수 안에서 매 렌더마다 만들면 같은 화면의 캐시가 유지되지 않습니다. 이 실습에서는 show 호출마다 하나를 만들고, 그 렌더링 동안 같은 인스턴스를 유지합니다. 실제 앱의 main.jsx에는 앱 수명 동안 유지되는 별도 client가 있습니다.

src/test/harness.jsx

src/test/harness.jsx 전체 코드 보기

show는 DOM 컨테이너·unmount와 client를 함께 반환합니다. DOM 결과가 우선이며 캐시 값 검사는 올바른 key에 결과가 들어갔다는 보조 증거입니다. fixture()도 매 테스트 안에서 호출합니다. vi.fn 호출 기록만 지워도 closure의 rows는 초기화되지 않으므로 하나의 공유 fixture에 mockClear만 적용하는 방식은 격리를 보장하지 않습니다.

afterEach는 assertion이 실패해도 실행됩니다. 먼저 cleanup으로 observer를 unmount하고 모든 client.clear()로 query와 mutation 캐시를 정리합니다. gcTime: Infinity는 이 하네스에서 GC 타이머를 만들지 않기 위한 선택이며 수동 정리를 대체하지 않습니다. 실제 HTTP 요청을 사용한다면 AbortSignal 소비와 요청 취소도 따로 설계해야 합니다. clear가 외부 서버의 쓰기 작업을 취소해 주지는 않습니다.

이 파일은 일반 순차 test를 사용합니다. test.concurrent로 바꾸면 공유 document와 afterEach의 전체 cleanup이 서로 간섭할 수 있습니다. 새 client만 만들었다고 같은 DOM의 동시 렌더까지 안전해지는 것은 아닙니다. 병렬화가 필요하면 파일 또는 실행 환경 단위의 DOM 격리를 먼저 확인하세요.

4. 시간 대신 Promise 완료를 제어하기

deferred는 테스트가 resolve와 reject를 보관하는 작은 장치입니다. api.list가 아직 완료되지 않았을 때 pending 문구가 있는지 확인한 다음, 응답을 완료하고 findByText로 새 목록을 기다립니다. 임의의 300ms 대기는 없으며 “응답 전”과 “응답 후”를 직접 나눕니다. waitFor의 기본 재검사는 기대 조건을 확인하는 장치이지 서비스의 고정 대기 시간을 흉내 낸 것이 아닙니다.

직접 완료하는 Promise는 act 안에서 처리하고 이후 findBy 또는 waitFor로 Query의 알림이 DOM에 반영될 때까지 기다립니다. getBy는 지금 있어야 할 요소, queryBy는 없어야 할 요소, findBy는 앞으로 나타날 요소에 사용합니다. waitFor 안에는 클릭이나 resolve를 넣지 않고 assertion만 넣습니다. 콜백이 여러 번 실행될 수 있기 때문입니다.

5. 일반 오류와 retry 정책을 나누기

일반 오류 화면 테스트의 기본값은 queries.retry: false입니다. 따라서 한 번 실패하면 바로 오류를 보여 주는 계약을 좁게 검사할 수 있습니다. mutations.retry도 false로 명시하여 저장 실패가 추가 쓰기를 발생시키지 않도록 했습니다. 컴포넌트에 retry를 직접 지정하면 client 기본값보다 우선하므로 이 대상의 useQuery에는 retry를 하드코딩하지 않습니다.

재시도 동작은 별도 두 테스트에서만 retry: 1, retryDelay: 0으로 엽니다. 1은 총 호출 횟수가 아니라 첫 실패 이후 추가 시도 수입니다. 두 번째 응답을 보류하여 도중에는 최종 오류가 표시되지 않는지 확인하고, 성공 또는 예산 소진 뒤 최종 오류를 검사합니다. 0은 테스트 시간을 줄인 정책이므로 운영의 지수 백오프 간격을 검증했다는 뜻은 아닙니다. 수동 “목록 다시 읽기”와 자동 retry는 서로 다른 사용자·라이브러리 동작입니다.

6. 저장 완료와 목록 반영을 구분하기

mutation 테스트는 write와 refresh 두 Promise를 분리합니다. 저장 API 완료만으로 테스트를 끝내면 invalidation을 제거해도 통과할 수 있습니다. 먼저 저장 버튼이 잠겼는지 확인하고 write를 완료합니다. api.list의 두 번째 호출이 시작돼도 완료 문구는 없어야 하고 버튼은 계속 잠겨 있어야 합니다. refresh를 완료한 뒤에야 목록과 캐시의 새 항목 및 완료 문구를 확인합니다.

onSuccess가 invalidateQueries의 완료를 await하는 것이 이 계약의 핵심입니다. 호출 횟수만으로 충분하지 않기 때문에 새 항목의 DOM과 정확한 캐시 배열을 함께 확인합니다. 저장 실패 테스트는 오류 문구뿐 아니라 입력 보존, 다시 제출 가능한 버튼, 목록 읽기가 추가로 발생하지 않았음을 확인합니다. 저장 성공 뒤 읽기 실패까지 자세히 검증하는 확장 사례는 저장 실습 글을 참고하세요.

7. 같은 queryKey의 오염을 직접 검출하기

격리 테스트는 서로 다른 테스트 실행 순서에 기대지 않고 한 사례 안에 A와 B를 배치합니다. A에서 데이터를 추가하고 캐시를 채운 뒤 unmount합니다. A의 캐시를 일부러 clear하지 않은 상태에서 같은 key와 staleTime: Infinity를 가진 B를 새 show로 렌더링합니다. B는 자기 목록을 읽고 A의 두 항목을 보여 주지 않아야 합니다. 테스트 마지막에는 afterEach가 두 client를 모두 정리합니다.

staleTime: Infinity는 오염을 잘 드러내는 조건입니다. 공유 client가 재사용되면 fresh한 A 데이터를 그대로 읽고 B의 queryFn은 실행하지 않을 수 있습니다. 따라서 client 객체가 다른지, B API가 한 번 호출됐는지, B DOM과 캐시가 정확한지 함께 확인합니다. A 캐시가 여전히 두 항목인 것도 검사하여 “A 캐시를 미리 비워서 우연히 통과”하는 경우와 구분합니다.

src/App.test.jsx

src/App.test.jsx 전체 코드 보기

8. 테스트 대상 코드 읽기

아래 대상은 테스트 계약을 읽기 위한 최소 화면입니다. useQuery와 useMutation을 mock하지 않습니다. api는 비동기 list와 save만 제공하고 오류는 throw 또는 rejected Promise로 전달합니다. Promise를 성공 값으로 반환하면서 그 안에 error 속성만 넣으면 Query는 성공으로 볼 수 있으므로 실패 fixture는 반드시 reject합니다. 화면의 데이터 함수만 주입 가능하게 둬 테스트가 내부 state나 훅의 가짜 반환값에 묶이지 않게 합니다.

src/App.jsx

src/App.jsx 전체 코드 보기

실행용 main.jsx의 메모리 데이터는 새로고침하면 초기화됩니다. 별도 HTTP 서버나 API 키가 없습니다. 저장 성공 뒤 목록 읽기가 실패하면 쓰기 성공과 읽기 실패를 구분해서 표시하며 읽기 버튼으로 회복할 수 있습니다. invalidateQueries는 기본적으로 후속 refetch 오류를 throw하지 않을 수 있으므로 mutation 성공만으로 읽기 성공을 단정하지 않습니다.

9. 실패 실험과 검증 체크리스트

작성 환경에서 Vitest 4.1.11의 테스트 파일 1개, 테스트 7개 통과와 Vite 8.3.0 프로덕션 빌드 통과를 확인했습니다. invalidation을 제거하는 의도적 변형에서는 “저장 응답 이후…” 테스트가 실패했고 원본 복원 뒤 전체 테스트를 다시 통과시켰습니다. 테스트가 실제 갱신 누락을 탐지한다는 제한된 증거입니다.

  • 각 test에서 fixture()와 show()를 호출하는가?
  • DOM 정리와 QueryClient 정리가 실패한 테스트 뒤에도 실행되는가?
  • 일반 오류는 retry:false, 정책 검사는 명시적 retry로 분리했는가?
  • pending 구간을 응답 완료 전에 실제로 관찰했는가?
  • 저장 응답과 후속 읽기 응답을 독립적으로 제어했는가?
  • 호출 기록만 아니라 오류·목록·입력·버튼을 확인했는가?
  • 캐시 격리는 같은 queryKey와 fresh 데이터에서도 유지되는가?
  • 고정 sleep을 추가하기 전에 기다릴 사건을 정확히 정했는가?

실패 메시지에서 먼저 어떤 계약이 깨졌는지 확인하세요. 목록 조회가 2회가 아니라면 key 불일치나 invalidation 누락을 살펴봅니다. 오류 테스트가 시간 초과라면 client 기본값보다 우선한 retry 옵션이 있는지 확인합니다. A 데이터가 B에 나타나면 client와 fixture의 생성 위치를 점검합니다. timeout을 늘리거나 matcher를 느슨하게 만드는 것이 첫 해결책은 아닙니다.

검증 범위는 jsdom 컴포넌트 행동과 번들 생성입니다. 실제 브라우저를 실행하지 않았으며 레이아웃, 모바일 입력, HTTP 직렬화·인증, 실제 네트워크 지연과 서버 영속성을 검증하지 않았습니다. 테스트의 fixture는 실제 통신을 대체하므로 해당 통신 계약은 별도 통합 테스트가 필요합니다.

실습 다운로드와 다음 학습

ZIP에는 실행용 HTML·CSS·JS, 테스트 7개, 하네스, package.json, 실행 README가 들어 있습니다. node_modules와 dist는 포함하지 않습니다. 다음 연습으로 테스트 대상의 invalidation을 잠시 제거하고 실패를 확인한 뒤 복원해 보세요. 더 나아가 읽기 갱신 실패 후 저장을 반복하지 않는 사용자 경로를 새 테스트로 작성할 수 있습니다.

API 의미를 확인할 공식 자료: TanStack Query Testing, Invalidations from Mutations, Testing Library Async Methods.

선택 실습: 같은 프로젝트로 연습하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 앱의 동작을 자동 테스트로 확인합니다.

이번 회차 추가·수정 파일: app/ui/task-list-client.test.tsx , app/ui/task-form.test.tsx

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

누적 프로젝트 적용

시작 위치: 아래 기존 환경·파일 구성에서 시작합니다. 이 글의 완료 ZIP은 결과 확인용입니다.

전체 실습 프로젝트 ZIP 내려받기

시작 체크포인트: React 검색 필터 테스트: 교집합·빈 결과·입력 초기화 검증의 전체 파일·변경 내용에서 이어갑니다.

목록 테스트를 추가하고 폼 테스트는 아래 전체 버전으로 갱신합니다. fetch 응답·Action·Next 라우팅 경계를 대체하고 실제 QueryClient를 매번 만듭니다. Link는 a로 대체하므로 링크 텍스트를 검사할 뿐 Next 경로 이동 성공을 검사하지 않습니다. 목록 오류→재조회와 다른 client의 캐시 격리, 폼 invalidation 호출을 나누어 검증합니다. 이 누적 검사만으로 실제 저장 후 서버 목록 갱신까지 검증했다고 하지 않습니다. 상단 독립 예제가 저장·후속 읽기 완료 순서를 더 엄밀히 보여 줍니다.

이번 회차 추가·수정 파일: app/ui/task-list-client.test.tsx, app/ui/task-form.test.tsx

전체 파일·코드 보기

app/ui/task-list-client.test.tsx — 전체 파일

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import TaskListClient from './task-list-client';
import type { ReactNode } from 'react';
vi.mock('next/link', () => ({
  default: ({ href, children }: { href: string; children: ReactNode }) => <a href={href}>{children}</a>,
}));
test('목록 오류 뒤 직접 재조회와 새 QueryClient의 격리', async () => {
  const row = { id: 'a', title: 'A 목록', description: '', completed: false, ownerId: 'demo' };
  const fetcher = vi.fn()
    .mockResolvedValueOnce(new Response('', { status: 500 }))
    .mockResolvedValueOnce(Response.json([row]))
    .mockResolvedValueOnce(Response.json([{ ...row, title: 'B 목록' }]));
  vi.stubGlobal('fetch', fetcher);
  const clients: QueryClient[] = [];
  function show() {
    const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
    clients.push(client);
    render(<QueryClientProvider client={client}>
      <TaskListClient ownerId="demo" q="" status="all" />
    </QueryClientProvider>);
  }
  try {
    show();
    expect(await screen.findByRole('alert')).toHaveTextContent('목록 조회에 실패');
    await userEvent.setup().click(screen.getByRole('button', { name: '목록 새로 조회' }));
    expect(await screen.findByRole('link', { name: 'A 목록' })).toBeInTheDocument();
    cleanup();
    show();
    expect(await screen.findByRole('link', { name: 'B 목록' })).toBeInTheDocument();
    expect(screen.queryByRole('link', { name: 'A 목록' })).not.toBeInTheDocument();
    expect(clients[0]).not.toBe(clients[1]);
  } finally { cleanup(); clients.forEach(client => client.clear()); }
});

app/ui/task-form.test.tsx — 전체 파일

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import TaskForm from './task-form';
const mocks = vi.hoisted(() => ({ createTask: vi.fn(), refresh: vi.fn() }));
vi.mock('@/app/lib/actions', () => ({ createTask: mocks.createTask }));
vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: mocks.refresh }) }));
let client: QueryClient;
beforeEach(() => {
  mocks.createTask.mockReset();
  mocks.refresh.mockReset();
  client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => client.clear());
function show() {
  render(<QueryClientProvider client={client}><TaskForm /></QueryClientProvider>);
  return userEvent.setup();
}
test('빈 입력은 제출하지 않고 유효한 제목은 서버 경계에 전달한다', async () => {
  mocks.createTask.mockResolvedValue({ ok: true, message: '저장 완료' });
  const invalidate = vi.spyOn(client, 'invalidateQueries');
  const user = show();
  await user.click(screen.getByRole('button', { name: '작업 추가' }));
  expect(await screen.findByText('제목이 필요합니다.')).toBeInTheDocument();
  expect(mocks.createTask).not.toHaveBeenCalled();
  await user.type(screen.getByRole('textbox', { name: '새 작업' }), '테스트 작업');
  await user.click(screen.getByRole('button', { name: '작업 추가' }));
  expect(await screen.findByRole('status')).toHaveTextContent('저장 완료');
  await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
  expect(mocks.createTask.mock.calls[0][0].get('title')).toBe('테스트 작업');
  expect(mocks.refresh).toHaveBeenCalledOnce();
  expect(invalidate).toHaveBeenCalledWith({ queryKey: ['tasks'] });
});
test('저장 실패는 입력을 보존한다', async () => {
  mocks.createTask.mockRejectedValue(new Error('test failure'));
  const user = show();
  await user.type(screen.getByRole('textbox'), '보존할 작업');
  await user.click(screen.getByRole('button', { name: '작업 추가' }));
  await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('저장에 실패'));
  expect(screen.getByRole('textbox')).toHaveValue('보존할 작업');
});

실행 결과 기록: 명령·종료 코드·테스트 개수·실패 이유를 남깁니다. 타입 검사와 jsdom 통과는 실제 브라우저·인증 서버·영속 저장소 검증이 아닙니다.

원래 제안된 확장 기준: 아래 검증 목표 중 현재 회차 파일이 다루는 범위는 위 실행 안내를 따릅니다. 이름만 적힌 testing-step 체크포인트는 다운로드 파일명이 아닙니다.

직접 확인: 6회차 테스트 기준

테스트 대상: app/ui/task-list-client.test.tsx와 app/ui/task-form.test.tsx의 QueryClient·mock 경계. 기존 독립 예제는 그대로 연습하고, 아래 기준은 Next.js TaskFlow에 적용할 때 확인합니다. 공개 글의 코드 예시와 실제 프로젝트의 파일 경로·타입이 다른 경우 현재 소스를 먼저 대조하세요.

  1. 테스트마다 새 QueryClient를 만들어 QueryClientProvider로 감싸고 API 함수만 mock합니다. 목록 오류·수동 재조회·새 client의 격리와 저장 성공을 나누어 확인합니다. 폼 파일에서는 invalidateQueries 호출을 검사합니다. 후속 읽기 완료와 실제 목록 반영은 상단 독립 테스트의 write/refresh 분리 사례로 확인합니다.
  2. 실행 명령: npm run test:run && npm run build. 실제 명령의 종료 코드와 테스트 파일·개수·실패 메시지를 기록합니다.

통과 예상: 각 테스트를 단독·전체로 실행해도 결과가 같고, 하단 목록 테스트는 A 오류 뒤 재조회와 새 B client의 격리를 보여야 합니다. 폼 테스트는 저장 후 invalidateQueries 호출과 입력 초기화를 확인합니다.

실패 예상: 하단 폼의 invalidation을 제거하면 호출 단언이 실패해야 합니다. 저장 뒤 DOM 목록 갱신 단언까지 실패하는 비교는 상단 독립 예제를 대상으로 합니다.

실패 진단: 단독 실행만 통과하면 QueryClient·fixture·mock 수명 공유를 확인합니다. 실패 검사가 늦으면 테스트용 retry:false와 비동기 대기를 확인합니다.

검증 기록: 이 글의 실습 절차는 작성했지만, 이번 개편에서 TaskFlow 원본 프로젝트의 테스트·빌드를 실행하지 않았습니다. 따라서 testing-step-06-query-isolation 완료와 테스트 통과는 미확인입니다. 실행 로그를 확인한 뒤에만 완료로 바꾸세요.

다음 학습: 7회차 비동기 실패·재시도·경쟁 상태

전체 코드

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Query 컴포넌트 테스트</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

package.json

{
  "name": "query-component-test-isolation",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "test": "vitest run",
    "build": "vite build"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "@tanstack/react-query": "5.102.8"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7",
    "@testing-library/jest-dom": "7.0.1"
  }
}

src/App.jsx

import React, { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
export const lessonKey = ['lessons', 'list'];
export function App({ api }) {
  const [title, setTitle] = useState('');
  const client = useQueryClient();
  const list = useQuery({ queryKey: lessonKey, queryFn: () => api.list() });
  const save = useMutation({
    mutationFn: (value) => api.save(value),
    onSuccess: async () => {
      setTitle('');
      await client.invalidateQueries({ queryKey: lessonKey, exact: true });
    },
  });
  function submit(event) {
    event.preventDefault();
    if (title.trim() && !save.isPending) save.mutate(title.trim());
  }
  return (
    <main>
      <h1>Query 컴포넌트 테스트</h1>
      {list.isPending && <p role="status">목록 읽는 중</p>}
      {list.isError && (
        <div role="alert">
          {list.error.message}
          <button onClick={() => list.refetch()}>목록 다시 읽기</button>
        </div>
      )}
      <ul>
        {list.data?.map((row) => (
          <li key={row.id}>{row.title}</li>
        ))}
      </ul>
      <form onSubmit={submit}>
        <label htmlFor="title">수업 제목</label>
        <input
          id="title"
          value={title}
          disabled={save.isPending}
          onChange={(event) => {
            setTitle(event.target.value);
            save.reset();
          }}
        />
        <button disabled={!title.trim() || save.isPending}>
          {save.isPending ? '저장 및 갱신 중' : '저장'}
        </button>
      </form>
      {save.isError && <p role="alert">{save.error.message}</p>}
      {save.isSuccess && !list.isError && <p role="status">목록 갱신 완료</p>}
      {save.isSuccess && list.isError && (
        <p role="status">저장은 완료됐지만 목록 확인이 필요합니다.</p>
      )}
    </main>
  );
}

src/App.test.jsx

import React from 'react';
import { test, expect } from 'vitest';
import { screen, act, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { show, fixture, deferred } from './test/harness.jsx';
import { lessonKey } from './App.jsx';

test('pending은 응답을 완료한 뒤 목록으로 바뀐다', async () => {
  const read = deferred();
  const api = fixture();
  api.list.mockReturnValueOnce(read.promise);
  const { client } = show(api);
  expect(screen.getByRole('status')).toHaveTextContent('목록 읽는 중');
  expect(screen.queryByText('첫 수업')).not.toBeInTheDocument();
  await act(async () => read.resolve([{ id: 1, title: '첫 수업' }]));
  expect(await screen.findByText('첫 수업')).toBeInTheDocument();
  expect(client.getQueryData(lessonKey)).toEqual([{ id: 1, title: '첫 수업' }]);
});

test('일반 오류는 자동 재시도하지 않고 읽기 버튼으로 회복한다', async () => {
  const read = deferred();
  const api = fixture();
  api.list.mockReturnValueOnce(read.promise);
  show(api);
  const user = userEvent.setup();
  await act(async () => read.reject(new Error('읽기 실패')));
  expect(await screen.findByRole('alert')).toHaveTextContent('읽기 실패');
  expect(api.list).toHaveBeenCalledTimes(1);
  await user.click(screen.getByRole('button', { name: '목록 다시 읽기' }));
  expect(await screen.findByText('첫 수업')).toBeInTheDocument();
  expect(api.list).toHaveBeenCalledTimes(2);
});

test('명시한 retry 1회는 두 번째 응답으로 성공한다', async () => {
  const second = deferred();
  const api = fixture();
  api.list
    .mockRejectedValueOnce(new Error('일시 실패'))
    .mockReturnValueOnce(second.promise);
  show(api, { retry: 1, retryDelay: 0 });
  await waitFor(() => expect(api.list).toHaveBeenCalledTimes(2));
  expect(screen.getByRole('status')).toHaveTextContent('목록 읽는 중');
  expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  await act(async () => second.resolve([{ id: 1, title: '복구된 수업' }]));
  expect(await screen.findByText('복구된 수업')).toBeInTheDocument();
  expect(api.list).toHaveBeenCalledTimes(2);
});

test('retry 예산 소진 뒤에는 최종 오류가 표시된다', async () => {
  const api = fixture();
  api.list.mockRejectedValue(new Error('계속 실패'));
  show(api, { retry: 1, retryDelay: 0 });
  expect(await screen.findByRole('alert')).toHaveTextContent('계속 실패');
  expect(api.list).toHaveBeenCalledTimes(2);
});

test('저장 응답 이후에도 목록 갱신이 끝날 때까지 완료를 알리지 않는다', async () => {
  const write = deferred();
  const refresh = deferred();
  const api = fixture();
  api.save.mockReturnValueOnce(write.promise);
  api.list
    .mockResolvedValueOnce([{ id: 1, title: '첫 수업' }])
    .mockReturnValueOnce(refresh.promise);
  const { client } = show(api);
  const user = userEvent.setup();
  await screen.findByText('첫 수업');
  await user.type(screen.getByRole('textbox', { name: '수업 제목' }), '두 번째 수업');
  await user.click(screen.getByRole('button', { name: '저장', exact: true }));
  expect(screen.getByRole('button', { name: '저장 및 갱신 중' })).toBeDisabled();
  await act(async () => write.resolve({ id: 2, title: '두 번째 수업' }));
  await waitFor(() => expect(api.list).toHaveBeenCalledTimes(2));
  expect(screen.queryByText('목록 갱신 완료')).not.toBeInTheDocument();
  expect(screen.getByRole('button', { name: '저장 및 갱신 중' })).toBeDisabled();
  const updated = [
    { id: 1, title: '첫 수업' },
    { id: 2, title: '두 번째 수업' },
  ];
  await act(async () => refresh.resolve(updated));
  expect(await screen.findByText('목록 갱신 완료')).toBeInTheDocument();
  expect(screen.getByText('두 번째 수업')).toBeInTheDocument();
  expect(client.getQueryData(lessonKey)).toEqual(updated);
  expect(api.save).toHaveBeenCalledExactlyOnceWith('두 번째 수업');
});

test('저장 실패는 입력을 남기고 목록을 갱신하지 않는다', async () => {
  const api = fixture();
  api.save.mockRejectedValueOnce(new Error('저장 실패'));
  show(api);
  const user = userEvent.setup();
  await screen.findByText('첫 수업');
  await user.type(screen.getByRole('textbox', { name: '수업 제목' }), '보존할 입력');
  await user.click(screen.getByRole('button', { name: '저장', exact: true }));
  expect(await screen.findByRole('alert')).toHaveTextContent('저장 실패');
  expect(screen.getByRole('textbox', { name: '수업 제목' })).toHaveValue('보존할 입력');
  expect(screen.getByRole('button', { name: '저장', exact: true })).toBeEnabled();
  expect(api.save).toHaveBeenCalledTimes(1);
  expect(api.list).toHaveBeenCalledTimes(1);
});

test('같은 queryKey라도 서로 다른 client와 fixture는 캐시를 공유하지 않는다', async () => {
  const firstApi = fixture('A 전용');
  const first = show(firstApi, { staleTime: Infinity });
  await within(first.container).findByText('A 전용');
  await firstApi.save('A에만 추가');
  await act(async () => first.client.invalidateQueries({ queryKey: lessonKey }));
  expect(await within(first.container).findByText('A에만 추가')).toBeInTheDocument();
  first.unmount();
  const secondApi = fixture('B 전용');
  const second = show(secondApi, { staleTime: Infinity });
  expect(second.client).not.toBe(first.client);
  expect(await within(second.container).findByText('B 전용')).toBeInTheDocument();
  expect(within(second.container).queryByText('A 전용')).not.toBeInTheDocument();
  expect(within(second.container).queryByText('A에만 추가')).not.toBeInTheDocument();
  expect(secondApi.list).toHaveBeenCalledTimes(1);
  expect(second.client.getQueryData(lessonKey)).toEqual([{ id: 1, title: 'B 전용' }]);
  expect(first.client.getQueryData(lessonKey)).toHaveLength(2);
});

src/main.jsx

import React from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { App } from './App.jsx';
import './styles.css';
let rows = [{ id: 1, title: '첫 수업' }];
const api = {
  async list() {
    return rows.map((row) => ({ ...row }));
  },
  async save(title) {
    const row = { id: rows.length + 1, title };
    rows = [...rows, row];
    return row;
  },
};
const client = new QueryClient({
  defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
createRoot(document.getElementById('root')).render(
  <QueryClientProvider client={client}>
    <App api={api} />
  </QueryClientProvider>,
);

src/styles.css

body {
  margin: 0;
  color: #222;
  background: #fff;
  font-family: sans-serif;
}
main {
  max-width: 40rem;
  margin: 3rem auto;
  padding: 1rem;
}
form {
  display: grid;
  gap: 0.75rem;
}
input,
button {
  padding: 0.75rem;
  font: inherit;
  border: 1px solid #777;
}
button {
  background: #eee;
  color: #222;
  cursor: pointer;
}
button:disabled {
  color: #777;
  cursor: default;
}
li {
  margin-block: 0.5rem;
}

src/test/harness.jsx

import React from 'react';
import { afterEach, vi } from 'vitest';
import { cleanup, render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { App } from '../App.jsx';
const clients = new Set();
afterEach(() => {
  cleanup();
  for (const client of clients) client.clear();
  clients.clear();
});
export function show(api, queries = {}) {
  const client = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        gcTime: Infinity,
        refetchOnWindowFocus: false,
        ...queries,
      },
      mutations: { retry: false },
    },
  });
  clients.add(client);
  const view = render(
    <QueryClientProvider client={client}>
      <App api={api} />
    </QueryClientProvider>,
  );
  return { ...view, client };
}
export function deferred() {
  let resolve;
  let reject;
  const promise = new Promise((yes, no) => {
    resolve = yes;
    reject = no;
  });
  return { promise, resolve, reject };
}
export function fixture(title = '첫 수업') {
  let rows = [{ id: 1, title }];
  return {
    list: vi.fn(async () => rows.map((row) => ({ ...row }))),
    save: vi.fn(async (value) => {
      const row = { id: rows.length + 1, title: value };
      rows = [...rows, row];
      return row;
    }),
  };
}

src/test/setup.js

import '@testing-library/jest-dom/vitest';

vite.config.js

import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.js'],
    include: ['src/**/*.test.jsx'],
  },
});

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

글 소개·학습 준비·전체 목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 9개 · 필수 7개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기