Notifications

No notifications

/Phase 4

Frontend Testing

Frontend Testing โ€” Confidence Through Automated Tests

Testing ensures your UI works as expected and prevents regressions. Modern frontend testing focuses on testing behavior (what the user sees and does) rather than implementation details.

Testing Pyramid for Frontend

LevelToolSpeedScope
Unit testsVitestโšก FastIndividual functions
Component testsReact Testing Library๐Ÿš€ FastSingle components
Integration testsRTL + MSW๐Ÿƒ MediumFeature flows
E2E testsPlaywright / Cypress๐Ÿข SlowFull app

React Testing Library Philosophy

> "The more your tests resemble the way your software is used, the more confidence they can give you." โ€” Kent C. Dodds

RTL provides utilities to:

  • Render components: render()
  • Query elements: getByRole, getByText, getByLabelText
  • Interact: fireEvent.click(), userEvent.type()
  • Assert: expect(element).toBeInTheDocument()

Query Priority

PriorityQueryExample
1stgetByRolegetByRole("button", { name: "Submit" })
2ndgetByLabelTextForm inputs
3rdgetByTextNon-interactive text
4thgetByTestIdLast resort

Vitest โ€” Modern Test Runner

Vitest is Vite-native, Jest-compatible, and blazing fast. It supports ESM, TypeScript, and JSX out of the box with a familiar API: describe, it, expect, vi.fn().

On this page

Detailed Theory

Why Test Your Frontend

Tests give you the courage to change code. Without them, every refactor is a gamble โ€” does this still work? Did I break the cart? With tests, you press save, hit run, and the green ticks tell you the app still does what users need.

The mindset shift: you're not testing functions, you're testing behaviour. "When the user clicks Buy, the cart shows 1 item." That's a test. The shape of the internal state isn't.

The Three Layers (Testing Pyramid)

LayerWhat it testsToolSpeed
UnitA single function or pure componentJest / VitestFastest, ms
IntegrationA component + its children + a fake APIRTL + MSWFast, 10s of ms
E2EReal browser, real backend, real flowPlaywright / CypressSlow, seconds

You want lots of unit tests, enough integration tests, and a handful of critical-path E2E tests. Inverting that pyramid is the single most common mistake.

Vitest / Jest โ€” The Test Runner

Vitest is the modern default for Vite/Next projects (Jest's API, much faster). Same syntax either way:

import { describe, it, expect } from "vitest";

function add(a: number, b: number) { return a + b; }

describe("add", () => { it("adds two numbers", () => { expect(add(2, 3)).toBe(5); });

it("handles negatives", () => { expect(add(-1, 1)).toBe(0); }); });

Run with vitest (watch mode) or vitest run (one-shot for CI).

Common Matchers

expect(x).toBe(5);                  // strict equality (===)
expect(obj).toEqual({ a: 1 });      // deep equality
expect(arr).toContain("apple");
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenCalledWith("hello");
expect(value).toBeTruthy();
expect(value).toBeNull();
expect(promise).rejects.toThrow("Boom");
expect(node).toBeInTheDocument();   // from jest-dom

Setup & Teardown

beforeEach(() => { /* reset before each test */ });
afterEach(() => { /* clean up after each */ });
beforeAll(() => { /* once before all */ });
afterAll(() => { /* once after all */ });

Always reset shared state (mocks, fake timers, the DOM) between tests โ€” flaky tests almost always come from forgetting this.

React Testing Library (RTL)

RTL renders a real React tree into JSDOM and gives you queries that mimic how a user finds things on the page.

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

function Counter() { const [n, setN] = useState(0); return <button onClick={() => setN(n + 1)}>Count: {n}</button>; }

it("increments on click", async () => { const user = userEvent.setup(); render(<Counter />);

await user.click(screen.getByRole("button"));

expect(screen.getByRole("button")).toHaveTextContent("Count: 1"); });

Notice we never asked about state. We clicked like a user, then asserted what the user would see. That's RTL's whole philosophy.

Choosing Queries (Order of Preference)

1. getByRole โ€” what assistive tech sees (button, heading, textbox) 2. getByLabelText โ€” form fields by their label 3. getByPlaceholderText โ€” fallback for unlabelled inputs 4. getByText โ€” for visible content 5. getByTestId โ€” last resort, when nothing semantic exists

If you reach for getByTestId first, your component probably has accessibility issues.

Beginner Mistakes to Skip

1. Testing implementation details ("the state is now {count: 1}") โ€” refactor breaks the test, app still works. Test outputs/behaviour instead. 2. Querying with CSS selectors / class names โ€” those change with styling. Use roles & text. 3. Using fireEvent instead of userEvent โ€” userEvent simulates real user actions (focus, hover, key delays). Default to it. 4. Asserting before async work finishes โ€” use findBy* or waitFor for things that appear later. 5. Mocking everything โ€” over-mocking gives green tests on broken apps. Only mock the network and external SDKs. 6. Chasing 100% coverage โ€” coverage is a smoke detector, not a goal. 70% on critical paths beats 95% on getters.

Intermediate: Async Queries & waitFor

Anything that appears after a useEffect or fetch is asynchronous. RTL has three flavours:

screen.getByText("Loaded");      // throws immediately if missing
screen.queryByText("Loaded");    // returns null if missing (good for "not present")
await screen.findByText("Loaded"); // waits up to 1 s

For a custom condition:

await waitFor(() => {
  expect(mockSave).toHaveBeenCalledWith({ id: 1 });
});

Intermediate: Mocking the Network with MSW

MSW (Mock Service Worker) intercepts fetch/XHR at the network layer โ€” your component code stays untouched.

import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";

const server = setupServer( http.get("/api/users", () => HttpResponse.json([{ id: 1, name: "Asha" }])) );

beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());

Now any fetch("/api/users") in your component receives the fake response. Same handlers can power your dev environment.

Intermediate: Mocking Modules & Functions

import { vi } from "vitest";

const onClick = vi.fn(); render(<Button onClick={onClick} />); await user.click(screen.getByRole("button")); expect(onClick).toHaveBeenCalledOnce();

// Mock an entire module vi.mock("../api", () => ({ fetchUser: vi.fn().mockResolvedValue({ id: 1 }), }));

Intermediate: Fake Timers (debounce, setTimeout, polling)

vi.useFakeTimers();
render(<SearchBox />);
await user.type(screen.getByRole("textbox"), "react");
vi.advanceTimersByTime(300); // skip the 300 ms debounce
expect(searchSpy).toHaveBeenCalledWith("react");
vi.useRealTimers();

Fake timers turn slow tests into instant tests, and make race conditions deterministic.

Intermediate: Custom render for Providers

Apps usually need a router, theme, store, and query client around every test. Wrap once:

function renderWithProviders(ui: ReactElement) {
  return render(
    <QueryClientProvider client={new QueryClient()}>
      <ThemeProvider><MemoryRouter>{ui}</MemoryRouter></ThemeProvider>
    </QueryClientProvider>
  );
}

Now every test starts with one line: renderWithProviders().

Intermediate: Snapshot Tests โ€” Use Sparingly

expect(container).toMatchSnapshot();

Great for stable presentational components, terrible for everything else (every CSS tweak breaks them and people start blindly updating snapshots). Reach for inline assertions by default.

Advanced: Testing Hooks in Isolation

import { renderHook, act } from "@testing-library/react";

const { result } = renderHook(() => useCounter()); act(() => { result.current.increment(); }); expect(result.current.count).toBe(1);

act wraps any state update so React can flush effects before assertions.

Advanced: Accessibility Checks

import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);

it("has no a11y violations", async () => { const { container } = render(<MyForm />); expect(await axe(container)).toHaveNoViolations(); });

Catches missing labels, low contrast, missing landmarks โ€” for free.

Advanced: End-to-End with Playwright

Playwright drives a real browser, perfect for the 5 critical flows in your app (sign up, log in, checkout, post creation, search).

import { test, expect } from "@playwright/test";

test("user can log in", async ({ page }) => { await page.goto("/login"); await page.getByLabel("Email").fill("a@b.com"); await page.getByLabel("Password").fill("hunter2"); await page.getByRole("button", { name: "Sign in" }).click(); await expect(page).toHaveURL("/dashboard"); await expect(page.getByRole("heading")).toContainText("Welcome"); });

Auto-waits, screenshots on failure, traces, parallel runs across Chromium / Firefox / WebKit.

Advanced: Visual Regression

Tools like Percy, Chromatic (for Storybook) or Playwright's toHaveScreenshot() snapshot the rendered UI as an image and diff it against the baseline. Catches "the button shifted 2 px and now overlaps the price" โ€” the kind of bug unit tests never see.

Advanced: CI Strategy

  • Unit + integration on every push (must be green to merge).
  • E2E on PR + nightly (more flake-prone, longer runtime).
  • Coverage report posted as a PR comment, not as a blocker.
  • Run tests in parallel with sharding (--shard=1/4).

Practice Path

1. Add Vitest to a project, write 5 unit tests for pure helpers. 2. Render a form with RTL, fill it via userEvent, assert the success message appears. 3. Replace a manual vi.mock("./api") with MSW โ€” feel the test become more realistic. 4. Add one Playwright test for your most important flow (signup or checkout). Watch it auto-screenshot when it fails.