There are two ways a test suite dies. It is too thin, so bugs reach production and the team stops believing testing helps. Or it is too thick and too slow, so the pipeline takes forty minutes, people merge on red, and the team stops believing testing helps. The destination is identical.
What follows is the shape we default to. It is not the maximal suite; it is the one that keeps paying for itself after the launch rush is over.
Weight the layers by what actually breaks
The classic pyramid puts almost everything in unit tests. In practice, for product work, the bugs that reach users are rarely a function returning the wrong number — they are components wired up wrong, a contract that drifted, a flow that breaks on the third step. So we weight toward integration.
- Unit tests for genuine logic: pricing, permissions, date maths, parsers, anything with branches worth enumerating. Fast, plentiful, cheap to keep.
- Integration tests as the centre of gravity. Render a component with its real children, interact with it the way a user would, assert on what appears. Testing Library over a mock-heavy shallow render.
- End-to-end tests for the handful of journeys the business cannot afford to have broken. Five to fifteen, not two hundred.
- Contract tests wherever a boundary is owned by someone else.
Write E2E tests the way a user describes them
The single biggest cause of flaky, high-maintenance E2E suites is selectors coupled to markup. A CSS class is an implementation detail; a role and an accessible name are the contract the user actually experiences — and asserting on them means the test doubles as an accessibility check.
import { test, expect } from '@playwright/test';
test('a visitor can send a project inquiry', async ({ page }) => {
await page.goto('/contact');
// Role + accessible name: stable across restyles, and it fails
// loudly if the field ever loses its label.
await page.getByLabel('Name').fill('Jordan Ellis');
await page.getByLabel('Email').fill('jordan@example.com');
await page.getByLabel('Message').fill('We need a WooCommerce migration.');
await page.getByRole('button', { name: 'Send message' }).click();
// Assert the user-visible outcome, never an internal state flag.
await expect(page.getByRole('status')).toContainText('Thanks');
});Two rules keep these suites healthy. Never use a hard wait — waitForTimeout is how a suite becomes slow and flaky at the same time; Playwright auto-waits on its assertions, so let it. And assert on what the user sees, not on a class name or a store value, because those change for reasons that have nothing to do with whether the feature works.
Contract tests catch what nothing else does
The most expensive outages we get called about involve nobody writing a bug. A payment provider adds a field. A CRM changes a status enum. An internal service makes an optional property required. Every unit test still passes, because every unit test mocks that boundary with a shape captured months ago.
A contract test validates the real response against a schema, and it is the cheapest insurance in the suite.
import { z } from 'zod';
const Contact = z.object({
id: z.string(),
properties: z.object({
email: z.string().email(),
lifecyclestage: z.enum(['subscriber', 'lead', 'customer']),
createdate: z.string().datetime(),
}),
});
test('HubSpot contact shape has not drifted', async () => {
const res = await fetch(`${API}/crm/v3/objects/contacts/${FIXTURE_ID}`, {
headers: { Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}` },
});
// Fails the moment the provider adds a status or tightens a field.
expect(() => Contact.parse(res.json())).not.toThrow();
});Run these on a schedule as well as in CI. A contract does not break when you deploy; it breaks when the other side deploys, which could be any Tuesday.
Load testing answers a question staging cannot
Functional tests tell you the feature works for one user. They tell you nothing about the connection pool at 200 concurrent sessions, which is the thing that takes a site down on launch day.
import http from 'k6/http';
import { check } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 100 }, // ramp
{ duration: '5m', target: 100 }, // hold — this is where leaks appear
{ duration: '2m', target: 0 },
],
thresholds: {
// Fail the run, do not just draw a graph.
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const res = http.get(`${__ENV.BASE_URL}/work`);
check(res, { 'status 200': (r) => r.status === 200 });
}The five-minute hold matters more than the peak. Ramping to a big number and stopping shows you the happy path; holding steady is what surfaces connection leaks, unbounded caches and the query that is fine until the buffer pool is full.
Make the pipeline fast enough to trust
A suite people skip has negative value: it costs maintenance and provides no signal. So the pipeline is a product with a performance budget of its own.
- 01Stage it. Lint, types and unit tests on every push, in under two minutes. Integration and E2E on pull requests. Load and full cross-browser nightly.
- 02Shard the slow layer. Playwright shards cleanly across runners; four shards turn twelve minutes into three.
- 03Quarantine flakes, do not retry them blindly. A blanket retry hides a real race condition until it happens in production. Move a flaky test to a quarantine job and fix it within the week or delete it.
- 04Fail fast and read clearly. A trace, a screenshot and a video on failure turn a twenty-minute reproduction into a ten-second look.
Where security fits
A short, automated pass catches the ordinary problems long before a penetration test is worth booking: dependency audit on every build, secret scanning on every commit, a ZAP baseline scan against a deployed preview, and an assertion that the security headers you configured are actually present in the response. That last one fails more often than people expect, usually after an infrastructure change nobody connected to the front end.
The goal is not maximum coverage. It is the smallest suite that would have caught the last five things that reached production.