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 cuối của series 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: một capstone ghép tất cả 8 phần 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.
Đây là điểm dừng của series — sau bài này bạn đã có đủ công cụ để dùng React Query trong dự án thật.
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.
pnpm add -D vitest @testing-library/react @testing-library/user-event jsdom msw
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());
// 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, với retry: false để test lỗi không phải chờ retry, và gcTime: Infinity để cache không bị dọn giữa chừng.
// src/test/render.tsx
import { type ReactElement } from 'react';
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export function renderWithClient(ui: ReactElement) {
// Client RIÊNG mỗi test → không rò rỉ cache giữa các test.
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false }, // đừng retry trong test, sẽ làm chậm/khó đoán
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
}
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: dùng findBy* (hoặc waitFor) cho mọi thứ xuất hiện sau khi fetch xong. getBy* là đồng bộ, sẽ trượt vì lúc đó data chưa về.
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();
});
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 userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/server';
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'));
});
7. 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 series: queryOptions + zod (Phần 3), keepPreviousData + pagination (Phần 4), useMutation + invalidate (Phần 5), optimistic delete + rollback (Phần 6), debounced search và loading/error/empty đúng chuẩn (Phần 2). Đó là một feature production-ready thật sự.
8. 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.
9. 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.
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õ).
Tóm tắt & kết series
- Test React Query bằng MSW (mock network), client riêng mỗi test với
retry: false, vàfindBy*/waitForcho data async. Test giá trị nhất: optimistic rollback. - Capstone ghép toàn bộ: zod + key factory +
queryOptions(Phần 3), pagination vớikeepPreviousData(Phần 4), mutation + invalidate (Phần 5), optimistic delete (Phần 6), loading/error/empty + debounce (Phần 2). - Bạn đã đi từ “fetch tay bằng useEffect” tới một feature CRUD production-ready với cache, optimistic, error handling và test.
Cảm ơn bạn đã theo hết series. Quay lại Phần 1 bất cứ lúc nào để ôn mental model — và hãy mở Devtools khi code, vì “thấy cache” là cách học React Query nhanh nhất.