Integration Testing
Integration testing verifies that different units of code work together correctly. While unit tests isolate individual functions, integration tests exercise real interactions between components, API calls, data stores, and user flows.
This guide covers mocking HTTP requests with MSW (Mock Service Worker), testing data flow with React Query and Redux, testing forms and user flows, and testing authentication flows including redirects and token management.
MSW intercepts network requests at the service worker level (browser) or via Node.js adapter (tests). It lets you write tests that make real fetch/axios calls without mocking the HTTP library itself.
| 1 | # Install MSW |
| 2 | npm install -D msw |
| 3 | |
| 4 | # Initialize service worker (browser usage only) |
| 5 | npx msw init public/ |
| 1 | // src/mocks/handlers.ts |
| 2 | import { http, HttpResponse } from "msw"; |
| 3 | |
| 4 | export const handlers = [ |
| 5 | // GET /api/users |
| 6 | http.get("https://api.example.com/users", () => { |
| 7 | return HttpResponse.json([ |
| 8 | { id: 1, name: "Alice", email: "alice@example.com" }, |
| 9 | { id: 2, name: "Bob", email: "bob@example.com" }, |
| 10 | ]); |
| 11 | }), |
| 12 | |
| 13 | // GET /api/users/:id — dynamic path |
| 14 | http.get("https://api.example.com/users/:id", ({ params }) => { |
| 15 | const { id } = params; |
| 16 | |
| 17 | return HttpResponse.json({ |
| 18 | id: Number(id), |
| 19 | name: "Alice", |
| 20 | email: "alice@example.com", |
| 21 | }); |
| 22 | }), |
| 23 | |
| 24 | // POST /api/users — with request body |
| 25 | http.post("https://api.example.com/users", async ({ request }) => { |
| 26 | const body = (await request.json()) as { name: string; email: string }; |
| 27 | |
| 28 | return HttpResponse.json( |
| 29 | { id: 3, name: body.name, email: body.email }, |
| 30 | { status: 201 } |
| 31 | ); |
| 32 | }), |
| 33 | |
| 34 | // Simulate errors |
| 35 | http.get("https://api.example.com/protected", () => { |
| 36 | return new HttpResponse(null, { status: 401 }); |
| 37 | }), |
| 38 | |
| 39 | // Network delay simulation |
| 40 | http.get("https://api.example.com/slow", async () => { |
| 41 | await new Promise((resolve) => setTimeout(resolve, 2000)); |
| 42 | |
| 43 | return HttpResponse.json({ status: "delayed response" }); |
| 44 | }), |
| 45 | ]; |
| 1 | // src/mocks/server.ts |
| 2 | import { setupServer } from "msw/node"; |
| 3 | import { handlers } from "./handlers"; |
| 4 | |
| 5 | export const server = setupServer(...handlers); |
| 6 | |
| 7 | // src/test-setup.ts (Vitest) |
| 8 | import { server } from "./mocks/server"; |
| 9 | |
| 10 | beforeAll(() => server.listen({ onUnhandledRequest: "warn" })); |
| 11 | afterEach(() => server.resetHandlers()); |
| 12 | afterAll(() => server.close()); |
| 13 | |
| 14 | // For specific test overrides: |
| 15 | // server.use( |
| 16 | // http.get("https://api.example.com/users", () => { |
| 17 | // return HttpResponse.json([{ id: 99, name: "Override" }]); |
| 18 | // }) |
| 19 | // ); |
info
Integration testing with React Query involves wrapping components in QueryClientProvider and using MSW to provide mock API responses.
| 1 | import { render, screen, waitFor } from "@testing-library/react"; |
| 2 | import userEvent from "@testing-library/user-event"; |
| 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
| 4 | import { http, HttpResponse } from "msw"; |
| 5 | import { server } from "../mocks/server"; |
| 6 | import UserList from "./UserList"; |
| 7 | |
| 8 | function createTestQueryClient() { |
| 9 | return new QueryClient({ |
| 10 | defaultOptions: { |
| 11 | queries: { |
| 12 | retry: false, |
| 13 | gcTime: 0, |
| 14 | }, |
| 15 | }, |
| 16 | }); |
| 17 | } |
| 18 | |
| 19 | function renderWithClient(ui: React.ReactElement) { |
| 20 | const queryClient = createTestQueryClient(); |
| 21 | return render( |
| 22 | <QueryClientProvider client={queryClient}> |
| 23 | {ui} |
| 24 | </QueryClientProvider> |
| 25 | ); |
| 26 | } |
| 27 | |
| 28 | describe("UserList with React Query", () => { |
| 29 | it("shows loading state", () => { |
| 30 | renderWithClient(<UserList />); |
| 31 | |
| 32 | expect(screen.getByText(/loading/i)).toBeInTheDocument(); |
| 33 | }); |
| 34 | |
| 35 | it("renders user list from API", async () => { |
| 36 | renderWithClient(<UserList />); |
| 37 | |
| 38 | await waitFor(() => { |
| 39 | expect(screen.getByText("Alice")).toBeInTheDocument(); |
| 40 | }); |
| 41 | expect(screen.getByText("Bob")).toBeInTheDocument(); |
| 42 | }); |
| 43 | |
| 44 | it("shows error state when API fails", async () => { |
| 45 | // Override handler for this test |
| 46 | server.use( |
| 47 | http.get("https://api.example.com/users", () => { |
| 48 | return new HttpResponse(null, { status: 500 }); |
| 49 | }) |
| 50 | ); |
| 51 | |
| 52 | renderWithClient(<UserList />); |
| 53 | |
| 54 | await waitFor(() => { |
| 55 | expect( |
| 56 | screen.getByText(/failed to load/i) |
| 57 | ).toBeInTheDocument(); |
| 58 | }); |
| 59 | }); |
| 60 | |
| 61 | it("refetches on button click", async () => { |
| 62 | const fetchSpy = vi.fn(); |
| 63 | server.use( |
| 64 | http.get("https://api.example.com/users", () => { |
| 65 | fetchSpy(); |
| 66 | return HttpResponse.json([ |
| 67 | { id: 1, name: "Alice", email: "a@example.com" }, |
| 68 | ]); |
| 69 | }) |
| 70 | ); |
| 71 | |
| 72 | renderWithClient(<UserList />); |
| 73 | |
| 74 | await screen.findByText("Alice"); |
| 75 | |
| 76 | const refreshBtn = screen.getByRole("button", { name: /refresh/i }); |
| 77 | await userEvent.click(refreshBtn); |
| 78 | |
| 79 | await waitFor(() => { |
| 80 | expect(fetchSpy).toHaveBeenCalledTimes(2); |
| 81 | }); |
| 82 | }); |
| 83 | |
| 84 | it("mutates data on form submission", async () => { |
| 85 | const mutationSpy = vi.fn(); |
| 86 | server.use( |
| 87 | http.post("https://api.example.com/users", async ({ request }) => { |
| 88 | mutationSpy(await request.json()); |
| 89 | return HttpResponse.json( |
| 90 | { id: 3, name: "Charlie", email: "c@example.com" }, |
| 91 | { status: 201 } |
| 92 | ); |
| 93 | }) |
| 94 | ); |
| 95 | |
| 96 | renderWithClient(<UserList />); |
| 97 | |
| 98 | await userEvent.type(screen.getByLabelText(/name/i), "Charlie"); |
| 99 | await userEvent.type(screen.getByLabelText(/email/i), "c@example.com"); |
| 100 | await userEvent.click(screen.getByRole("button", { name: /add user/i })); |
| 101 | |
| 102 | await waitFor(() => { |
| 103 | expect(mutationSpy).toHaveBeenCalledWith({ |
| 104 | name: "Charlie", |
| 105 | email: "c@example.com", |
| 106 | }); |
| 107 | }); |
| 108 | }); |
| 109 | }); |
best practice
Integration testing with Redux involves rendering components inside a Provider with a configured store and testing interaction between dispatching actions and UI updates.
| 1 | import { render, screen, waitFor } from "@testing-library/react"; |
| 2 | import userEvent from "@testing-library/user-event"; |
| 3 | import { Provider } from "react-redux"; |
| 4 | import { configureStore } from "@reduxjs/toolkit"; |
| 5 | import { http, HttpResponse } from "msw"; |
| 6 | import { server } from "../mocks/server"; |
| 7 | import cartReducer from "../store/cartSlice"; |
| 8 | import CartPage from "./CartPage"; |
| 9 | |
| 10 | function createTestStore(initialState?: any) { |
| 11 | return configureStore({ |
| 12 | reducer: { |
| 13 | cart: cartReducer, |
| 14 | }, |
| 15 | preloadedState: initialState, |
| 16 | }); |
| 17 | } |
| 18 | |
| 19 | function renderWithStore( |
| 20 | ui: React.ReactElement, |
| 21 | initialState?: any |
| 22 | ) { |
| 23 | const store = createTestStore(initialState); |
| 24 | return render(<Provider store={store}>{ui}</Provider>); |
| 25 | } |
| 26 | |
| 27 | describe("CartPage with Redux", () => { |
| 28 | it("shows empty cart message", () => { |
| 29 | renderWithStore(<CartPage />, { |
| 30 | cart: { items: [], total: 0 }, |
| 31 | }); |
| 32 | |
| 33 | expect( |
| 34 | screen.getByText(/your cart is empty/i) |
| 35 | ).toBeInTheDocument(); |
| 36 | }); |
| 37 | |
| 38 | it("renders cart items from Redux state", () => { |
| 39 | renderWithStore(<CartPage />, { |
| 40 | cart: { |
| 41 | items: [ |
| 42 | { id: 1, name: "Widget", price: 19.99, quantity: 2 }, |
| 43 | { id: 2, name: "Gadget", price: 29.99, quantity: 1 }, |
| 44 | ], |
| 45 | total: 69.97, |
| 46 | }, |
| 47 | }); |
| 48 | |
| 49 | expect(screen.getByText("Widget")).toBeInTheDocument(); |
| 50 | expect(screen.getByText("Gadget")).toBeInTheDocument(); |
| 51 | expect(screen.getByText(/$69.97/)).toBeInTheDocument(); |
| 52 | }); |
| 53 | |
| 54 | it("dispatches remove action", async () => { |
| 55 | const user = userEvent.setup(); |
| 56 | |
| 57 | renderWithStore(<CartPage />, { |
| 58 | cart: { |
| 59 | items: [ |
| 60 | { id: 1, name: "Widget", price: 19.99, quantity: 1 }, |
| 61 | ], |
| 62 | total: 19.99, |
| 63 | }, |
| 64 | }); |
| 65 | |
| 66 | await user.click( |
| 67 | screen.getByRole("button", { name: /remove/i }) |
| 68 | ); |
| 69 | |
| 70 | await waitFor(() => { |
| 71 | expect( |
| 72 | screen.queryByText("Widget") |
| 73 | ).not.toBeInTheDocument(); |
| 74 | }); |
| 75 | }); |
| 76 | |
| 77 | it("dispatches async fetchCart thunk", async () => { |
| 78 | server.use( |
| 79 | http.get("https://api.example.com/cart", () => { |
| 80 | return HttpResponse.json({ |
| 81 | items: [ |
| 82 | { id: 1, name: "Widget", price: 19.99, quantity: 2 }, |
| 83 | ], |
| 84 | total: 39.98, |
| 85 | }); |
| 86 | }) |
| 87 | ); |
| 88 | |
| 89 | renderWithStore(<CartPage />, { |
| 90 | cart: { items: [], total: 0 }, |
| 91 | }); |
| 92 | |
| 93 | await userEvent.click( |
| 94 | screen.getByRole("button", { name: /load cart/i }) |
| 95 | ); |
| 96 | |
| 97 | await waitFor(() => { |
| 98 | expect(screen.getByText("Widget")).toBeInTheDocument(); |
| 99 | expect(screen.getByText(/$39.98/)).toBeInTheDocument(); |
| 100 | }); |
| 101 | }); |
| 102 | }); |
pro tip
Forms are the primary way users interact with applications. Integration tests should exercise the full flow: filling fields, submitting, validation, and success/error feedback.
| 1 | import { render, screen, waitFor } from "@testing-library/react"; |
| 2 | import userEvent from "@testing-library/user-event"; |
| 3 | import { http, HttpResponse } from "msw"; |
| 4 | import { server } from "../mocks/server"; |
| 5 | import CheckoutForm from "./CheckoutForm"; |
| 6 | |
| 7 | describe("CheckoutForm", () => { |
| 8 | it("validates required fields on submit", async () => { |
| 9 | const user = userEvent.setup(); |
| 10 | render(<CheckoutForm />); |
| 11 | |
| 12 | await user.click(screen.getByRole("button", { name: /place order/i })); |
| 13 | |
| 14 | await waitFor(() => { |
| 15 | expect( |
| 16 | screen.getByText(/name is required/i) |
| 17 | ).toBeInTheDocument(); |
| 18 | expect( |
| 19 | screen.getByText(/email is required/i) |
| 20 | ).toBeInTheDocument(); |
| 21 | expect( |
| 22 | screen.getByText(/address is required/i) |
| 23 | ).toBeInTheDocument(); |
| 24 | }); |
| 25 | }); |
| 26 | |
| 27 | it("shows field-level validation on blur", async () => { |
| 28 | const user = userEvent.setup(); |
| 29 | render(<CheckoutForm />); |
| 30 | |
| 31 | const emailInput = screen.getByLabelText(/email/i); |
| 32 | await user.type(emailInput, "invalid-email"); |
| 33 | await user.tab(); // blur |
| 34 | |
| 35 | await waitFor(() => { |
| 36 | expect( |
| 37 | screen.getByText(/invalid email format/i) |
| 38 | ).toBeInTheDocument(); |
| 39 | }); |
| 40 | }); |
| 41 | |
| 42 | it("submits form successfully", async () => { |
| 43 | const user = userEvent.setup(); |
| 44 | |
| 45 | server.use( |
| 46 | http.post("https://api.example.com/orders", () => { |
| 47 | return HttpResponse.json( |
| 48 | { orderId: "ORD-123", status: "confirmed" }, |
| 49 | { status: 201 } |
| 50 | ); |
| 51 | }) |
| 52 | ); |
| 53 | |
| 54 | render(<CheckoutForm />); |
| 55 | |
| 56 | await user.type(screen.getByLabelText(/name/i), "Alice"); |
| 57 | await user.type(screen.getByLabelText(/email/i), "alice@example.com"); |
| 58 | await user.type(screen.getByLabelText(/address/i), "123 Main St"); |
| 59 | await user.type(screen.getByLabelText(/city/i), "New York"); |
| 60 | await user.type(screen.getByLabelText(/zip/i), "10001"); |
| 61 | |
| 62 | await user.click(screen.getByRole("button", { name: /place order/i })); |
| 63 | |
| 64 | await waitFor(() => { |
| 65 | expect( |
| 66 | screen.getByText(/order confirmed/i) |
| 67 | ).toBeInTheDocument(); |
| 68 | expect( |
| 69 | screen.getByText(/ORD-123/i) |
| 70 | ).toBeInTheDocument(); |
| 71 | }); |
| 72 | }); |
| 73 | |
| 74 | it("handles server validation errors", async () => { |
| 75 | const user = userEvent.setup(); |
| 76 | |
| 77 | server.use( |
| 78 | http.post("https://api.example.com/orders", () => { |
| 79 | return HttpResponse.json( |
| 80 | { errors: { email: "Email already in use" } }, |
| 81 | { status: 422 } |
| 82 | ); |
| 83 | }) |
| 84 | ); |
| 85 | |
| 86 | render(<CheckoutForm />); |
| 87 | |
| 88 | await user.type(screen.getByLabelText(/name/i), "Alice"); |
| 89 | await user.type(screen.getByLabelText(/email/i), "existing@example.com"); |
| 90 | // Fill remaining required fields... |
| 91 | |
| 92 | await user.click(screen.getByRole("button", { name: /place order/i })); |
| 93 | |
| 94 | await waitFor(() => { |
| 95 | expect( |
| 96 | screen.getByText(/email already in use/i) |
| 97 | ).toBeInTheDocument(); |
| 98 | }); |
| 99 | }); |
| 100 | |
| 101 | it("multi-step wizard flow", async () => { |
| 102 | const user = userEvent.setup(); |
| 103 | render(<MultiStepCheckout />); |
| 104 | |
| 105 | // Step 1: Shipping |
| 106 | expect(screen.getByText(/shipping information/i)).toBeInTheDocument(); |
| 107 | await user.type(screen.getByLabelText(/address/i), "123 Main St"); |
| 108 | await user.click(screen.getByRole("button", { name: /next/i })); |
| 109 | |
| 110 | // Step 2: Payment |
| 111 | await waitFor(() => { |
| 112 | expect(screen.getByText(/payment details/i)).toBeInTheDocument(); |
| 113 | }); |
| 114 | await user.type(screen.getByLabelText(/card number/i), "4111111111111111"); |
| 115 | await user.click(screen.getByRole("button", { name: /next/i })); |
| 116 | |
| 117 | // Step 3: Review |
| 118 | await waitFor(() => { |
| 119 | expect(screen.getByText(/review order/i)).toBeInTheDocument(); |
| 120 | }); |
| 121 | await user.click(screen.getByRole("button", { name: /place order/i })); |
| 122 | |
| 123 | // Confirmation |
| 124 | await waitFor(() => { |
| 125 | expect( |
| 126 | screen.getByText(/order placed!/i) |
| 127 | ).toBeInTheDocument(); |
| 128 | }); |
| 129 | }); |
| 130 | }); |
best practice
Authentication flows involve login, token management, protected routes, and session expiration. Integration tests should verify the entire auth lifecycle.
| 1 | import { render, screen, waitFor } from "@testing-library/react"; |
| 2 | import userEvent from "@testing-library/user-event"; |
| 3 | import { MemoryRouter, Route, Routes } from "react-router-dom"; |
| 4 | import { http, HttpResponse } from "msw"; |
| 5 | import { server } from "../mocks/server"; |
| 6 | import LoginPage from "./LoginPage"; |
| 7 | import Dashboard from "./Dashboard"; |
| 8 | import ProtectedRoute from "./ProtectedRoute"; |
| 9 | |
| 10 | describe("Authentication flows", () => { |
| 11 | beforeEach(() => { |
| 12 | localStorage.clear(); |
| 13 | }); |
| 14 | |
| 15 | it("logs in successfully and stores token", async () => { |
| 16 | const user = userEvent.setup(); |
| 17 | |
| 18 | server.use( |
| 19 | http.post("https://api.example.com/auth/login", () => { |
| 20 | return HttpResponse.json({ |
| 21 | token: "mock-jwt-token-abc123", |
| 22 | user: { id: 1, name: "Alice", role: "admin" }, |
| 23 | }); |
| 24 | }) |
| 25 | ); |
| 26 | |
| 27 | render( |
| 28 | <MemoryRouter initialEntries={["/login"]}> |
| 29 | <Routes> |
| 30 | <Route path="/login" element={<LoginPage />} /> |
| 31 | <Route |
| 32 | path="/dashboard" |
| 33 | element={<ProtectedRoute><Dashboard /></ProtectedRoute>} |
| 34 | /> |
| 35 | </Routes> |
| 36 | </MemoryRouter> |
| 37 | ); |
| 38 | |
| 39 | await user.type(screen.getByLabelText(/email/i), "alice@example.com"); |
| 40 | await user.type(screen.getByLabelText(/password/i), "correct-password"); |
| 41 | await user.click(screen.getByRole("button", { name: /sign in/i })); |
| 42 | |
| 43 | await waitFor(() => { |
| 44 | expect(localStorage.getItem("auth_token")).toBe( |
| 45 | "mock-jwt-token-abc123" |
| 46 | ); |
| 47 | }); |
| 48 | |
| 49 | // Should redirect to dashboard |
| 50 | await waitFor(() => { |
| 51 | expect(screen.getByText(/welcome, alice/i)).toBeInTheDocument(); |
| 52 | }); |
| 53 | }); |
| 54 | |
| 55 | it("shows error on invalid credentials", async () => { |
| 56 | const user = userEvent.setup(); |
| 57 | |
| 58 | server.use( |
| 59 | http.post("https://api.example.com/auth/login", () => { |
| 60 | return new HttpResponse( |
| 61 | JSON.stringify({ message: "Invalid email or password" }), |
| 62 | { status: 401 } |
| 63 | ); |
| 64 | }) |
| 65 | ); |
| 66 | |
| 67 | render( |
| 68 | <MemoryRouter initialEntries={["/login"]}> |
| 69 | <Routes> |
| 70 | <Route path="/login" element={<LoginPage />} /> |
| 71 | </Routes> |
| 72 | </MemoryRouter> |
| 73 | ); |
| 74 | |
| 75 | await user.type(screen.getByLabelText(/email/i), "wrong@example.com"); |
| 76 | await user.type(screen.getByLabelText(/password/i), "wrong-password"); |
| 77 | await user.click(screen.getByRole("button", { name: /sign in/i })); |
| 78 | |
| 79 | await waitFor(() => { |
| 80 | expect( |
| 81 | screen.getByText(/invalid email or password/i) |
| 82 | ).toBeInTheDocument(); |
| 83 | }); |
| 84 | |
| 85 | // Should NOT redirect |
| 86 | expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); |
| 87 | }); |
| 88 | |
| 89 | it("redirects unauthenticated user to login", () => { |
| 90 | localStorage.removeItem("auth_token"); |
| 91 | |
| 92 | render( |
| 93 | <MemoryRouter initialEntries={["/dashboard"]}> |
| 94 | <Routes> |
| 95 | <Route path="/login" element={<LoginPage />} /> |
| 96 | <Route |
| 97 | path="/dashboard" |
| 98 | element={<ProtectedRoute><Dashboard /></ProtectedRoute>} |
| 99 | /> |
| 100 | </Routes> |
| 101 | </MemoryRouter> |
| 102 | ); |
| 103 | |
| 104 | // Should redirect to login |
| 105 | expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); |
| 106 | expect(screen.queryByText(/dashboard/i)).not.toBeInTheDocument(); |
| 107 | }); |
| 108 | |
| 109 | it("handles token expiration gracefully", async () => { |
| 110 | const user = userEvent.setup(); |
| 111 | |
| 112 | server.use( |
| 113 | http.get("https://api.example.com/protected-data", () => { |
| 114 | return new HttpResponse(null, { status: 401 }); |
| 115 | }) |
| 116 | ); |
| 117 | |
| 118 | // Start with an expired token |
| 119 | localStorage.setItem("auth_token", "expired-token"); |
| 120 | |
| 121 | render( |
| 122 | <MemoryRouter initialEntries={["/dashboard"]}> |
| 123 | <Routes> |
| 124 | <Route path="/login" element={<LoginPage />} /> |
| 125 | <Route |
| 126 | path="/dashboard" |
| 127 | element={<ProtectedRoute><Dashboard /></ProtectedRoute>} |
| 128 | /> |
| 129 | </Routes> |
| 130 | </MemoryRouter> |
| 131 | ); |
| 132 | |
| 133 | // Should detect expired token and redirect to login |
| 134 | await waitFor(() => { |
| 135 | expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); |
| 136 | }); |
| 137 | }); |
| 138 | |
| 139 | it("supports logout flow", async () => { |
| 140 | const user = userEvent.setup(); |
| 141 | localStorage.setItem("auth_token", "valid-token"); |
| 142 | |
| 143 | render( |
| 144 | <MemoryRouter initialEntries={["/dashboard"]}> |
| 145 | <Routes> |
| 146 | <Route path="/login" element={<LoginPage />} /> |
| 147 | <Route |
| 148 | path="/dashboard" |
| 149 | element={<ProtectedRoute><Dashboard /></ProtectedRoute>} |
| 150 | /> |
| 151 | </Routes> |
| 152 | </MemoryRouter> |
| 153 | ); |
| 154 | |
| 155 | await screen.findByText(/dashboard/i); |
| 156 | |
| 157 | await user.click(screen.getByRole("button", { name: /logout/i })); |
| 158 | |
| 159 | await waitFor(() => { |
| 160 | expect(localStorage.getItem("auth_token")).toBeNull(); |
| 161 | expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); |
| 162 | }); |
| 163 | }); |
| 164 | }); |
warning
Common patterns for using MSW effectively in integration tests.
| Pattern | Description | Example |
|---|---|---|
| Dynamic handlers | Use factory functions for response data | http.get(path, ({ params }) => ...) |
| Passthrough | Bypass MSW for specific requests | server.use(http.get(...).passthrough()) |
| Request assertion | Capture request details for assertions | Wrap handler body with spy function |
| Network error | Simulate connection failure | return HttpResponse.error() |
| Delay / Timeout | Test loading states and timeouts | await delay(2000) inside handler |
| Per-test override | Override a handler for a single test | server.use(http.get(...)) in test body |
| 1 | // Factory pattern for reusable handler data |
| 2 | function createUser(overrides = {}) { |
| 3 | return { |
| 4 | id: 1, |
| 5 | name: "Default Name", |
| 6 | email: "default@example.com", |
| 7 | role: "user", |
| 8 | ...overrides, |
| 9 | }; |
| 10 | } |
| 11 | |
| 12 | // Handler with factory |
| 13 | http.get("https://api.example.com/users/:id", ({ params }) => { |
| 14 | return HttpResponse.json( |
| 15 | createUser({ id: Number(params.id), name: "Dynamic User" }) |
| 16 | ); |
| 17 | }); |
| 18 | |
| 19 | // Testing network errors |
| 20 | it("handles network failure", async () => { |
| 21 | server.use( |
| 22 | http.get("https://api.example.com/users", () => { |
| 23 | return HttpResponse.error(); |
| 24 | }) |
| 25 | ); |
| 26 | |
| 27 | renderWithClient(<UserList />); |
| 28 | |
| 29 | await waitFor(() => { |
| 30 | expect( |
| 31 | screen.getByText(/network error/i) |
| 32 | ).toBeInTheDocument(); |
| 33 | }); |
| 34 | }); |
| 35 | |
| 36 | // Testing race conditions |
| 37 | it("handles rapid re-renders", async () => { |
| 38 | let resolvePromise: (value: any) => void; |
| 39 | const promise = new Promise((resolve) => { |
| 40 | resolvePromise = resolve; |
| 41 | }); |
| 42 | |
| 43 | server.use( |
| 44 | http.get("https://api.example.com/users", async () => { |
| 45 | await promise; |
| 46 | return HttpResponse.json([]); |
| 47 | }) |
| 48 | ); |
| 49 | |
| 50 | const { rerender } = renderWithClient(<UserList query="" />); |
| 51 | |
| 52 | // Trigger re-render with new query before response arrives |
| 53 | rerender(<UserList query="alice" />); |
| 54 | |
| 55 | // Resolve the delayed response |
| 56 | resolvePromise!([]); |
| 57 | |
| 58 | await waitFor(() => { |
| 59 | // Should use latest query, not the first one |
| 60 | expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); |
| 61 | }); |
| 62 | }); |
info