Skip to main content
Frontend for Backend Engineers

Testing

Ravinder··6 min read
FrontendReactTypeScriptTestingPlaywrightVitest
Share:
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

graph TD E2E["E2E Tests — Playwright\n(few, critical paths, real browser)"] INT["Integration Tests — RTL + MSW\n(component trees + mocked API)"] UNIT["Unit Tests — Vitest\n(pure functions, hooks, utilities)"] UNIT --> |"fast, many"| UNIT INT --> |"medium speed, moderate"| INT E2E --> |"slow, few"| E2E style UNIT fill:#22c55e,color:#fff style INT fill:#f59e0b,color:#fff style E2E fill:#ef4444,color:#fff

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):

  1. getByRole — accessible role + name (most resilient)
  2. getByLabelText — for form fields
  3. getByText — for content
  4. getByTestId — 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

graph LR M[Mock these] --> API[External API calls — use MSW] M --> TM[Time — vi.useFakeTimers] M --> RN[Random / crypto — deterministic seeds] D[Do not mock these] --> RC[React core behavior] D --> DM[DOM APIs that jsdom supports] D --> BL[Business logic under test] D --> UI[Component internals / state]

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.
Share: