|$ curl https://forge-ai.dev/api/markdown?path=docs/testing/integration
$cat docs/integration-testing.md
updated Recently·35 min read·published

Integration Testing

TestingIntegrationIntermediate
Introduction

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 — Mock Service Worker

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.

terminal
Bash
1# Install MSW
2npm install -D msw
3
4# Initialize service worker (browser usage only)
5npx msw init public/
handlers.ts
TypeScript
1// src/mocks/handlers.ts
2import { http, HttpResponse } from "msw";
3
4export 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];
server-setup.ts
TypeScript
1// src/mocks/server.ts
2import { setupServer } from "msw/node";
3import { handlers } from "./handlers";
4
5export const server = setupServer(...handlers);
6
7// src/test-setup.ts (Vitest)
8import { server } from "./mocks/server";
9
10beforeAll(() => server.listen({ onUnhandledRequest: "warn" }));
11afterEach(() => server.resetHandlers());
12afterAll(() => 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

MSW intercepts at the network level, not the module level. This means it works with fetch, axios, ky, or any other HTTP client without special configuration. Your code does not know it is being mocked — it just works.
Testing Data Flow — React Query

Integration testing with React Query involves wrapping components in QueryClientProvider and using MSW to provide mock API responses.

UserList.test.tsx
TSX
1import { render, screen, waitFor } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4import { http, HttpResponse } from "msw";
5import { server } from "../mocks/server";
6import UserList from "./UserList";
7
8function createTestQueryClient() {
9 return new QueryClient({
10 defaultOptions: {
11 queries: {
12 retry: false,
13 gcTime: 0,
14 },
15 },
16 });
17}
18
19function renderWithClient(ui: React.ReactElement) {
20 const queryClient = createTestQueryClient();
21 return render(
22 <QueryClientProvider client={queryClient}>
23 {ui}
24 </QueryClientProvider>
25 );
26}
27
28describe("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

Create a fresh QueryClient per test with retry: false and gcTime: 0. This prevents cached data from one test leaking into another and ensures retries do not cause test timeouts on error cases.
Testing Data Flow — Redux

Integration testing with Redux involves rendering components inside a Provider with a configured store and testing interaction between dispatching actions and UI updates.

CartPage.test.tsx
TSX
1import { render, screen, waitFor } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { Provider } from "react-redux";
4import { configureStore } from "@reduxjs/toolkit";
5import { http, HttpResponse } from "msw";
6import { server } from "../mocks/server";
7import cartReducer from "../store/cartSlice";
8import CartPage from "./CartPage";
9
10function createTestStore(initialState?: any) {
11 return configureStore({
12 reducer: {
13 cart: cartReducer,
14 },
15 preloadedState: initialState,
16 });
17}
18
19function renderWithStore(
20 ui: React.ReactElement,
21 initialState?: any
22) {
23 const store = createTestStore(initialState);
24 return render(<Provider store={store}>{ui}</Provider>);
25}
26
27describe("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

Create a createTestStore function that builds a fresh store for each test. Pass preloaded state to set up the exact Redux state you need. This makes tests deterministic without mocking the entire store shape.
Testing Forms & User Flows

Forms are the primary way users interact with applications. Integration tests should exercise the full flow: filling fields, submitting, validation, and success/error feedback.

CheckoutForm.test.tsx
TSX
1import { render, screen, waitFor } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { http, HttpResponse } from "msw";
4import { server } from "../mocks/server";
5import CheckoutForm from "./CheckoutForm";
6
7describe("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

Multi-step flows should be tested end-to-end within a single test. Do not test each step in isolation — the value of integration tests is verifying state transitions between steps. Use waitFor after each navigation action to wait for the next step to render.
Testing Auth Flows

Authentication flows involve login, token management, protected routes, and session expiration. Integration tests should verify the entire auth lifecycle.

auth.test.tsx
TSX
1import { render, screen, waitFor } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { MemoryRouter, Route, Routes } from "react-router-dom";
4import { http, HttpResponse } from "msw";
5import { server } from "../mocks/server";
6import LoginPage from "./LoginPage";
7import Dashboard from "./Dashboard";
8import ProtectedRoute from "./ProtectedRoute";
9
10describe("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

Always clear localStorage in beforeEach when testing auth flows. Auth state persisted from a previous test causes false positives. Also ensure MSW handlers are reset between tests to prevent stale auth endpoint mocks from leaking.
MSW Patterns & Pitfalls

Common patterns for using MSW effectively in integration tests.

PatternDescriptionExample
Dynamic handlersUse factory functions for response datahttp.get(path, ({ params }) => ...)
PassthroughBypass MSW for specific requestsserver.use(http.get(...).passthrough())
Request assertionCapture request details for assertionsWrap handler body with spy function
Network errorSimulate connection failurereturn HttpResponse.error()
Delay / TimeoutTest loading states and timeoutsawait delay(2000) inside handler
Per-test overrideOverride a handler for a single testserver.use(http.get(...)) in test body
msw-patterns.test.ts
TypeScript
1// Factory pattern for reusable handler data
2function 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
13http.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
20it("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
37it("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

The onUnhandledRequest option in server.listen() can be set to "warn" during development and "error" in CI. This ensures no real network requests slip through in test environments, which would make tests non-deterministic.
$Blueprint — Engineering Documentation·Section ID: INT-01·Revision: 1.0