Skip to main content
Back to Blog
ReactVitestTestingReact Testing LibraryMSWFrontendJavaScriptTypeScriptUnit TestingIntegration Testing

Testing React Apps in 2026: Vitest, React Testing Library, MSW

A comprehensive guide to setting up unit, integration, and component tests in React using Vitest, React Testing Library, and Mock Service Worker (MSW) — with real-world examples, best practices, and pro tips for modern frontend development.

May 2, 202615 min readNiraj Kumar

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

QueryUse When
getByRolePreferred — queries by ARIA role (button, heading, textbox)
getByLabelTextFor form inputs with associated labels
getByTextFor visible text content
getByPlaceholderTextFor 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.
  • userEvent over fireEvent — 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*, or await 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

All Articles
ReactVitestTestingReact Testing LibraryMSWFrontendJavaScriptTypeScriptUnit TestingIntegration Testing

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.