jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 ở đâuBạn thực sự test gìĐánh giá
Mock useQueryComponent khi nhận sẵn data giả❌ Bỏ qua key, cache, status, retry — refactor là vỡ test
Mock apiFetch / module APIComponent + 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
VitestTest runner + expect + fake timers (chạy nhanh, cấu hình giống Vite)
@testing-library/reactrender, screen, waitFor, query findBy*/getBy*
@testing-library/user-eventGiả lập tương tác người dùng (click/type) sát thật hơn fireEvent
jsdomDOM giả trong Node để component render được
MSWChặ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:

HookKhi nào chạyVì sao bắt buộc
server.listen()Một lần trước toàn bộ testBậ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 testGỡ 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ộ testGỡ 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 testnew QueryClient() trong helperTránh rò rỉ cache — test này thấy data của test kia, gây flaky
queries.retryfalseTest ca lỗi không phải chờ 3 lần retry + backoff → nhanh & xác định
mutations.retryfalseNhư trên, cho mutation
gcTimeInfinity (tuỳ chọn)Cache không bị dọn giữa chừng trong lúc waitFor đang chờ
Logger lỗitắ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ỉ: QueryClient chứa một QueryCache sống suốt đời client. Nếu bạn export 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ấyDùng cho
getBy*Ném lỗi ngayThứ đã có trên DOM (trạng thái loading ban đầu)
queryBy*Trả nullKhẳng định một phần tử không tồn tại
findBy*Không (Promise)Retry tới timeout rồi némThứ xuất hiện sau khi fetch xong (data async)
waitFor(fn)Không (Promise)Retry fn tới khi không némAssertion 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: false cố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ái error — và waitFor có thể timeout trước. Với retry: false, lỗi đầu tiên là error ngay.


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 testKiểu assertionVì sao
Optimistic bật ngayđồng bộ, ngay sau await user.clickonMutate chạy trước khi request bay đi → DOM đã đổi
Rollback sau lỗiawait waitFor(...)Rollback xảy ra trong onError, sau khi response 500 về
Chốt cuối (tuỳ chọn)findBy* giá trị thậtonSettled invalidate → refetch; chỉ test nếu UX phụ thuộc

Đặt server.use(...) lỗi trước user.click là 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 handlerCú phápDùng khi
GET trả JSONhttp.get('*/path', () => HttpResponse.json(data))Happy path mặc định
POST đọc bodyhttp.post('*/path', async ({ request }) => { const b = await request.json(); ... })Test create/update, echo lại body
Đọc paramshttp.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ạngasync () => { await delay(200); return HttpResponse.json(...) }Test loading kéo dài / race
One-time overrideserver.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 khi resetHandlers dọ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, customersQuery là 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ộng customerKeys.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 capstoneCơ chế React QueryHọc ở
customersQuery(filters) + zodqueryOptions tái dùng + validate responsePhần 3
customerKeys factoryQuery-key có cấu trúc, invalidate theo tiền tốPhần 3
placeholderData: keepPreviousDataChuyển trang/search không nháyPhần 4
useCreateCustomer + invalidateQueriesMutation ghi → làm cũ list → refetchPhần 5
useDeleteCustomer (cancel/snapshot/rollback/settled)Optimistic delete + rollbackPhần 6
isPending/isError/isPlaceholderData UIBốn trạng thái + debouncePhần 2
Retry/Error Boundary cho lỗi nặngthrowOnError, retry thông minhPhầ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, defaultOptions hợp lý (staleTime, retry).
  • Mọi response validate bằng zod trong apiFetch; không any, không as thiếu kiểm.
  • Key factory mỗi feature; queryOptions/infiniteQueryOptions cho 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).
  • retry khô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ứngNguyên nhânCách sửa
Test pass/fail tuỳ thứ tự chạyDùng chung một QueryClient cho mọi test → rò rỉ cacheClient mới mỗi test (createTestClient() trong helper)
Test “treo” rồi timeout ở ca lỗiretry mặc định 3 lần + backoff làm chậm tới errorĐặt retry: false ở cả queriesmutations
getByText('An') ném “không tìm thấy”Quên await — data async chưa renderDùng await screen.findByText('An') thay getByText
act(...) warning đỏ consoleState 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 mockThêm handler cho URL đó, hoặc đổi 'error''warn' nếu cố ý
Override server.use “dính” sang test sauThiếu afterEach(() => server.resetHandlers())Luôn reset handler ở afterEach
vi.useFakeTimers() làm waitFor đứng hìnhFake timers chặn cả timer của retry/waitForTắt retry, await vi.advanceTimersByTimeAsync(ms), hoặc tránh fake timers khi không cần
Test debounce flakyvi.useFakeTimers() nhưng không advance, hoặc dùng real setTimeoutvi.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ật vi.useFakeTimers(), không có timer nào tự chạy — bạn phải advanceTimersByTimeAsync. Nếu đồng thời để retry bậ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

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*/waitFor cho 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.