Testing is no longer optional. In 2026, with React applications powering everything from fintech dashboards to AI-driven consumer products, shipping untested code is a recipe for midnight incidents and lost user trust. Yet many developers still struggle with where to start, what to test, and how to mock the external world cleanly.
This guide walks you through the modern testing stack — Vitest, React Testing Library (RTL), and Mock Service Worker (MSW) — and shows you how to set up unit tests, integration tests, component tests, and realistic API mocking in a production-grade React project. Whether you are just getting started with testing or looking to level up your existing setup, this post has something for you.
Why This Stack in 2026?
Before diving into code, let's understand why these three tools have become the industry standard for React testing.
Vitest: The Speed Demon
Vitest emerged as the natural successor to Jest for Vite-based React projects. By 2026, it's become the default choice for most new React projects, and for good reason:
- Native ESM support — no transpilation gymnastics required
- Vite-powered — shares your app's configuration, so no duplicate setup
- Blazing fast — parallel test execution and smart caching make feedback loops near-instant
- Jest-compatible API — if you know Jest, you already know 90% of Vitest
- Built-in code coverage via V8 or Istanbul
React Testing Library: The Right Abstraction
React Testing Library (RTL) enforces a philosophy that has aged beautifully: test behavior, not implementation. Instead of poking at component internals, RTL queries the DOM the way a real user would — by roles, labels, and visible text. This leads to tests that are resilient to refactoring and actually catch real regressions.
Mock Service Worker: Mocking at the Network Layer
MSW intercepts HTTP requests at the service worker level (in the browser) or via Node.js interceptors (in tests). This means your tests make real fetch/axios calls — MSW just intercepts them before they hit the network. The result is an API mocking layer that is:
- Shared between tests, Storybook, and development
- Framework-agnostic — works with fetch, axios, ky, and any HTTP client
- Realistic — your code exercises the actual network path, not a shallow stub
Project Setup
Let's scaffold a fresh React + TypeScript project and wire everything together.
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
Install Testing Dependencies
# Vitest + DOM environment
npm install -D vitest @vitest/coverage-v8 jsdom
# React Testing Library
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
# MSW
npm install -D msw@latest
Configure Vitest
Open vite.config.ts and add the test configuration:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "lcov", "html"],
exclude: ["node_modules/", "src/test/"],
},
},
});
Create the Test Setup File
// src/test/setup.ts
import "@testing-library/jest-dom";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// Automatically unmount and clean up DOM after each test
afterEach(() => {
cleanup();
});
Update tsconfig.json
Make sure TypeScript knows about Vitest's global types:
{
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"]
}
}
Add the test script to package.json:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"coverage": "vitest run --coverage"
}
}
Unit Testing with Vitest
Unit tests are the foundation. They test individual functions or modules in complete isolation.
A Real-World Example: Utility Functions
Let's say you have a formatCurrency utility used across your app:
// src/utils/formatCurrency.ts
export function formatCurrency(
amount: number,
currency: string = "USD",
locale: string = "en-US"
): string {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(amount);
}
Here's how you test it:
// src/utils/formatCurrency.test.ts
import { describe, it, expect } from "vitest";
import { formatCurrency } from "./formatCurrency";
describe("formatCurrency", () => {
it("formats a number as USD by default", () => {
expect(formatCurrency(1234.5)).toBe("$1,234.50");
});
it("formats a number in EUR with German locale", () => {
const result = formatCurrency(1000, "EUR", "de-DE");
expect(result).toContain("1.000,00");
expect(result).toContain("€");
});
it("handles zero correctly", () => {
expect(formatCurrency(0)).toBe("$0.00");
});
it("handles negative values", () => {
expect(formatCurrency(-500)).toBe("-$500.00");
});
});
Run the tests:
npm test
Testing Custom Hooks
Custom hooks often contain non-trivial logic. Use RTL's renderHook to test them in isolation:
// src/hooks/useCounter.ts
import { useState } from "react";
export function useCounter(initialValue: number = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// src/hooks/useCounter.test.ts
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";
describe("useCounter", () => {
it("initializes with the provided value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increments the count", () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
it("decrements the count", () => {
const { result } = renderHook(() => useCounter(5));
act(() => result.current.decrement());
expect(result.current.count).toBe(4);
});
it("resets to initial value", () => {
const { result } = renderHook(() => useCounter(3));
act(() => result.current.increment());
act(() => result.current.reset());
expect(result.current.count).toBe(3);
});
});
Component Testing with React Testing Library
Component tests verify that UI pieces render correctly and respond to user interactions. RTL's query API makes this feel natural.
A Product Card Component
// src/components/ProductCard.tsx
type Product = {
id: number;
name: string;
price: number;
inStock: boolean;
};
type ProductCardProps = {
product: Product;
onAddToCart: (id: number) => void;
};
export function ProductCard({ product, onAddToCart }: ProductCardProps) {
return (
<article aria-label={product.name}>
<h2>{product.name}</h2>
<p>${product.price.toFixed(2)}</p>
{product.inStock ? (
<button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
) : (
<span>Out of Stock</span>
)}
</article>
);
}
// src/components/ProductCard.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProductCard } from "./ProductCard";
const mockProduct = {
id: 1,
name: "Mechanical Keyboard",
price: 149.99,
inStock: true,
};
describe("ProductCard", () => {
it("renders the product name and price", () => {
render(<ProductCard product={mockProduct} onAddToCart={() => {}} />);
expect(screen.getByText("Mechanical Keyboard")).toBeInTheDocument();
expect(screen.getByText("$149.99")).toBeInTheDocument();
});
it("calls onAddToCart with the product id when the button is clicked", async () => {
const user = userEvent.setup();
const handleAddToCart = vi.fn();
render(<ProductCard product={mockProduct} onAddToCart={handleAddToCart} />);
await user.click(screen.getByRole("button", { name: /add to cart/i }));
expect(handleAddToCart).toHaveBeenCalledOnce();
expect(handleAddToCart).toHaveBeenCalledWith(1);
});
it("shows out of stock message when product is unavailable", () => {
render(
<ProductCard
product={{ ...mockProduct, inStock: false }}
onAddToCart={() => {}}
/>
);
expect(screen.getByText("Out of Stock")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
Key RTL Queries to Know
| Query | Use When |
|---|---|
getByRole | Preferred — queries by ARIA role (button, heading, textbox) |
getByLabelText | For form inputs with associated labels |
getByText | For visible text content |
getByPlaceholderText | For inputs identified by placeholder |
queryBy* | Returns null if not found (for asserting absence) |
findBy* | Async — waits for element to appear |
Always prefer getByRole — it's the most accessible and robust query.
Mocking API Calls with MSW
This is where many testing setups fall apart. Using vi.mock or monkey-patching fetch is brittle and hides real bugs. MSW gives you a proper network layer.
Setting Up MSW Handlers
// src/test/mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/products", () => {
return HttpResponse.json([
{ id: 1, name: "Mechanical Keyboard", price: 149.99, inStock: true },
{ id: 2, name: "Wireless Mouse", price: 59.99, inStock: false },
]);
}),
http.post("/api/cart", async ({ request }) => {
const body = await request.json() as { productId: number };
return HttpResponse.json(
{ success: true, cartItemId: 42, productId: body.productId },
{ status: 201 }
);
}),
http.get("/api/products/:id", ({ params }) => {
const { id } = params;
if (id === "999") {
return HttpResponse.json({ message: "Not found" }, { status: 404 });
}
return HttpResponse.json({ id: Number(id), name: "Product", price: 99 });
}),
];
Configure the MSW Server for Tests
// src/test/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
Update src/test/setup.ts to start/stop the server:
// src/test/setup.ts
import "@testing-library/jest-dom";
import { afterAll, afterEach, beforeAll } from "vitest";
import { cleanup } from "@testing-library/react";
import { server } from "./mocks/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
Pro tip:
onUnhandledRequest: "error"will fail your test if a request is made to an endpoint you haven't mocked. This prevents silent failures where your component fetches data you forgot to intercept.
Integration Testing: Putting It All Together
Integration tests verify that multiple units work together correctly — a component that fetches data and renders it, for example.
A Product List Page
// src/pages/ProductList.tsx
import { useEffect, useState } from "react";
type Product = { id: number; name: string; price: number; inStock: boolean };
export function ProductList() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/products")
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch");
return res.json();
})
.then((data) => {
setProducts(data);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return <p>Loading products...</p>;
if (error) return <p role="alert">Error: {error}</p>;
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} — ${p.price.toFixed(2)}
{!p.inStock && <span> (Out of Stock)</span>}
</li>
))}
</ul>
);
}
// src/pages/ProductList.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "../test/mocks/server";
import { ProductList } from "./ProductList";
describe("ProductList", () => {
it("renders a loading state initially", () => {
render(<ProductList />);
expect(screen.getByText(/loading products/i)).toBeInTheDocument();
});
it("renders products returned by the API", async () => {
render(<ProductList />);
await waitFor(() => {
expect(screen.getByText(/mechanical keyboard/i)).toBeInTheDocument();
});
expect(screen.getByText(/wireless mouse/i)).toBeInTheDocument();
expect(screen.getByText(/\(Out of Stock\)/i)).toBeInTheDocument();
});
it("renders an error message when the API fails", async () => {
// Override the handler for this specific test
server.use(
http.get("/api/products", () => {
return HttpResponse.json(
{ message: "Server Error" },
{ status: 500 }
);
})
);
render(<ProductList />);
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/failed to fetch/i);
});
});
});
Notice how server.use() lets you override handlers for a single test without affecting others. server.resetHandlers() in afterEach cleans it up automatically.
Testing Forms and User Interactions
Forms are some of the most important things to test. @testing-library/user-event simulates real user behavior — typing, tabbing, clicking — much more accurately than fireEvent.
// src/components/LoginForm.tsx
import { useState } from "react";
type LoginFormProps = {
onSubmit: (email: string, password: string) => Promise<void>;
};
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
await onSubmit(email, password);
setSubmitting(false);
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit" disabled={submitting}>
{submitting ? "Signing in..." : "Sign In"}
</button>
</form>
);
}
// src/components/LoginForm.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
describe("LoginForm", () => {
it("calls onSubmit with email and password", async () => {
const user = userEvent.setup();
const mockSubmit = vi.fn().mockResolvedValue(undefined);
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/password/i), "secret123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(mockSubmit).toHaveBeenCalledWith("test@example.com", "secret123");
});
it("disables the button while submitting", async () => {
const user = userEvent.setup();
// onSubmit never resolves during the test
const mockSubmit = vi.fn(() => new Promise(() => {}));
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/password/i), "secret123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(screen.getByRole("button", { name: /signing in/i })).toBeDisabled();
});
});
Best Practices
1. Follow the Testing Trophy
The modern testing philosophy (popularized by Kent C. Dodds) suggests:
- Static analysis — TypeScript, ESLint (free, always on)
- Unit tests — pure functions, hooks, utilities
- Integration tests — components + API + state (the sweet spot)
- E2E tests — critical user journeys only (Playwright for these)
Most of your value comes from integration tests. Don't over-invest in unit testing implementation details.
2. Query by Accessibility
Always prefer accessible queries:
// ✅ Good — tests what users actually interact with
screen.getByRole("button", { name: /submit/i });
screen.getByLabelText(/email address/i);
// ❌ Bad — brittle, tied to implementation
screen.getByClassName("submit-btn");
container.querySelector("#submit");
3. Avoid act() Warnings Explicitly
RTL's waitFor, findBy*, and userEvent handle act() wrapping internally. Manual act() calls are usually a code smell.
4. Keep Tests Isolated
Each test should be fully independent — no shared mutable state between tests. MSW's server.resetHandlers() and RTL's cleanup (called automatically in modern setups) handle this for you.
5. Test Error Boundaries and Edge Cases
it("handles empty product list gracefully", async () => {
server.use(
http.get("/api/products", () => HttpResponse.json([]))
);
render(<ProductList />);
await waitFor(() => {
expect(screen.getByText(/no products available/i)).toBeInTheDocument();
});
});
Common Mistakes to Avoid
❌ Testing Implementation Details
// Bad — if you rename the state variable, this breaks
expect(component.state("isLoading")).toBe(false);
// Good — test what the user sees
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
❌ Using fireEvent Instead of userEvent
fireEvent.click() fires a single DOM event. Real users generate a cascade of events: pointerover, mouseover, pointermove, mouseenter, mousemove, pointerdown, mousedown, focus, pointerup, mouseup, click. userEvent simulates all of them.
// ❌ Misses many real-world edge cases
fireEvent.click(button);
// ✅ Realistic user simulation
await user.click(button);
❌ Forgetting to await Async Operations
// ❌ This may pass before the API call completes
render(<ProductList />);
expect(screen.getByText("Keyboard")).toBeInTheDocument(); // Fails!
// ✅ Wait for the element to appear
await waitFor(() => {
expect(screen.getByText("Keyboard")).toBeInTheDocument();
});
// Or use findBy (already async)
expect(await screen.findByText("Keyboard")).toBeInTheDocument();
❌ Mocking fetch Directly
// ❌ Brittle, doesn't test the real HTTP path
global.fetch = vi.fn().mockResolvedValue({ json: () => [] });
// ✅ Use MSW — intercepted at the network layer
server.use(http.get("/api/products", () => HttpResponse.json([])));
❌ One Massive describe Block
Keep tests focused. Each describe block should cover one component or behavior. Test file = one component/module. This keeps failures easy to locate.
🚀 Pro Tips
Tip 1: Use screen.debug() When Stuck
When a query fails and you don't know why, screen.debug() prints the current DOM to the console. It's your best friend during debugging.
Tip 2: Generate Coverage Reports Locally
npm run coverage
# Then open coverage/index.html in your browser
This shows exactly which lines and branches are untested — use it as a map, not a goal. 80% coverage with the right tests beats 100% with trivial ones.
Tip 3: Reuse MSW Handlers in Storybook and Dev
MSW handlers are just plain objects. Create a shared src/mocks/handlers.ts and import it in your test setup, Storybook decorators, and dev server middleware. One source of truth for all mock data.
Tip 4: Use vi.spyOn for Partial Mocks
When you only want to mock one method of a module:
import * as api from "../api";
const spy = vi.spyOn(api, "fetchUser").mockResolvedValue({ id: 1 });
Tip 5: Snapshot Tests Are for UI Regressions, Not Logic
Inline snapshots (toMatchInlineSnapshot) are fine for small, stable UI pieces. Avoid large component tree snapshots — they break constantly and provide little signal.
Tip 6: Run Tests in Watch Mode During Development
npm test
# Vitest starts in watch mode by default — it only re-runs affected tests
Tip 7: Use the Vitest UI for Visual Debugging
npm run test:ui
# Opens a browser UI showing test results, coverage, and module graphs
📌 Key Takeaways
- Vitest is the fastest, most ergonomic test runner for Vite-based React apps in 2026 — use it for everything.
- React Testing Library keeps you honest by forcing you to test from the user's perspective. Always query by role and label, not by class or ID.
- MSW is the gold standard for API mocking — it intercepts at the network layer so your code never knows it's being tested.
- Integration tests give you the best ROI. Favor tests that exercise a slice of your app — component + hook + API — over purely isolated unit tests.
userEventoverfireEvent— always simulate real user interactions.server.use()lets you override handlers per test for error and edge case scenarios without breaking other tests.- Async is everywhere — always use
waitFor,findBy*, orawait user.*for anything involving state changes or network calls. - Great tests are readable and isolated. Each test should read like a user story: given, when, then.
Wrapping Up
The testing landscape in 2026 has never been more mature or developer-friendly. Vitest's speed removes the friction of slow test suites. RTL's philosophy keeps your tests aligned with what users actually care about. And MSW closes the gap between mocked and real API behavior.
The key shift in mindset is this: write tests for behavior, not implementation. Ask yourself — "if I refactor this component tomorrow, will my tests still be valid?" If yes, you're writing good tests.
Start small. Add tests to the components and utilities you're shipping today. Build the habit. Over time, your test suite becomes the safety net that lets your team move faster, not slower.
Happy testing. 🧪
References
- Vitest Documentation — Official Vitest docs and configuration reference
- React Testing Library — Full API reference and guiding principles
- Mock Service Worker (MSW) — Setup, handlers, and advanced patterns
- Testing Library — Which Query to Use — Priority guide for accessible queries
- Kent C. Dodds — Write Tests, Not Too Many, Mostly Integration — The philosophy behind the testing trophy
- Vitest Coverage — Setting up V8 and Istanbul coverage providers
- MSW + Storybook Integration — Sharing handlers across test and development environments