Home/Blog

Testing Strategy for Modern Web Apps: What to Test Where

A practical testing strategy for web apps: unit, component, integration and Playwright end-to-end tests, what each layer covers, and fixing flaky tests.

Testing Strategy for Modern Web Apps: What to Test Where

A good testing strategy for a modern web app puts most of its effort into integration-level tests: components tested through the DOM the way users use them, and API handlers tested against a real database. Fast unit tests cover pure logic, a small set of Playwright end-to-end tests covers the journeys that make money, and static checks (TypeScript in strict mode, ESLint) sit underneath everything. Test behavior rather than implementation, keep every layer deterministic, and treat a flaky test as a bug with an owner, because a suite people do not trust is a suite people stop reading.

Key takeaways

  • Put most effort into component and integration tests. They catch real bugs and survive refactors.
  • Test database code against a real database in a container, not a mock. Mock at the network boundary with MSW, not by stubbing fetch.
  • Keep end-to-end tests few and focused on revenue and retention journeys. Push edge cases down a layer.
  • Async Server Components are hard to test in jsdom; test the logic they call and cover the page end to end.
  • Flakiness is a defect. Retries hide it; quarantine with an owner and a deadline fixes it.

What to test where

The useful question is not "how many unit tests" but "what is the cheapest test that would catch this bug".

What you are testingLayerToolingNotes
Types, unused code, obvious mistakesStaticTypeScript strict, ESLintCheapest bug catcher you have
Pure logic: pricing, permissions, parsers, date mathUnitVitestTable-driven, milliseconds per test
Client components and formsComponentVitest with Testing Library and user-event, MSWQuery by role and label, never by class name
API handlers, repositories, migrationsIntegrationVitest with TestcontainersReal database, real transactions
Contracts between servicesContractOpenAPI validation or PactCatches drift that mocks hide
Signup, login, checkout, the core workflowEnd to endPlaywrightFew, fast, isolated data
Visual appearance and layoutVisualPlaywright screenshots, layout checksPin browser, fonts and viewport
AccessibilityAutomated plus manualaxe-core, screen reader passesAutomation finds the mechanical issues only

This is roughly the testing trophy: a broad static base, some unit tests, the most effort in integration tests, and a small end-to-end cap. Our TypeScript practices guide covers the static layer, which pays for itself before you write a single test.

Unit tests: pure logic, table-driven

Unit tests earn their keep on code with branches, boundaries and arithmetic. Write them as tables so a new case is one line:

// lib/pricing.ts
export type Tier = "none" | "silver" | "gold";
const RATE_BPS: Record<Tier, number> = { none: 0, silver: 1000, gold: 1500 };
 
// Money in integer cents and discounts in basis points: no floating point surprises.
export function priceCents(unitCents: number, quantity: number, tier: Tier): number {
  if (!Number.isInteger(unitCents) || !Number.isInteger(quantity) || quantity < 0) {
    throw new RangeError("unitCents and quantity must be non-negative integers");
  }
  const subtotal = unitCents * quantity;
  return subtotal - Math.round((subtotal * RATE_BPS[tier]) / 10_000);
}
// lib/pricing.test.ts
import { describe, expect, it } from "vitest";
import { priceCents } from "./pricing";
 
describe("priceCents", () => {
  it.each([
    { unit: 2999, qty: 3, tier: "none", expected: 8997 },
    { unit: 10000, qty: 2, tier: "silver", expected: 18000 },
    { unit: 3333, qty: 3, tier: "silver", expected: 8999 },
    { unit: 10000, qty: 0, tier: "gold", expected: 0 },
  ] as const)("$unit x $qty at $tier = $expected", ({ unit, qty, tier, expected }) => {
    expect(priceCents(unit, qty, tier)).toBe(expected);
  });
 
  it("rejects fractional cents", () => {
    expect(() => priceCents(29.99, 1, "none")).toThrow(RangeError);
  });
});

Skip unit tests for code that only moves data from one shape to another; the component or integration test that uses it covers it better. For logic where a wrong answer is expensive (billing, permissions), mutation testing with a tool such as Stryker tells you whether your tests would actually notice a bug.

Component tests: behavior through the DOM

Component tests render a component, interact with it the way a user would, and assert on what the user sees. Testing Library's queries push you toward accessible markup: if you cannot find a button by its role and name, neither can a screen reader user.

Mock the network, not your data layer. MSW intercepts requests at the network level, so your real fetching code, error handling and request formatting all run:

// test/msw.ts
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
 
export const server = setupServer(
  http.get("/api/products", ({ request }) => {
    const category = new URL(request.url).searchParams.get("category");
    return HttpResponse.json({ data: fixtures.products.filter((p) => !category || p.category === category) });
  }),
);
// vitest.setup.ts
import { afterAll, afterEach, beforeAll } from "vitest";
import { server } from "./test/msw";
 
// Fail loudly when a test makes a request nobody mocked.
// MSW 3 option name; MSW 2 calls it onUnhandledRequest.
beforeAll(() => server.listen({ onUnhandledFrame: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// components/product-filter.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { expect, it } from "vitest";
import { server } from "../test/msw";
import { ProductFilter } from "./product-filter";
 
it("filters products by category", async () => {
  const user = userEvent.setup();
  render(<ProductFilter />);
 
  await user.selectOptions(screen.getByLabelText("Category"), "shirts");
 
  expect(await screen.findByRole("heading", { name: "Oxford shirt" })).toBeVisible();
  expect(screen.queryByRole("heading", { name: "Wool scarf" })).not.toBeInTheDocument();
});
 
it("shows a retry option when the API fails", async () => {
  server.use(http.get("/api/products", () => HttpResponse.json({ error: "boom" }, { status: 500 })));
  render(<ProductFilter />);
 
  expect(await screen.findByRole("alert")).toHaveTextContent("Could not load products");
  expect(screen.getByRole("button", { name: "Try again" })).toBeEnabled();
});

The second test is the one teams skip and production needs: every component that fetches should have a test for the failure state.

jsdom is fast but is not a browser. It does no layout, so anything about sizes, scrolling, intersection or computed color is untested there. Run those cases in a real browser, either with Vitest's browser mode or as Playwright tests.

Server Components

Async React Server Components are not well supported by jsdom-based runners, and the Next.js documentation recommends end-to-end tests for them. The practical approach: keep Server Components thin, move data loading and transformation into plain functions you can unit and integration test, test synchronous and Client Components as normal, and let a few Playwright tests cover the assembled page.

Integration tests: real database, real transactions

Code whose job is to talk to a database should be tested against that database. Mocks will happily accept a query with a typo, a missing index will not show, and constraint and transaction behavior is simply absent. Testcontainers starts a real, throwaway database per test run:

// orders/create-order.test.ts
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { Pool } from "pg";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { migrate } from "../db/migrate";
import { createOrder } from "./create-order";
 
let container: StartedPostgreSqlContainer;
let pool: Pool;
 
beforeAll(async () => {
  container = await new PostgreSqlContainer("postgres:17-alpine").start();
  pool = new Pool({ connectionString: container.getConnectionUri() });
  await migrate(pool); // the same migrations production runs
}, 120_000);
 
afterAll(async () => {
  await pool.end();
  await container.stop();
});
 
beforeEach(async () => {
  await pool.query("TRUNCATE orders, order_items, products RESTART IDENTITY CASCADE");
});
 
describe("createOrder", () => {
  it("rejects an order when stock is insufficient and writes nothing", async () => {
    await pool.query("INSERT INTO products (sku, stock) VALUES ('TSHIRT-M', 1)");
 
    await expect(createOrder(pool, { items: [{ sku: "TSHIRT-M", qty: 2 }] })).rejects.toThrow("insufficient_stock");
 
    const { rows } = await pool.query("SELECT count(*)::int AS n FROM orders");
    expect(rows[0].n).toBe(0);
  });
 
  it("does not oversell under concurrent orders", async () => {
    await pool.query("INSERT INTO products (sku, stock) VALUES ('TSHIRT-M', 1)");
 
    const results = await Promise.allSettled([
      createOrder(pool, { items: [{ sku: "TSHIRT-M", qty: 1 }] }),
      createOrder(pool, { items: [{ sku: "TSHIRT-M", qty: 1 }] }),
    ]);
 
    expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
  });
});

The concurrency test is the kind of bug that only a real database can reveal: it fails if createOrder reads stock and writes it back without a lock or a conditional update. Run migrations from scratch in this setup, so a broken migration fails CI rather than a deploy.

For APIs consumed by other teams or services, add a contract check: validate responses against the OpenAPI document in integration tests, or use consumer-driven contracts with Pact. That catches the drift between what your MSW handlers say the API returns and what it actually returns.

End-to-end tests with Playwright

End-to-end tests are the most realistic and the most expensive layer. Keep them for journeys whose failure would cost revenue or users, and make each one independent.

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
 
export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: process.env.CI ? [["html"], ["github"]] : "list",
  use: {
    baseURL: process.env.BASE_URL ?? "http://localhost:3000",
    trace: "on-first-retry",
  },
  projects: [
    { name: "setup", testMatch: /auth\.setup\.ts/ },
    {
      name: "desktop",
      use: { ...devices["Desktop Chrome"], storageState: "e2e/.auth/user.json" },
      dependencies: ["setup"],
    },
    {
      name: "mobile",
      use: { ...devices["Pixel 5"], storageState: "e2e/.auth/user.json" },
      dependencies: ["setup"],
    },
  ],
});
// e2e/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
 
setup("sign in once and save the session", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.E2E_USER_EMAIL!);
  await page.getByLabel("Password").fill(process.env.E2E_USER_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
  await page.context().storageState({ path: "e2e/.auth/user.json" });
});

What makes an end-to-end suite stay green:

  • Log in once. The setup project signs in and saves storage state; every test starts authenticated. Logging in through the UI in every test is slow and multiplies flakiness.
  • Create data per test, through the API. Each test makes its own project, cart or document with a unique name, so tests can run in parallel and in any order.
  • Use web-first assertions. await expect(locator).toBeVisible() retries until the condition holds. waitForTimeout(2000) is a flaky test you have not met yet.
  • Use role and label locators. They survive markup refactors and double as accessibility checks.
  • Keep traces on retry. A trace of the failing run (DOM snapshots, network, console) turns "flaky on CI" into a diagnosis.
  • Run against a preview deployment where you can. It tests the build artifact and the real configuration, not just the dev server.

Accessibility and layout checks

Run axe in real browsers, not only in jsdom. jsdom cannot compute colors, so color contrast checks do not work there.

import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
 
for (const path of ["/", "/pricing", "/dashboard"]) {
  test(`${path} has no detectable accessibility violations`, async ({ page }) => {
    await page.goto(path);
    const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze();
    expect(results.violations).toEqual([]);
  });
}

Automated checks find missing labels, invalid ARIA and contrast failures. They cannot tell you whether a keyboard or screen reader user can complete checkout, so schedule manual passes on core journeys before major releases.

Layout regressions deserve their own check too. The most common mobile bug is a single element wider than the screen, and screenshot tests at desktop width never see it. wontfit, our open-source checker, loads pages of a running app at phone and tablet widths, names the element that overflows and by how many pixels, and exits non-zero so the pull request fails.

Flaky tests: causes and fixes

SymptomUsual causeFix
Passes locally, fails in CI under loadFixed sleeps, racing an animation or requestWeb-first assertions, wait for the network response you depend on, reducedMotion: "reduce"
Fails only when run with other testsShared data or order dependenceUnique data per test, fullyParallel, reset state in beforeEach
Fails at midnight or month endReal clock, time zonesvi.useFakeTimers() in unit tests, Playwright's page.clock, a fixed timezoneId
Fails when a third party is slowReal calls to payment, maps or analyticspage.route() to stub third parties; test the real integration separately
Fails on one CI machine onlyFonts, locale, viewport differencesPin the browser and container image; set locale and viewport in config

Policy matters as much as technique. Record every retry in CI, and when a test flakes, quarantine it with a named owner and a fix-by date. A retry that quietly turns red into green trains the team to ignore failures, and the real regression slips through the same way.

Running it in CI

Order the pipeline so the cheapest failures come first: type-check and lint, then unit and component tests, then integration tests against containers, then end-to-end tests against a preview build. Shard the slow layers (Playwright's --shard=1/4 splits a suite across four machines). Our GitHub Actions CI/CD guide covers caching, parallel jobs and deployment gates.

Coverage is a map, not a target. Use it to find untested critical code; a team chasing a coverage number writes tests that assert nothing.

What goes wrong in production

Tests prove the code does what you specified, in the environment you tested. Production breaks in the gaps:

  • Configuration. A missing environment variable or a feature flag set differently in production. Validate configuration at startup and fail fast.
  • Data shape and volume. A migration that runs in seconds on test data locks a large production table. Test migrations against a production-sized copy before running them.
  • Mock drift. The MSW handler returns last quarter's API shape. Contract tests or schema validation keep mocks honest.
  • Third parties. The payment provider's sandbox behaves differently from production. Keep a small set of tests against the real sandbox, and monitor real transactions.
  • Real devices and networks. Slow phones and flaky networks expose race conditions that a fast CI machine never hits.

That is why the last layer of a testing strategy is not a test at all: synthetic checks on critical journeys in production, error tracking, and alerts on the metrics that mean customers are failing. Our observability guide covers that layer.

Frequently Asked Questions

What is the testing trophy?

The testing trophy is a model for distributing test effort, popularized by Kent C. Dodds. Static analysis sits at the base, a modest layer of unit tests above it, the largest layer is integration tests, and a small cap of end-to-end tests sits on top. It shifts effort from isolated unit tests toward tests that exercise components and services the way they are really used.

How many end-to-end tests should a web app have?

Enough to cover the journeys that would cost you money or users if they broke, such as signup, login, checkout and the core workflow, and few enough that the suite stays fast and reliable. For many products that is a few dozen tests, not hundreds. Push edge cases and validation rules down into component and integration tests, where they are cheaper and less flaky.

Should I mock the database in tests?

Not for code whose job is to talk to the database. Queries, transactions, constraints and migrations behave differently in a real engine than in a mock, so test them against a real database started in a container. Mock the database only in unit tests of logic that happens to receive data, where the query itself is not what you are testing.

Should I use Vitest or Jest?

For new projects built on Vite or modern bundlers, Vitest is the simpler choice: it shares your build configuration, handles TypeScript and ESM natively, and keeps a Jest-compatible API. Jest remains fine for existing suites that work well. Migrating a large, stable Jest suite rarely pays for itself unless configuration pain is slowing the team down.

How do you fix flaky tests?

Treat each flaky test as a bug with an owner. The usual causes are fixed sleeps instead of waiting for a condition, shared data between tests, real clocks and time zones, animations, and calls to third-party services. Quarantine a flaky test with a deadline rather than letting retries hide it, and record retries in CI so flakiness stays visible.

How do you test React Server Components?

Test the logic they call, such as data loaders and formatting, with unit and integration tests, and test synchronous components with your normal component tests. Async Server Components are not well supported by jsdom-based component test runners, so the Next.js documentation recommends end-to-end tests for them. Keep the async layer thin so most behavior is testable elsewhere.

Need help with your test strategy?

We write tests for systems we then operate, so we optimize for suites that catch real regressions and stay green for the right reasons. If your test suite is slow, flaky or not catching the bugs that reach production, or you are taking an AI-built prototype to production without tests, talk to the senior engineer who would do the work.