Testing
Backend engineers are generally comfortable with testing. Unit tests for business logic, integration tests against a real database, end-to-end tests for critical user flows — the pyramid is familiar. Frontend testing has the same pyramid, but the boundaries between layers are less obvious, and the tooling is different enough to be disorienting.
The failure mode here is one of two extremes: testing implementation details (which makes tests brittle) or only testing via E2E (which is slow and hard to debug). The right approach mirrors what you do on the backend.
The Frontend Test Pyramid
Most of your test investment should be in integration tests — they give the best signal-to-cost ratio. Unit tests for pure logic. E2E for the flows that must never break.
Unit Tests with Vitest
Vitest is Vite-native, Jest-compatible, and fast. Use it for pure functions, custom hooks, and utilities:
// utils/format.ts
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 1) + "…";
}// utils/format.test.ts
import { describe, it, expect } from "vitest";
import { formatCurrency, truncate } from "./format";
describe("formatCurrency", () => {
it("formats USD amounts", () => {
expect(formatCurrency(1234.5)).toBe("$1,234.50");
});
it("formats non-USD currencies", () => {
expect(formatCurrency(1000, "EUR")).toMatch(/1,000/);
});
});
describe("truncate", () => {
it("returns the string unchanged if within limit", () => {
expect(truncate("hello", 10)).toBe("hello");
});
it("truncates with ellipsis at limit", () => {
expect(truncate("hello world", 8)).toBe("hello w…");
});
});Testing custom hooks with renderHook:
// hooks/useDebounce.test.ts
import { renderHook, act } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { useDebounce } from "./useDebounce";
describe("useDebounce", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("delays updating the value", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "initial", delay: 300 } }
);
expect(result.current).toBe("initial");
rerender({ value: "updated", delay: 300 });
expect(result.current).toBe("initial"); // not yet debounced
act(() => vi.advanceTimersByTime(300));
expect(result.current).toBe("updated");
});
});Integration Tests with React Testing Library + MSW
React Testing Library (RTL) tests components from the user's perspective — it renders into a real DOM (via jsdom) and queries by accessible roles and text, not by CSS classes or component internals.
Mock Service Worker (MSW) intercepts fetch calls at the network level and returns controlled responses — the equivalent of mocking your database layer in backend integration tests.
// mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: params.id,
name: "Priya Sharma",
email: "priya@example.com",
});
}),
http.get("/api/users/:id", () => {
return new HttpResponse(null, { status: 404 });
}, { once: true }), // override for error test
];// components/UserProfile.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { server } from "../mocks/server"; // MSW server
import { http, HttpResponse } from "msw";
import { UserProfile } from "./UserProfile";
import { QueryClientWrapper } from "../test-utils"; // TanStack Query wrapper
describe("UserProfile", () => {
it("shows the user name after loading", async () => {
render(
<QueryClientWrapper>
<UserProfile userId="123" />
</QueryClientWrapper>
);
// Loading state is visible initially
expect(screen.getByRole("status")).toBeInTheDocument();
// User name appears after the mock API responds
await screen.findByText("Priya Sharma");
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("shows an error message when the API fails", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 }))
);
render(
<QueryClientWrapper>
<UserProfile userId="123" />
</QueryClientWrapper>
);
await screen.findByText(/something went wrong/i);
});
});What to query by (in priority order):
getByRole— accessible role + name (most resilient)getByLabelText— for form fieldsgetByText— for contentgetByTestId— last resort only
Never query by CSS class or component displayName — those are implementation details.
E2E Tests with Playwright
Playwright tests run in a real browser against a running app instance. Reserve them for critical user flows that cross multiple pages and represent real monetary or security risk.
// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Authentication flow", () => {
test("user can sign up, log in, and reach dashboard", async ({ page }) => {
// Sign up
await page.goto("/signup");
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Password").fill("Secure123!");
await page.getByLabel("Confirm Password").fill("Secure123!");
await page.getByRole("button", { name: "Sign Up" }).click();
// Redirect to dashboard
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});
test("shows error for invalid credentials", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("wrong@example.com");
await page.getByLabel("Password").fill("wrongpassword");
await page.getByRole("button", { name: "Log in" }).click();
await expect(page.getByRole("alert")).toContainText("Invalid credentials");
});
});Playwright configuration for multi-browser and CI:
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
use: { baseURL: "http://localhost:3000" },
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "mobile", use: { ...devices["iPhone 14"] } },
],
});What to Mock
The heuristic: mock at the boundary of your system, not inside it. If you are testing a component, mock the API it calls. Do not mock the component's internal state management or child components.
Key Takeaways
- The frontend test pyramid mirrors the backend: many fast unit tests, moderate integration tests, few slow E2E tests — invest most effort in integration tests.
- Vitest is the correct unit test runner for Vite/Next.js projects; its API is Jest-compatible so backend engineers can reuse existing knowledge.
- React Testing Library forces you to test from the user's perspective — query by accessible role and text, never by CSS class or internal component state.
- MSW intercepts at the network level, giving you realistic API mocking without coupling tests to fetch implementation details.
- Playwright is the right E2E tool for cross-browser and mobile testing; scope E2E tests to critical business flows, not every feature.
- Mock at system boundaries (API layer), not inside the system; mocking component internals makes tests brittle and provides no additional confidence.