TanStack Query · Phần 8 — Testing & Capstone
Test hook query/mutation bằng Vitest + React Testing Library + MSW (mock ở tầng network), kiểm thử optimistic rollback, rồi ghép tất cả thành một feature CRUD hoàn chỉnh.
Phần 8 khép lại nửa nền tảng của series (Phần 1–8). Đây không phải điểm dừng cuối — chỉ là một cột mốc. Bài có hai nửa: nửa đầu test code React Query đúng cách bằng Vitest + React Testing Library + MSW (vì code fetch không được test sẽ vỡ âm thầm); nửa sau là một capstone ghép tất cả Phần 1–8 thành một feature CRUD hoàn chỉnh, để bạn thấy mọi mảnh khớp với nhau ra sao.
Series đi tiếp đâu? Sau cột mốc này, nửa nâng cao/production (Phần 9–18) đào sâu hơn nhiều:
QueryClient& defaults sâu (9), SSR + Next.js App Router & hydration (10), prefetching & tích hợp router (11), cache như một store (12), offline-first & persistence (13), realtime WebSocket/SSE (14), mutation nâng cao (15), hiệu năng render (16), type-safety đỉnh cao (17), và kiến trúc production + migration (18). Nửa nền tảng cho bạn đủ công cụ để dùng React Query trong feature thật; nửa sau biến nó thành kiến trúc chịu được production.
1. Triết lý test: mock ở tầng network, không mock React Query
Sai lầm phổ biến là mock useQuery hoặc mock apiFetch. Làm vậy bạn test “code giả” chứ không phải hành vi thật. Cách đúng: để React Query chạy thật, chỉ chặn HTTP ở tầng network bằng MSW (Mock Service Worker). Test của bạn khi đó kiểm chứng đúng luồng người dùng: gọi mạng → cache → render.
Có ba “tầng” bạn có thể chèn mock. Chọn sai tầng làm test mất giá trị:
| Mock ở đâu | Bạn thực sự test gì | Đánh giá |
|---|---|---|
Mock useQuery | Component khi nhận sẵn data giả | ❌ Bỏ qua key, cache, status, retry — refactor là vỡ test |
Mock apiFetch / module API | Component + hook, nhưng không có lớp HTTP | ⚠️ Tốt hơn, nhưng vẫn không kiểm chứng URL/params/headers thật |
| Mock ở network (MSW) | Toàn luồng: fetch → cache → select → render | ✅ Sát người dùng nhất; refactor cách gọi API không phá test |
Cơ chế MSW: nó cài một request interceptor (ở Node là patch http/fetch, ở browser là Service Worker). React Query gọi fetch y như production — chỉ có phản hồi được MSW dựng. Vì vậy queryKey, staleTime, retry, structural sharing… đều chạy thật.
pnpm add -D vitest @testing-library/react @testing-library/user-event jsdom msw
| Công cụ | Vai trò trong test React Query |
|---|---|
| Vitest | Test runner + expect + fake timers (chạy nhanh, cấu hình giống Vite) |
| @testing-library/react | render, screen, waitFor, query findBy*/getBy* |
| @testing-library/user-event | Giả lập tương tác người dùng (click/type) sát thật hơn fireEvent |
| jsdom | DOM giả trong Node để component render được |
| MSW | Chặn HTTP ở tầng network, trả response mock |
2. Thiết lập MSW
// src/test/server.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('*/customers', () =>
HttpResponse.json([
{ id: '1', name: 'An', email: 'an@example.com' },
{ id: '2', name: 'Bình', email: 'binh@example.com' },
]),
),
http.post('*/customers', async ({ request }) => {
const body = (await request.json()) as { name: string; email: string };
return HttpResponse.json({ id: '3', ...body }, { status: 201 });
}),
];
export const server = setupServer(...handlers);
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers()); // reset override sau mỗi test
afterAll(() => server.close());
Ba hook lifecycle này là xương sống của mọi test dùng MSW — hiểu rõ từng cái:
| Hook | Khi nào chạy | Vì sao bắt buộc |
|---|---|---|
server.listen() | Một lần trước toàn bộ test | Bật interceptor; onUnhandledRequest: 'error' để request lạ (chưa mock) làm fail test thay vì lọt ra mạng thật |
server.resetHandlers() | Sau mỗi test | Gỡ mọi server.use(...) override → test sau bắt đầu từ handler gốc, không rò rỉ |
server.close() | Một lần sau toàn bộ test | Gỡ interceptor, trả fetch về nguyên trạng |
onUnhandledRequest: 'error'là một “lưới an toàn” quý giá: nếu component gọi một endpoint bạn chưa nghĩ tới (vd analytics, ảnh), test fail ngay với URL cụ thể — bạn thấy lỗi thật thay vì một timeout khó hiểu.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
});
3. Helper render kèm provider (quan trọng nhất)
Mỗi test cần một QueryClient mới và cô lập. Lý do và các tuỳ chọn test-time gói gọn trong bảng sau:
| Tuỳ chọn test-time | Đặt gì | Vì sao |
|---|---|---|
| Client mới mỗi test | new QueryClient() trong helper | Tránh rò rỉ cache — test này thấy data của test kia, gây flaky |
queries.retry | false | Test ca lỗi không phải chờ 3 lần retry + backoff → nhanh & xác định |
mutations.retry | false | Như trên, cho mutation |
gcTime | Infinity (tuỳ chọn) | Cache không bị dọn giữa chừng trong lúc waitFor đang chờ |
| Logger lỗi | tắt (v5 mặc định im) | Không spam console khi cố ý test lỗi |
// src/test/render.tsx
import { type ReactElement } from 'react';
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export function createTestClient() {
// Client RIÊNG mỗi test → không rò rỉ cache giữa các test.
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // đừng retry trong test, sẽ làm chậm/khó đoán
gcTime: Infinity, // không dọn cache giữa lúc đang waitFor
},
mutations: { retry: false },
},
});
}
export function renderWithClient(ui: ReactElement) {
const queryClient = createTestClient();
return {
queryClient, // trả ra để test có thể đọc/ghi cache khi cần
...render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
),
};
}
Cơ chế rò rỉ:
QueryClientchứa mộtQueryCachesống suốt đời client. Nếu bạnexport const queryClient = new QueryClient()ở module rồi dùng chung cho mọi test, dữ liệu test trước vẫn nằm trong cache khi test sau chạy —getBy*có thể thấy data “ma”, và thứ tự test ảnh hưởng kết quả. Client mới mỗi test = cache trắng, độc lập.
4. Test một query hook
// src/features/customers/CustomerList.test.tsx
import { screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { renderWithClient } from '@/test/render';
import { CustomerList } from './CustomerList';
describe('CustomerList', () => {
it('hiển thị khách hàng sau khi tải xong', async () => {
renderWithClient(<CustomerList />);
// Ban đầu là trạng thái loading
expect(screen.getByText(/đang tải/i)).toBeInTheDocument();
// findBy* tự đợi (async) cho tới khi data về và render
expect(await screen.findByText('An')).toBeInTheDocument();
expect(screen.getByText('Bình')).toBeInTheDocument();
});
});
Mấu chốt là chọn đúng kiểu query của Testing Library — đây là nguồn flaky số một trong test React Query:
| Query | Đồng bộ? | Khi không tìm thấy | Dùng cho |
|---|---|---|---|
getBy* | Có | Ném lỗi ngay | Thứ đã có trên DOM (trạng thái loading ban đầu) |
queryBy* | Có | Trả null | Khẳng định một phần tử không tồn tại |
findBy* | Không (Promise) | Retry tới timeout rồi ném | Thứ xuất hiện sau khi fetch xong (data async) |
waitFor(fn) | Không (Promise) | Retry fn tới khi không ném | Assertion phức tạp / chờ một điều kiện đổi |
Luồng thời gian một query test:
render() ──▶ status: 'pending' (getByText "đang tải" PASS)
│ fetch() đi qua MSW
▼
microtask ──▶ data về → setState → re-render
│
▼
findByText('An') ──▶ retry mỗi ~50ms tới khi thấy → PASS
Dùng getBy* cho data async sẽ luôn trượt: lúc dòng đó chạy, fetch còn đang bay, DOM chưa có “An”. findBy*/waitFor mới biết đợi.
5. Test trạng thái lỗi (override handler)
import { http, HttpResponse } from 'msw';
import { server } from '@/test/server';
it('hiển thị lỗi khi server trả 500', async () => {
// Override handler chỉ cho test này (afterEach sẽ reset).
server.use(
http.get('*/customers', () => new HttpResponse(null, { status: 500 })),
);
renderWithClient(<CustomerList />);
expect(await screen.findByText(/có lỗi/i)).toBeInTheDocument();
});
Vì sao
retry: falsecốt yếu ở đây: mặc định React Query retry 3 lần với exponential backoff. Không tắt, test này phải chờ ~vài giây mới tới trạng tháierror— vàwaitForcó thể timeout trước. Vớiretry: false, lỗi đầu tiên làerrorngay.
6. Test optimistic rollback
Đây là test giá trị nhất — kiểm chứng UI cập nhật tức thì rồi quay lại khi server lỗi:
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/server';
import { renderWithClient } from '@/test/render';
it('rollback khi toggle favorite thất bại', async () => {
const user = userEvent.setup();
renderWithClient(<CustomerList />);
const toggle = await screen.findByRole('button', { name: /favorite an/i });
expect(toggle).toHaveAttribute('aria-pressed', 'false');
// Bắt server trả lỗi cho thao tác toggle.
server.use(
http.post('*/customers/1/favorite', () => new HttpResponse(null, { status: 500 })),
);
await user.click(toggle);
// Optimistic: bật lên NGAY
expect(toggle).toHaveAttribute('aria-pressed', 'true');
// Sau khi server lỗi → onError rollback → quay về false
await waitFor(() => expect(toggle).toHaveAttribute('aria-pressed', 'false'));
});
Ba pha của test này map thẳng vào vòng đời optimistic của Phần 6:
1. SAU click → onMutate vá cache → assert 'true' (ĐỒNG BỘ, getBy/toHaveAttr)
2. server 500 → onError rollback → assert 'false' (ASYNC, waitFor)
3. (onSettled → invalidateQueries → refetch chốt giá trị thật)
| Pha cần test | Kiểu assertion | Vì sao |
|---|---|---|
| Optimistic bật ngay | đồng bộ, ngay sau await user.click | onMutate chạy trước khi request bay đi → DOM đã đổi |
| Rollback sau lỗi | await waitFor(...) | Rollback xảy ra trong onError, sau khi response 500 về |
| Chốt cuối (tuỳ chọn) | findBy* giá trị thật | onSettled invalidate → refetch; chỉ test nếu UX phụ thuộc |
Đặt
server.use(...)lỗi trướcuser.clicklà bắt buộc: handler phải sẵn sàng tại thời điểm mutation gọi mạng. Đặt sau click là một race — đôi khi request đã bay trước khi override kịp đăng ký.
7. MSW sâu: handlers, override & one-time
Handler là trái tim của test. Hai khái niệm cần phân biệt: handler gốc (trong server.ts, dùng cho mọi test) và override (server.use(...), chỉ test hiện tại, bị resetHandlers gỡ ở afterEach).
| Mẫu handler | Cú pháp | Dùng khi |
|---|---|---|
| GET trả JSON | http.get('*/path', () => HttpResponse.json(data)) | Happy path mặc định |
| POST đọc body | http.post('*/path', async ({ request }) => { const b = await request.json(); ... }) | Test create/update, echo lại body |
| Đọc params | http.get('*/customers/:id', ({ params }) => ...) | Route động |
| Đọc query string | ({ request }) => new URL(request.url).searchParams.get('q') | Test search/pagination |
| Trả lỗi HTTP | () => new HttpResponse(null, { status: 500 }) | Test trạng thái error |
| Trễ mạng | async () => { await delay(200); return HttpResponse.json(...) } | Test loading kéo dài / race |
| One-time override | server.use(http.get('*/path', resolver, { once: true })) | Lần gọi đầu lỗi, các lần sau dùng handler gốc (test retry/refetch) |
{ once: true } đặc biệt hữu ích để test luồng retry hoặc refetch: cho request đầu fail, request thứ hai (sau invalidate) thành công — bạn kiểm chứng được “lỗi rồi tự hồi phục”.
// Lần đầu 500, lần sau OK → test "thử lại thành công"
server.use(
http.get('*/customers', () => new HttpResponse(null, { status: 500 }), { once: true }),
http.get('*/customers', () => HttpResponse.json([{ id: '1', name: 'An', email: 'an@example.com' }])),
);
Thứ tự đăng ký: MSW khớp handler gần nhất được thêm trước.
server.use(...)chèn lên đầu danh sách nên luôn thắng handler gốc — cho tới khiresetHandlersdọn sạch.
8. Capstone — feature CRUD hoàn chỉnh
Giờ ghép mọi thứ. Cấu trúc feature theo quy ước feature-first đã dùng suốt series:
src/features/customers/
schema.ts # zod schema + z.infer types (Phần 3)
keys.ts # query-key factory (Phần 3)
api.ts # queryOptions + mutationFn (apiFetch) (Phần 3, 5)
hooks.ts # useCustomers, useCreateCustomer, ... (Phần 2-6)
CustomersPage.tsx # trang ghép tất cả
hooks.ts — gom mọi hook
Ở capstone này,
customersQuerylà bản phân trang: nhận{ q?, page? }và trả vềpageSchema({ items, hasMore }) như Phần 4, khác bản{ q? }đơn giản ở Phần 3. Nhớ mở rộngcustomerKeys.listđể chứa cảpage.
// src/features/customers/hooks.ts
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { customersQuery, createCustomer, deleteCustomer } from './api';
import { customerKeys } from './keys';
import type { Customer } from './schema';
export function useCustomers(filters: { q?: string; page?: number }) {
return useQuery({
...customersQuery(filters),
placeholderData: keepPreviousData, // chuyển trang/search mượt (Phần 4)
});
}
export function useCreateCustomer() {
const qc = useQueryClient();
return useMutation({
mutationFn: createCustomer,
onSuccess: () => qc.invalidateQueries({ queryKey: customerKeys.lists() }), // Phần 5
});
}
export function useDeleteCustomer() {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteCustomer,
// Optimistic delete (Phần 6)
onMutate: async (id: string) => {
const key = customerKeys.lists();
await qc.cancelQueries({ queryKey: key });
const previous = qc.getQueryData<{ items: Customer[] }>(key);
qc.setQueryData<{ items: Customer[] }>(key, (old) =>
old ? { ...old, items: old.items.filter((c) => c.id !== id) } : old,
);
return { previous, key };
},
onError: (_e, _id, ctx) => {
if (ctx) qc.setQueryData(ctx.key, ctx.previous); // rollback
},
onSettled: (_d, _e, _id, ctx) => {
if (ctx) qc.invalidateQueries({ queryKey: ctx.key }); // đồng bộ
},
});
}
CustomersPage.tsx — trang ghép
// src/features/customers/CustomersPage.tsx
import { useState } from 'react';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useCustomers, useCreateCustomer, useDeleteCustomer } from './hooks';
export function CustomersPage() {
const [q, setQ] = useState('');
const [page, setPage] = useState(1);
const debouncedQ = useDebouncedValue(q, 300); // tránh fetch mỗi phím gõ
const list = useCustomers({ q: debouncedQ, page });
const create = useCreateCustomer();
const remove = useDeleteCustomer();
return (
<section>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Tìm khách hàng…" />
{list.isPending ? (
<TableSkeleton />
) : list.isError ? (
<ErrorState message={list.error.message} />
) : list.data.items.length === 0 ? (
<EmptyState label="Không tìm thấy" />
) : (
<ul style={{ opacity: list.isPlaceholderData ? 0.6 : 1 }}>
{list.data.items.map((c) => (
<li key={c.id}>
{c.name}
<button onClick={() => remove.mutate(c.id)}>Xoá</button>
</li>
))}
</ul>
)}
<Pagination
page={page}
hasMore={list.data?.hasMore ?? false}
disabled={list.isPlaceholderData}
onChange={setPage}
/>
<NewCustomerForm
onSubmit={(input) =>
create.mutate(input, { onSuccess: () => toast.success('Đã thêm') })
}
pending={create.isPending}
/>
</section>
);
}
Feature này dùng mọi thứ trong nửa nền tảng. Bảng truy vết feature → phần để bạn thấy mỗi mảnh đến từ đâu:
| Mảnh trong capstone | Cơ chế React Query | Học ở |
|---|---|---|
customersQuery(filters) + zod | queryOptions tái dùng + validate response | Phần 3 |
customerKeys factory | Query-key có cấu trúc, invalidate theo tiền tố | Phần 3 |
placeholderData: keepPreviousData | Chuyển trang/search không nháy | Phần 4 |
useCreateCustomer + invalidateQueries | Mutation ghi → làm cũ list → refetch | Phần 5 |
useDeleteCustomer (cancel/snapshot/rollback/settled) | Optimistic delete + rollback | Phần 6 |
isPending/isError/isPlaceholderData UI | Bốn trạng thái + debounce | Phần 2 |
| Retry/Error Boundary cho lỗi nặng | throwOnError, retry thông minh | Phần 7 |
Đó là một feature production-ready thật sự — và là minh chứng rằng tám phần đầu đã hợp thành một bộ công cụ hoàn chỉnh.
9. Checklist React Query cho dự án thật
- Một
QueryClientở module scope,defaultOptionshợp lý (staleTime,retry). - Mọi response validate bằng zod trong
apiFetch; khôngany, khôngasthiếu kiểm. - Key factory mỗi feature;
queryOptions/infiniteQueryOptionscho mọi query. - Component xử lý đủ loading / error / empty / data.
- Mutation invalidate đúng phạm vi; optimistic chỉ ở chỗ cần phản hồi tức thì (đủ cancel/snapshot/rollback/settled).
-
retrykhông thử lại lỗi 4xx; lỗi nghiêm trọng đẩy lên Error Boundary. - Prefetch (hover/loader) cho luồng điều hướng quan trọng.
- Test bằng MSW ở tầng network, client riêng mỗi test với
retry: false.
10. Gotchas thường gặp
| Triệu chứng | Nguyên nhân | Cách sửa |
|---|---|---|
| Test pass/fail tuỳ thứ tự chạy | Dùng chung một QueryClient cho mọi test → rò rỉ cache | Client mới mỗi test (createTestClient() trong helper) |
| Test “treo” rồi timeout ở ca lỗi | retry mặc định 3 lần + backoff làm chậm tới error | Đặt retry: false ở cả queries và mutations |
getByText('An') ném “không tìm thấy” | Quên await — data async chưa render | Dùng await screen.findByText('An') thay getByText |
act(...) warning đỏ console | State update sau khi test xong (fetch về muộn) | await mọi assertion async; waitFor cho điều kiện cuối |
onUnhandledRequest báo lỗi URL lạ | Component gọi endpoint chưa mock | Thêm handler cho URL đó, hoặc đổi 'error'→'warn' nếu cố ý |
Override server.use “dính” sang test sau | Thiếu afterEach(() => server.resetHandlers()) | Luôn reset handler ở afterEach |
vi.useFakeTimers() làm waitFor đứng hình | Fake timers chặn cả timer của retry/waitFor | Tắt retry, await vi.advanceTimersByTimeAsync(ms), hoặc tránh fake timers khi không cần |
| Test debounce flaky | vi.useFakeTimers() nhưng không advance, hoặc dùng real setTimeout | vi.useFakeTimers() + await user.type + vi.advanceTimersByTimeAsync(300) |
Fake timers + retry là cặp đôi gây đau đầu.
waitFor, exponential backoff, vàuseDebouncedValueđều dựa trên timer. Khi bậtvi.useFakeTimers(), không có timer nào tự chạy — bạn phảiadvanceTimersByTimeAsync. Nếu đồng thời đểretrybật, mỗi lần retry lại thêm một timer phải đẩy thủ công. Quy tắc: trong test, tắt retry và chỉ bật fake timers cho đúng test cần kiểm soát thời gian (debounce, polling).
11. Recipes nhanh
Đợi nhiều phần tử cùng lúc:
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(2);
Đọc/ghi cache trực tiếp từ test (nhờ helper trả ra queryClient):
const { queryClient } = renderWithClient(<CustomersPage />);
await screen.findByText('An');
// Seed cache thủ công để dựng một trạng thái khó tái hiện qua UI:
queryClient.setQueryData(['customers', 'list'], { items: [], hasMore: false });
Test create → list tự cập nhật (invalidate refetch):
it('thêm customer rồi thấy trong list', async () => {
const user = userEvent.setup();
renderWithClient(<CustomersPage />);
await screen.findByText('An');
await user.type(screen.getByLabelText(/tên/i), 'Chi');
await user.type(screen.getByLabelText(/email/i), 'chi@example.com');
await user.click(screen.getByRole('button', { name: /thêm/i }));
// POST 201 → onSuccess invalidate → refetch list (giờ có 'Chi')
expect(await screen.findByText('Chi')).toBeInTheDocument();
});
Khẳng định KHÔNG có request thừa (debounce gom phím gõ):
let calls = 0;
server.use(http.get('*/customers', () => { calls++; return HttpResponse.json([]); }));
await user.type(screen.getByPlaceholderText(/tìm/i), 'abc'); // 3 phím
await waitFor(() => expect(screen.getByText(/không tìm thấy/i)).toBeInTheDocument());
expect(calls).toBeLessThanOrEqual(2); // chỉ fetch sau khi ngừng gõ, không phải mỗi phím
12. Bài tập
1. Vì sao nên mock bằng MSW ở tầng network thay vì mock useQuery hay apiFetch?
Lời giải
Mock useQuery/apiFetch là test “code giả” — bạn không kiểm chứng luồng thật (key, cache, trạng thái, retry). MSW chặn HTTP nên React Query chạy thật; test phản ánh đúng hành vi người dùng và không vỡ khi bạn refactor cách gọi API.
2. Vì sao mỗi test cần một QueryClient mới với retry: false?
Lời giải
Client riêng tránh rò rỉ cache giữa các test (test này thấy data của test kia). retry: false để test ca lỗi không phải chờ các lần retry + backoff, giúp test nhanh và xác định.
3. Trong capstone, isPlaceholderData được dùng để làm gì?
Lời giải
Báo rằng data đang hiển thị là của trang/lần tìm trước trong khi kết quả mới đang fetch nền. Dùng để làm mờ danh sách (opacity) và khoá nút phân trang, tránh nhấp nháy và double-fetch.
4. Trong test optimistic rollback, vì sao phải đăng ký server.use(...lỗi 500...) trước khi gọi user.click?
Lời giải
Vì onMutate chạy đồng bộ rồi mutation gọi mạng ngay sau click. Handler lỗi phải có mặt tại thời điểm request bay đi. Nếu server.use chạy sau click, có thể request đã khớp với handler gốc (thành công) trước khi override kịp đăng ký — test sẽ flaky.
5. findBy* khác waitFor thế nào? Khi nào chọn cái nào?
Lời giải
findBy* = getBy* + waitFor, chuyên để đợi một phần tử xuất hiện theo text/role. waitFor(fn) tổng quát hơn: lặp lại fn tới khi không ném — dùng cho assertion phức tạp (vd toHaveAttribute đổi giá trị, hoặc nhiều điều kiện). Ưu tiên findBy* cho “đợi thấy phần tử”; dùng waitFor khi cần đợi một trạng thái thay đổi trên phần tử đã có.
6. Vì sao { once: true } trên một handler lại hữu ích khi test luồng retry/refetch?
Lời giải
{ once: true } khiến handler chỉ khớp một lần rồi tự gỡ. Bạn đăng ký một handler 500 { once: true } đứng trước handler 200 gốc: request đầu nhận lỗi, request thứ hai (sau retry hoặc invalidate) rơi xuống handler thành công. Nhờ đó test được kịch bản “lỗi rồi tự hồi phục” mà không cần đếm thủ công.
Nâng cao: Viết test cho luồng tạo customer: render trang, gõ form, submit, và findBy* xác nhận customer mới xuất hiện sau khi invalidate refetch. Thêm một test cho debounced search (chỉ một request sau khi ngừng gõ) dùng vi.useFakeTimers() + vi.advanceTimersByTimeAsync(300).
Tóm tắt
- Test ở tầng network bằng MSW, không mock
useQuery/apiFetch— để React Query chạy thật thì test mới phản ánh hành vi người dùng và sống sót qua refactor. - Client mới mỗi test với
retry: false(vàgcTime: Infinity) là điều kiện tiên quyết: tránh rò rỉ cache giữa test, tránh chờ retry ở ca lỗi. findBy*/waitForcho data async,getBy*cho thứ đã có,queryBy*để khẳng định không tồn tại — chọn sai là nguồn flaky số một.- Override handler bằng
server.use(...)(reset ởafterEach);{ once: true }để test “lỗi rồi hồi phục”. - Test giá trị nhất là optimistic rollback: assert trạng thái lạc quan (đồng bộ) → assert rollback sau lỗi (
waitFor). - Capstone ghép Phần 1–8 thành CRUD thật: zod + key factory +
queryOptions(P3),keepPreviousData+ pagination (P4), mutation + invalidate (P5), optimistic delete + rollback (P6), bốn trạng thái + debounce (P2), retry/Error Boundary (P7). - Bạn đã đi từ “fetch tay bằng
useEffect” tới một feature CRUD production-ready có test — khép lại trọn vẹn nửa nền tảng của series.
Phần tiếp theo
Phần 9 — QueryClient & defaults sâu: mở nửa nâng cao của series. Ta mổ xẻ QueryClient từ trong ra: QueryCache/MutationCache, cơ chế defaultOptions (merge và thứ tự ưu tiên option), setQueryDefaults/getQueryDefaults theo từng key, staleTime vs gcTime ở tầng global, retry/retryDelay tuỳ biến, và cách thiết kế một bộ default chuẩn cho cả app — nền tảng cho mọi chủ đề production (SSR, prefetching, offline, realtime) ở các phần sau.