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

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
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
전체 코드
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'],
},
});
vite.config.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
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
8. 테스트 대상 코드 읽기
아래 대상은 테스트 계약을 읽기 위한 최소 화면입니다. useQuery와 useMutation을 mock하지 않습니다. api는 비동기 list와 save만 제공하고 오류는 throw 또는 rejected Promise로 전달합니다. Promise를 성공 값으로 반환하면서 그 안에 error 속성만 넣으면 Query는 성공으로 볼 수 있으므로 실패 fixture는 반드시 reject합니다. 화면의 데이터 함수만 주입 가능하게 둬 테스트가 내부 state나 훅의 가짜 반환값에 묶이지 않게 합니다.
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.
이 글이 도움이 되었나요?
테스트 학습 순서
필수 7개 · 전체 9개
읽음 기록 관리
전체 과정 목차 (9개)
- 필수 학습 · Vite 프로젝트에서 Vitest로 테스트 환경 시작하기
- 필수 학습 · React Testing Library로 클릭·입력 테스트 작성하기
- 선택 참고 · Vitest document is not defined 오류 해결: jsdom 설정 기준
- 필수 선수 · Jest console.error 테스트: try catch 에러 검증
- 필수 학습 · React 검색 필터 테스트: 교집합·빈 결과·입력 초기화 검증
- 필수 학습 · TanStack Query 컴포넌트 테스트: QueryClient 격리와 실패·재시도·목록 갱신 검증 현재 글
- 필수 학습 · 비동기 테스트 고급: 실패·재시도·경쟁 상태를 결정적으로 검증하기
- 필수 학습 · Playwright 첫 E2E 테스트: 로컬 앱 실행부터 결과 확인까지
- 선택 참고 · Playwright 테스트 실패 해결: locator가 요소를 찾지 못할 때 체크리스트
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.