Skip to content
End To End Tester
Coverageadvanced

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

javascript
// babel.config.js — a separate build, never shipped
module.exports = {
  presets: ['next/babel'],
  plugins: process.env.COVERAGE === 'true' ? ['istanbul'] : []
};
bash
COVERAGE=true npm run build && npm run start

The instrumented build writes coverage into window.__coverage__ as the application runs. The test then harvests it.

typescript
// 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:

typescript
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:

javascript
// 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.

json
{
  "scripts": {
    "start:coverage": "c8 --reporter=json --report-dir=.nyc_output/server node server.js"
  }
}
typescript
// 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 }
}
csharp
// .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:

bash
# 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 this

Rows 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 ↗

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

Was this page useful?