End-to-End Code Coverage
Instrument the application, run Playwright or Cypress against it, and merge the result with the unit run — the measurement that shows which code only your slowest tests protect.
2 min read · updated 19 September 2026
Nearly every project measures coverage from unit tests only. Adding the end-to-end run answers a question the unit number cannot:
Which code is reached only by end-to-end tests?
That list is code whose sole protection is your slowest, most fragile and most expensive suite. It is a refactoring backlog written by measurement rather than opinion.
#Front-end: instrument the bundle
// babel.config.js — a separate build, never shipped
module.exports = {
presets: ['next/babel'],
plugins: process.env.COVERAGE === 'true' ? ['istanbul'] : []
};COVERAGE=true npm run build && npm run startThe instrumented build writes coverage into window.__coverage__ as the
application runs. The test then harvests it.
// playwright/fixtures.ts
import { test as base } from '@playwright/test';
import fs from 'node:fs';
import crypto from 'node:crypto';
export const test = base.extend({
page: async ({ page }, use) => {
await use(page);
// After the test, before the page closes.
const coverage = await page.evaluate(() => (window as any).__coverage__);
if (coverage) {
fs.mkdirSync('.nyc_output', { recursive: true });
fs.writeFileSync(`.nyc_output/${crypto.randomUUID()}.json`, JSON.stringify(coverage));
}
}
});For a multi-page journey, harvest on every navigation — the counter resets when the document does:
page.on('framenavigated', async (frame) => {
if (frame !== page.mainFrame()) return;
const coverage = await page.evaluate(() => (window as any).__coverage__).catch(() => null);
if (coverage) writeCoverage(coverage);
});#Cypress
Simpler, because the plugin does the harvesting:
// cypress/support/e2e.js
import '@cypress/code-coverage/support';
// cypress.config.js
setupNodeEvents(on, config) {
require('@cypress/code-coverage/task')(on, config);
return config;
}#Server-side coverage
The more interesting half for a full-stack application, and the one usually skipped. Run the server under a coverage collector and flush on shutdown.
{
"scripts": {
"start:coverage": "c8 --reporter=json --report-dir=.nyc_output/server node server.js"
}
}// playwright.config.ts
webServer: {
command: 'npm run start:coverage',
url: 'http://127.0.0.1:3000/api/health',
// c8 writes its report when the process exits cleanly, so the run must
// not be killed with SIGKILL.
gracefulShutdown: { signal: 'SIGTERM', timeout: 10_000 }
}// .NET: coverlet can attach to a running host, or run the app under
// `dotnet-coverage collect` for the duration of the test run.
dotnet-coverage collect --output server.cobertura.xml --output-format cobertura \
-- dotnet run --project ./src/Api#Merging
Both suites produce Istanbul-format JSON. Combine them:
# unit tests
npx jest --coverage --coverageReporters=json --coverageDirectory=coverage/unit
# e2e (already written into .nyc_output by the fixture)
npx playwright test
# merge and report
npx nyc merge coverage/unit coverage/merged/unit.json
npx nyc merge .nyc_output coverage/merged/e2e.json
npx nyc report --temp-dir coverage/merged --reporter=html --reporter=text-summary#The question this answers
Generate all three reports and compare:
unit e2e merged
src/pricing/ 96% 12% 96% ← well protected below
src/checkout/flow.ts 31% 88% 91% ← ONLY e2e protects this
src/api/webhooks/ 0% 74% 74% ← only e2e, and it handles money
src/admin/export.ts 0% 0% 0% ← nothing protects thisRows two and three are the finding. checkout/flow.ts is at 91% total and
almost all of that comes from tests that take forty seconds each and
occasionally fail for no reason. Any refactor of
that file is guarded only by them.
That is a concrete, prioritised argument for pushing tests down the pyramid — and it is an argument made by data rather than by preference, which is why it tends to win.
Row four is a different finding, and a worse one.
#Costs and caveats
Instrumented code is slower. Typically 20–40% for JavaScript. Fine for a test environment, never for production.
Never ship the instrumented build. Keep it behind an explicit environment variable and assert in CI that the production artefact contains no coverage counters.
Source maps matter. Without correct maps the report points at bundled output and is unreadable.
Merged percentages invite misuse. A high merged number can hide the fact that most of it comes from the expensive suite. The per-suite breakdown is the useful artefact; the merged total is the one that ends up on a badge.
#Worth doing occasionally, not continuously
This is a periodic diagnostic, not a per-pull-request gate. Run it monthly, or before a significant refactor, look at the "only e2e" column, and act on it. Gating on merged coverage recreates every problem with coverage targets with a slower pipeline attached.
Common questions
- Why collect coverage from end-to-end tests at all?
- To find the code that ONLY end-to-end tests reach. That list is your refactoring backlog — code whose only protection is your slowest and most fragile tests. It is a question no other measurement answers.
- How do I instrument a production build for coverage?
- Build a separate coverage bundle with babel-plugin-istanbul (or the V8 collector for server code), deploy that to the test environment, and never ship it. Instrumented code is slower and larger and should not reach users.
- Can I merge browser coverage with unit-test coverage?
- Yes — both produce Istanbul-format JSON, and nyc merge plus nyc report combines them. The merged report is the interesting one, because it shows total protection rather than per-suite protection.
Runnable samples for this page
last test results ↗- TypeScript (Playwright)
browser/tests/coverage/e2e-code-coverage
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Code CoverageWhat the percentage measures, why it is a finding tool rather than a target, how to collect it in each ecosystem, and how to gate on it without causing harm.
- Coverage MetricsLine, statement, branch, condition and path coverage — what each measures, why branch coverage is worth three times line coverage, and what none of them see.
- PlaywrightThe default browser automation tool in 2026 — auto-waiting locators, tracing, sharding and fixtures — with the configuration that matters and the mistakes that still cause flakiness.
- CypressIn-browser test execution, automatic retry-ability and time-travel debugging — what Cypress's architecture buys, and the constraints that come with it.
- End-to-End TestingWhat belongs in an end-to-end suite and what does not, how many journeys are enough, and the practices that keep a browser suite from becoming the thing everyone ignores.
- Testing in GitHub ActionsA complete pipeline — unit tests, integration with containers, sharded Playwright, coverage and artefacts — plus the caching and concurrency settings that make it fast.