Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
infrastructure-ci-cd/test-coverage.md
1# Test Coverage23## Table of Contents451. [Coverage Setup](#coverage-setup)62. [Collecting Coverage](#collecting-coverage)73. [Coverage Reports](#coverage-reports)84. [Coverage Thresholds](#coverage-thresholds)95. [Advanced Patterns](#advanced-patterns)106. [CI Integration](#ci-integration)1112## Coverage Setup1314### Install Dependencies1516```bash17# For V8 coverage (built into Playwright)18# No additional dependencies needed1920# For Istanbul-based coverage (more features)21npm install -D nyc @istanbuljs/nyc-config-typescript22```2324### Basic Configuration2526```typescript27// playwright.config.ts28import { defineConfig } from "@playwright/test";2930export default defineConfig({31use: {32// Enable coverage collection33contextOptions: {34// V8 coverage is automatic with the API below35},36},37});38```3940### V8 Coverage Fixture4142```typescript43// fixtures/coverage.ts44import { test as base, expect } from "@playwright/test";45import fs from "fs";46import path from "path";47import { randomUUID } from "crypto";4849export const test = base.extend<{}, { collectCoverage: void }>({50collectCoverage: [51async ({ browser }, use) => {52// Start coverage for all pages53const context = await browser.newContext();54const page = await context.newPage();5556await page.coverage.startJSCoverage();57await page.coverage.startCSSCoverage();5859await use();6061// Collect coverage62const [jsCoverage, cssCoverage] = await Promise.all([63page.coverage.stopJSCoverage(),64page.coverage.stopCSSCoverage(),65]);6667// Save coverage data68const coverageDir = "./coverage";69if (!fs.existsSync(coverageDir)) {70fs.mkdirSync(coverageDir, { recursive: true });71}7273fs.writeFileSync(74path.join(coverageDir, `coverage-${randomUUID()}.json`),75JSON.stringify([...jsCoverage, ...cssCoverage])76);7778await context.close();79},80{ scope: "worker", auto: true },81],82});83```8485## Collecting Coverage8687### Per-Test Coverage8889```typescript90test("collect coverage for single test", async ({ page }) => {91// Start coverage collection92await page.coverage.startJSCoverage({93resetOnNavigation: false,94});9596// Run test97await page.goto("/app");98await page.getByRole("button", { name: "Submit" }).click();99await expect(page.getByText("Success")).toBeVisible();100101// Stop and get coverage102const coverage = await page.coverage.stopJSCoverage();103104// Filter to only your source files105const appCoverage = coverage.filter((entry) => entry.url.includes("/src/"));106107console.log(`Covered ${appCoverage.length} source files`);108});109```110111### Coverage for Specific Files112113```typescript114test("track specific module coverage", async ({ page }) => {115await page.coverage.startJSCoverage();116117await page.goto("/checkout");118await page.getByRole("button", { name: "Pay" }).click();119120const coverage = await page.coverage.stopJSCoverage();121122// Find coverage for checkout module123const checkoutCoverage = coverage.find((c) => c.url.includes("checkout.js"));124125if (checkoutCoverage) {126const totalBytes = checkoutCoverage.text?.length || 0;127const coveredBytes = checkoutCoverage.ranges.reduce(128(sum, range) => sum + (range.end - range.start),1290130);131const percentage = (coveredBytes / totalBytes) * 100;132133console.log(`Checkout module: ${percentage.toFixed(1)}% covered`);134expect(percentage).toBeGreaterThan(80);135}136});137```138139### CSS Coverage140141```typescript142test("collect CSS coverage", async ({ page }) => {143await page.coverage.startCSSCoverage();144145await page.goto("/app");146147// Interact to trigger different CSS states148await page.getByRole("button").hover();149await page.getByRole("dialog").waitFor();150151const cssCoverage = await page.coverage.stopCSSCoverage();152153// Find unused CSS154for (const entry of cssCoverage) {155const totalBytes = entry.text?.length || 0;156const usedBytes = entry.ranges.reduce(157(sum, range) => sum + (range.end - range.start),1580159);160const unusedPercentage = ((totalBytes - usedBytes) / totalBytes) * 100;161162if (unusedPercentage > 50) {163console.warn(`${entry.url}: ${unusedPercentage.toFixed(1)}% unused CSS`);164}165}166});167```168169## Coverage Reports170171### Converting to Istanbul Format172173```typescript174// scripts/convert-coverage.ts175import { execSync } from "child_process";176import fs from "fs";177import path from "path";178import v8ToIstanbul from "v8-to-istanbul";179180async function convertCoverage() {181const coverageDir = "./coverage";182const files = fs.readdirSync(coverageDir).filter((f) => f.endsWith(".json"));183184const istanbulCoverage: any = {};185186for (const file of files) {187const coverageData = JSON.parse(188fs.readFileSync(path.join(coverageDir, file), "utf-8")189);190191for (const entry of coverageData) {192if (!entry.url.startsWith("file://")) continue;193194const filePath = entry.url.replace("file://", "");195const converter = v8ToIstanbul(filePath);196197await converter.load();198converter.applyCoverage(entry.functions || []);199200const istanbul = converter.toIstanbul();201Object.assign(istanbulCoverage, istanbul);202}203}204205fs.writeFileSync(206path.join(coverageDir, "coverage-final.json"),207JSON.stringify(istanbulCoverage)208);209}210211convertCoverage();212```213214### Generating HTML Report215216```bash217# Using nyc to generate report218npx nyc report --reporter=html --reporter=text --temp-dir=./coverage219```220221```typescript222// package.json scripts223{224"scripts": {225"test": "playwright test",226"test:coverage": "playwright test && npm run coverage:report",227"coverage:report": "npx nyc report --reporter=html --reporter=lcov --temp-dir=./coverage"228}229}230```231232### Custom Coverage Reporter233234```typescript235// reporters/coverage-reporter.ts236import type { Reporter, FullResult } from "@playwright/test/reporter";237import fs from "fs";238import path from "path";239240class CoverageReporter implements Reporter {241private coverageData: any[] = [];242243onEnd(result: FullResult) {244// Aggregate all coverage files245const coverageDir = "./coverage";246const files = fs247.readdirSync(coverageDir)248.filter((f) => f.endsWith(".json"));249250for (const file of files) {251const data = JSON.parse(252fs.readFileSync(path.join(coverageDir, file), "utf-8")253);254this.coverageData.push(...data);255}256257// Generate summary258const summary = this.generateSummary();259console.log("\n📊 Coverage Summary:");260console.log(` Files: ${summary.totalFiles}`);261console.log(` Lines: ${summary.lineCoverage.toFixed(1)}%`);262console.log(` Bytes: ${summary.byteCoverage.toFixed(1)}%`);263264if (summary.lineCoverage < 80) {265console.warn("⚠️ Coverage below 80% threshold!");266}267}268269private generateSummary() {270let totalBytes = 0;271let coveredBytes = 0;272const files = new Set<string>();273274for (const entry of this.coverageData) {275if (entry.url.includes("/src/")) {276files.add(entry.url);277totalBytes += entry.text?.length || 0;278coveredBytes += entry.ranges.reduce(279(sum: number, r: any) => sum + (r.end - r.start),2800281);282}283}284285return {286totalFiles: files.size,287byteCoverage: (coveredBytes / totalBytes) * 100,288lineCoverage: (coveredBytes / totalBytes) * 100, // Simplified289};290}291}292293export default CoverageReporter;294```295296## Coverage Thresholds297298### Enforcing Minimum Coverage299300```typescript301// tests/coverage.spec.ts302import { test, expect } from "@playwright/test";303import fs from "fs";304import path from "path";305306test.afterAll(async () => {307const coverageDir = "./coverage";308const files = fs.readdirSync(coverageDir).filter((f) => f.endsWith(".json"));309310let totalBytes = 0;311let coveredBytes = 0;312313for (const file of files) {314const coverage = JSON.parse(315fs.readFileSync(path.join(coverageDir, file), "utf-8")316);317318for (const entry of coverage) {319if (!entry.url.includes("/src/")) continue;320totalBytes += entry.text?.length || 0;321coveredBytes += entry.ranges.reduce(322(sum: number, r: any) => sum + (r.end - r.start),3230324);325}326}327328const coveragePercent = (coveredBytes / totalBytes) * 100;329330// Enforce threshold331expect(coveragePercent).toBeGreaterThan(80);332});333```334335### Per-Directory Thresholds336337```typescript338// coverage-check.ts339interface CoverageThreshold {340pattern: RegExp;341minCoverage: number;342}343344const thresholds: CoverageThreshold[] = [345{ pattern: /\/src\/core\//, minCoverage: 90 },346{ pattern: /\/src\/utils\//, minCoverage: 85 },347{ pattern: /\/src\/components\//, minCoverage: 70 },348{ pattern: /\/src\/pages\//, minCoverage: 60 },349];350351function checkThresholds(coverage: any[]): string[] {352const violations: string[] = [];353354for (const threshold of thresholds) {355const matchingFiles = coverage.filter((c) => threshold.pattern.test(c.url));356357let total = 0;358let covered = 0;359360for (const file of matchingFiles) {361total += file.text?.length || 0;362covered += file.ranges.reduce(363(sum: number, r: any) => sum + (r.end - r.start),3640365);366}367368const percent = total > 0 ? (covered / total) * 100 : 0;369370if (percent < threshold.minCoverage) {371violations.push(372`${threshold.pattern}: ${percent.toFixed(1)}% < ${373threshold.minCoverage374}%`375);376}377}378379return violations;380}381```382383## Advanced Patterns384385### Merging Coverage Across Shards386387```typescript388// scripts/merge-coverage.ts389import fs from "fs";390import { glob } from "glob";391392async function mergeCoverage() {393const files = await glob("shard-*/coverage/*.json");394const merged = new Map<string, any>();395396for (const file of files) {397const data = JSON.parse(fs.readFileSync(file, "utf-8"));398for (const entry of data) {399if (merged.has(entry.url)) {400const existing = merged.get(entry.url);401existing.ranges.push(...entry.ranges);402} else {403merged.set(entry.url, { ...entry });404}405}406}407408fs.writeFileSync(409"./coverage/merged.json",410JSON.stringify([...merged.values()])411);412}413414mergeCoverage();415```416417### Incremental Coverage418419```typescript420// Check coverage only for changed files in CI421import { execSync } from "child_process";422import fs from "fs";423424const changedFiles = execSync("git diff --name-only HEAD~1")425.toString()426.split("\n")427.filter((f) => f.endsWith(".ts"));428429const coverage = JSON.parse(fs.readFileSync("./coverage/merged.json", "utf-8"));430431for (const file of changedFiles) {432const entry = coverage.find((c: any) => c.url.includes(file));433if (entry) {434const percent =435(entry.ranges.reduce((s: number, r: any) => s + r.end - r.start, 0) /436(entry.text?.length || 1)) *437100;438console.log(`${file}: ${percent.toFixed(1)}%`);439}440}441```442443## CI Integration444445### GitHub Actions446447```yaml448# .github/workflows/test.yml449name: Tests with Coverage450451on: [push, pull_request]452453jobs:454test:455runs-on: ubuntu-latest456steps:457- uses: actions/checkout@v4458459- uses: actions/setup-node@v4460with:461node-version: 22462463- run: npm ci464- run: npx playwright install --with-deps465466- name: Run tests with coverage467run: npm run test:coverage468469- name: Upload coverage to Codecov470uses: codecov/codecov-action@v3471with:472files: ./coverage/lcov.info473fail_ci_if_error: true474475- name: Check coverage threshold476run: |477COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')478if (( $(echo "$COVERAGE < 80" | bc -l) )); then479echo "Coverage $COVERAGE% is below 80% threshold"480exit 1481fi482```483484## Anti-Patterns to Avoid485486| Anti-Pattern | Problem | Solution |487| ---------------------------- | -------------------------------------- | --------------------------- |488| Coverage for coverage's sake | Gaming metrics | Focus on critical paths |489| 100% coverage target | Diminishing returns, tests for getters | Set realistic thresholds |490| Ignoring coverage drops | Technical debt | Enforce thresholds in CI |491| No source map support | Wrong line numbers | Enable source maps in build |492| Coverage only in CI | Late feedback | Run locally too |493494## Related References495496- **CI/CD**: See [ci-cd.md](ci-cd.md) for pipeline configuration497- **Performance**: See [performance.md](performance.md) for optimizing coverage collection498