Skip to content
End To End Tester
Platformsfoundation

Browser Testing

What a browser test can prove that nothing below it can, the three engines that matter, headless versus headed, and the cost model that should shape your suite.

3 min read · updated 19 September 2026

A browser test is the only kind that exercises what the user actually receives: the shipped JavaScript bundle, the applied CSS, the real event loop, the real network stack.

Everything below it — component tests, DOM tests in jsdom — is testing a model of the browser. Usually a good model. Not the thing.

#What only a browser can tell you

  • Layout. Whether the button is on screen, overlapped, or pushed below the fold at 320px.
  • Applied CSS. jsdom parses stylesheets and applies almost nothing. White text on a white background is invisible to every test except this one.
  • Real event sequences. Pointer capture, focus traps, scroll-linked behaviour, :focus-visible.
  • The actual bundle. Whether a tree-shaking change broke a dynamic import, whether a polyfill is missing on the engine you shipped to.
  • Timing under real conditions. Layout shift, hydration mismatches, long tasks.
  • Engine differences. Date parsing, regex features, Intl output — see cross-browser testing.

#The three engines

Engine Browsers Notes
Chromium (Blink) Chrome, Edge, Brave, Opera, Electron Largest share; the default to develop against
Gecko Firefox Independent implementation; catches spec assumptions
WebKit Safari on macOS and every browser on iOS The one that bites

WebKit matters disproportionately because iOS requires it: Chrome on an iPhone is WebKit with a Chrome interface. If any meaningful share of your traffic is iOS, WebKit is not optional.

#Headless

typescript
// Playwright: headless by default; headed for debugging.
await chromium.launch({ headless: true });
await chromium.launch({ headless: false, slowMo: 250 });   // watch it happen

Modern headless Chromium is the same binary in a different mode, so the old "works headed, fails headless" folklore is largely obsolete. What still differs:

  • Fonts. A CI container has a different font set, which changes text metrics and therefore layout. This is the main reason visual baselines must be generated in the same container that compares them.
  • GPU. Headless usually falls back to software rendering; canvas and WebGL output can differ subtly.
  • Media. Codec availability varies by build.

#Viewports and devices

typescript
// Emulation: viewport, user agent, device pixel ratio, touch support.
const iPhone = devices['iPhone 15'];
const context = await browser.newContext({ ...iPhone });

// Or just the dimensions that matter to your layout
await page.setViewportSize({ width: 375, height: 812 });

Emulation tests your layout at that size. It does not test the device's browser, its font stack, its input model or its memory ceiling. That distinction is where a lot of false confidence comes from — a responsive suite that passes on emulated Mobile Safari is not evidence that the site works on an iPhone.

#Network control

typescript
// Fail a dependency the real one will not fail on demand.
await page.route('**/api/rates/**', (route) => route.fulfill({ status: 503 }));

// Cut third-party weight so the suite is fast and deterministic.
await page.route(/analytics|doubleclick|hotjar/, (route) => route.abort());

// Emulate a slow connection (Chromium, via CDP).
const client = await page.context().newCDPSession(page);
await client.send('Network.emulateNetworkConditions', {
  offline: false, latency: 400, downloadThroughput: 400_000, uploadThroughput: 200_000
});

Blocking third-party scripts is worth doing by default. They are slow, they are outside your control, and they are a recurring source of flakiness that has nothing to do with your application.

#Console and page errors

Most suites ignore these, which wastes a free signal:

typescript
test.beforeEach(async ({ page }) => {
  page.on('pageerror', (error) => { throw new Error(`Uncaught: ${error.message}`); });
  page.on('console', (message) => {
    if (message.type() === 'error') throw new Error(`console.error: ${message.text()}`);
  });
});

An uncaught exception that does not break the assertion still usually means something is broken. Failing on it costs nothing and catches a lot.

#The cost model

A browser test costs roughly a thousand times what a unit test costs, in both wall-clock and maintenance. That ratio should shape the suite:

  • Journeys, not permutations. One checkout test, not one per payment method. See end-to-end testing.
  • Set up through the API, act through the UI.
  • One engine for everything, three for the critical few.
  • Capture traces on retry so a CI failure is diagnosable without reproducing it.

#What to run here that is not a journey

Two things are worth a browser and are not user-journey tests:

Accessibility scans. axe-core in a browser catches contrast, missing labels and ARIA misuse that no other level can see — see accessibility testing.

Visual regression. A screenshot comparison catches the entire class of defect where the logic is right and the page looks wrong. See screenshots.

Common questions

Is headless testing different from headed?
Less than it used to be. Modern headless Chromium is the same binary in a different mode, so behaviour matches closely. Differences that remain tend to involve fonts, GPU-accelerated rendering, media playback and some dialog handling — which is why visual regression baselines should be generated in the same environment that will compare against them.
How many browsers should my suite run against?
Run everything on one engine and a small critical subset on the others. Running the full suite on three engines triples the cost and the flakiness for a small increase in signal, because most defects are not engine-specific.
Do I need real devices?
For the last mile, yes. Emulating a viewport tests your layout; it does not test the device's browser, its fonts, its input handling or its memory limits. A device cloud for a handful of journeys covers what emulation cannot.

Runnable samples for this page

last test results ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?