# Test Coverage > **When to use**: Measuring how much of your application code is exercised by Playwright E2E tests. Useful for identifying untested code paths and enforcing coverage thresholds in CI. ## Quick Reference ```bash # Install coverage dependencies npm install -D nyc istanbul-lib-coverage istanbul-reports v8-to-istanbul # Run with coverage collection npx playwright test # uses fixtures to collect coverage # Generate reports from collected data npx nyc report --reporter=html --reporter=text-summary npx nyc report --reporter=lcov # for CI integration (Codecov, SonarQube) # Check coverage thresholds npx nyc check-coverage --lines 80 --branches 70 --functions 75 ``` ## Patterns ### Pattern 1: V8 Coverage with Playwright (Recommended) **Use when**: Measuring code coverage of a web application during E2E tests. V8 coverage uses Chrome DevTools Protocol for accurate JavaScript coverage. **Avoid when**: Testing third-party sites you don't control. **Step 1: Create a coverage fixture:** ```ts // fixtures/coverage.ts import { test as base, expect } from "@playwright/test" import * as fs from "fs" import * as path from "path" import * as crypto from "crypto" const coverageDir = path.resolve(process.cwd(), ".nyc_output") export const test = base.extend({ page: async ({ page, browserName }, use, testInfo) => { // V8 coverage only works in Chromium if (browserName === "chromium") { await page.coverage.startJSCoverage({ resetOnNavigation: false }) } await use(page) if (browserName === "chromium") { const coverage = await page.coverage.stopJSCoverage() // Filter to only your application code const appCoverage = coverage.filter( (entry) => entry.url.includes("localhost") && !entry.url.includes("node_modules") ) if (appCoverage.length > 0) { // Ensure output directory exists fs.mkdirSync(coverageDir, { recursive: true }) // Write V8 coverage data const coverageFile = path.join(coverageDir, `coverage-${crypto.randomUUID()}.json`) fs.writeFileSync( coverageFile, JSON.stringify({ result: appCoverage.map((entry) => ({ scriptId: "0", url: entry.url, functions: entry.functions || [], })), }) ) } } }, }) export { expect } ``` **Step 2: Use the coverage fixture in tests:** ```ts // tests/dashboard.spec.ts import { test, expect } from "../fixtures/coverage" test("dashboard loads with widgets", async ({ page }) => { await page.goto("/dashboard") await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() // Coverage is automatically collected and saved }) ``` **Step 3: Generate coverage report after tests:** ```json // package.json { "scripts": { "test:e2e": "npx playwright test", "test:coverage": "npx playwright test --project=chromium && npx nyc report --reporter=html --reporter=text-summary", "coverage:check": "npx nyc check-coverage --lines 80 --branches 70 --functions 75" } } ``` ### Pattern 2: Istanbul (Source-Map Based) Coverage **Use when**: Your application uses webpack, Vite, or another bundler and you want source-mapped coverage tied to original source files. **Avoid when**: V8 coverage works well enough -- it's simpler to set up. **Step 1: Instrument your application code:** For **Vite**: ```bash npm install -D vite-plugin-istanbul ``` ```ts // vite.config.ts import { defineConfig } from "vite" import istanbul from "vite-plugin-istanbul" export default defineConfig({ plugins: [ ...(process.env.COVERAGE === "true" ? [ istanbul({ include: "src/*", exclude: ["node_modules", "test/"], extension: [".js", ".ts", ".tsx", ".jsx"], requireEnv: true, }), ] : []), ], }) ``` For **webpack** (Create React App): ```bash npm install -D @istanbuljs/nyc-config-typescript babel-plugin-istanbul ``` ```json // babel.config.json (or .babelrc) { "env": { "test": { "plugins": ["istanbul"] } } } ``` **Step 2: Collect coverage from `window.__coverage__`:** ```ts // fixtures/istanbul-coverage.ts import { test as base, expect } from "@playwright/test" import * as fs from "fs" import * as path from "path" import * as crypto from "crypto" const coverageDir = path.resolve(process.cwd(), ".nyc_output") export const test = base.extend({ page: async ({ page }, use) => { await use(page) // Istanbul instruments code and exposes coverage on window.__coverage__ const coverage = await page.evaluate(() => (window as any).__coverage__) if (coverage) { fs.mkdirSync(coverageDir, { recursive: true }) const coverageFile = path.join(coverageDir, `coverage-${crypto.randomUUID()}.json`) fs.writeFileSync(coverageFile, JSON.stringify(coverage)) } }, }) export { expect } ``` **Step 3: Configure nyc:** ```json // .nycrc.json { "extends": "@istanbuljs/nyc-config-typescript", "all": true, "include": ["src/**/*.{ts,tsx,js,jsx}"], "exclude": ["src/**/*.test.*", "src/**/*.spec.*", "src/test/**", "src/**/*.d.ts"], "reporter": ["html", "text-summary", "lcov"], "report-dir": "coverage" } ``` ```bash # Start app with instrumentation COVERAGE=true npm run dev # Run tests (in another terminal or after dev server starts) npx playwright test --project=chromium # Generate report npx nyc report ``` ### Pattern 3: Coverage Thresholds in CI **Use when**: Enforcing minimum coverage levels as a quality gate. **Avoid when**: You are just starting with coverage -- set thresholds after establishing a baseline. ```json // .nycrc.json { "check-coverage": true, "lines": 80, "branches": 70, "functions": 75, "statements": 80, "reporter": ["html", "text-summary", "lcov"] } ``` **GitHub Actions with coverage check:** ```yaml # .github/workflows/playwright.yml (add to test job steps) - name: Run Playwright tests with coverage run: COVERAGE=true npx playwright test --project=chromium - name: Generate coverage report run: npx nyc report --reporter=html --reporter=text-summary --reporter=lcov if: ${{ !cancelled() }} - name: Check coverage thresholds run: npx nyc check-coverage --lines 80 --branches 70 --functions 75 - name: Upload coverage report uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: name: coverage-report path: coverage/ retention-days: 14 ``` ### Pattern 4: Merging Coverage from Sharded Runs **Use when**: Tests are sharded across multiple CI machines and you need aggregate coverage. **Avoid when**: Tests run on a single machine. ```yaml # .github/workflows/playwright.yml jobs: test: strategy: fail-fast: false matrix: shard: [1/4, 2/4, 3/4, 4/4] steps: # ... checkout, install, etc. - name: Run sharded tests with coverage run: COVERAGE=true npx playwright test --shard=${{ matrix.shard }} --project=chromium - name: Upload coverage data uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: name: coverage-${{ strategy.job-index }} path: .nyc_output/ retention-days: 1 merge-coverage: needs: test if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: "npm" - run: npm ci - name: Download all coverage data uses: actions/download-artifact@v4 with: path: all-coverage pattern: coverage-* merge-multiple: true - name: Merge coverage run: | mkdir -p .nyc_output cp all-coverage/*.json .nyc_output/ npx nyc report --reporter=html --reporter=text-summary --reporter=lcov npx nyc check-coverage --lines 80 --branches 70 --functions 75 - name: Upload merged coverage report uses: actions/upload-artifact@v4 with: name: coverage-report path: coverage/ retention-days: 14 ``` ### Pattern 5: CSS Coverage **Use when**: Identifying unused CSS to reduce bundle size. **Avoid when**: CSS is not a performance concern for your application. ```ts // fixtures/css-coverage.ts import { test as base, expect } from "@playwright/test" import * as fs from "fs" import * as path from "path" export const test = base.extend({ page: async ({ page, browserName }, use, testInfo) => { if (browserName === "chromium") { await page.coverage.startCSSCoverage() } await use(page) if (browserName === "chromium") { const coverage = await page.coverage.stopCSSCoverage() // Calculate unused CSS percentage per file const report = coverage.map((entry) => { const totalBytes = entry.text.length const usedBytes = entry.ranges.reduce((acc, range) => acc + (range.end - range.start), 0) return { url: entry.url, totalBytes, usedBytes, unusedPercent: ((1 - usedBytes / totalBytes) * 100).toFixed(1), } }) // Save for analysis const outputDir = path.resolve(process.cwd(), "css-coverage") fs.mkdirSync(outputDir, { recursive: true }) fs.writeFileSync( path.join(outputDir, `${testInfo.testId}.json`), JSON.stringify(report, null, 2) ) } }, }) export { expect } ``` ## Decision Guide | Coverage Type | Tool | Measures | Setup Effort | Accuracy | | -------------------------- | ------------------------------------------------ | ----------------------- | ------------ | ------------------- | | V8 (Chrome DevTools) | `page.coverage` API | JS execution in browser | Low | High (runtime) | | Istanbul (instrumentation) | `babel-plugin-istanbul` / `vite-plugin-istanbul` | JS with source maps | Medium | High (source-level) | | CSS | `page.coverage.startCSSCoverage()` | CSS rule usage | Low | High | | Metric | What It Measures | Good Threshold | Why | | ---------- | ------------------------------ | -------------- | ------------------------------------------------- | | Lines | Lines of code executed | 80%+ | Most intuitive; catches dead code | | Branches | If/else paths taken | 70%+ | Catches untested conditionals | | Functions | Functions called | 75%+ | Catches unused functions | | Statements | Individual statements executed | 80%+ | Similar to lines but counts multi-statement lines | | Scenario | Approach | Why | | ------------------------------ | ------------------------------------ | ------------------------------------- | | Just starting with coverage | V8 coverage, no thresholds | Establish baseline first | | Mature codebase | Istanbul + thresholds in CI | Source-mapped, enforced | | Sharded CI | Merge `.nyc_output` after all shards | Aggregate coverage view | | Only Chromium matters | V8 coverage fixture | Simplest setup, no build changes | | Need source-level accuracy | Istanbul with bundler plugin | Maps to original source | | Uploading to Codecov/SonarQube | Generate `lcov` format | Standard format for coverage services | ## Anti-Patterns | Anti-Pattern | Problem | Do This Instead | | --------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------- | | Targeting 100% E2E coverage | E2E tests are expensive; diminishing returns past 80% | Set realistic thresholds; use unit tests for edge cases | | Coverage collection in all browsers | V8 coverage only works in Chromium; wasted effort | Run coverage only with `--project=chromium` | | No source filtering | Coverage includes `node_modules` and third-party code | Filter: `entry.url.includes('localhost') && !entry.url.includes('node_modules')` | | Coverage slowing down CI without value | Collecting coverage on every PR with no thresholds | Either enforce thresholds or skip coverage | | Istanbul instrumentation in production builds | Performance overhead; exposes internals | Only instrument when `COVERAGE=true` | | Measuring E2E coverage alone | E2E tests cover happy paths; unit tests cover branches | Combine E2E coverage with unit test coverage | ## Troubleshooting ### Coverage report shows 0% or empty **Cause (V8)**: Tests aren't using the coverage fixture, or `startJSCoverage()` wasn't called. **Fix**: Ensure tests import from the coverage fixture file, not directly from `@playwright/test`: ```ts // Use this: import { test, expect } from "../fixtures/coverage" // Not this: import { test, expect } from "@playwright/test" ``` **Cause (Istanbul)**: Application wasn't instrumented, or `window.__coverage__` is undefined. **Fix**: Verify the app is running with instrumentation: ```bash # Check if coverage is exposed COVERAGE=true npm run dev # In browser console: window.__coverage__ -> should be an object, not undefined ``` ### Coverage files not merging correctly **Cause**: Coverage JSON files from different shards have incompatible formats or overlapping keys. **Fix**: Ensure all shards write to `.nyc_output/` with unique filenames: ```ts const coverageFile = path.join(coverageDir, `coverage-${crypto.randomUUID()}.json`) ``` Then merge with `npx nyc report` (it reads all files in `.nyc_output/`). ### V8 coverage doesn't map to source files **Cause**: V8 coverage reports against bundled/compiled URLs, not original source files. **Fix**: Use Istanbul instrumentation instead for source-level accuracy, or use `v8-to-istanbul` to convert: ```bash npm install -D v8-to-istanbul ``` ### Coverage drops when running in parallel **Cause**: Parallel workers override each other's coverage files if filenames collide. **Fix**: Include `workerInfo.workerIndex` or a UUID in the filename: ```ts const coverageFile = path.join( coverageDir, `coverage-worker${workerInfo.workerIndex}-${crypto.randomUUID()}.json` ) ``` ## Related - [ci/parallel-and-sharding.md](parallel-and-sharding.md) -- merging coverage from sharded runs - [ci/ci-github-actions.md](ci-github-actions.md) -- coverage thresholds in CI - [ci/reporting-and-artifacts.md](reporting-and-artifacts.md) -- uploading coverage reports - [core/configuration.md](../core/configuration.md) -- project setup for coverage - [core/test-architecture.md](../core/test-architecture.md) -- E2E vs unit test coverage strategies