Coverage Analysis Skill
This skill helps you analyze and improve test coverage across the monorepo using Vitest's V8 coverage provider.
When to Use This Skill
-
Analyzing test coverage across packages
-
Identifying untested code paths
-
Setting coverage thresholds
-
Generating coverage reports
-
Improving test quality
-
Pre-deployment coverage checks
-
Code review coverage validation
Coverage Overview
The project uses Vitest with V8 coverage provider for:
-
Line coverage: Percentage of lines executed
-
Branch coverage: Percentage of conditional branches tested
-
Function coverage: Percentage of functions called
-
Statement coverage: Percentage of statements executed
Configuration
Vitest Coverage Config
// vitest.config.ts (root or package-level) import { defineConfig } from "vitest/config";
export default defineConfig({ test: { globals: true, environment: "node", // or "jsdom" for frontend coverage: { provider: "v8", reporter: ["text", "json", "html", "lcov"], reportsDirectory: "./coverage", exclude: [ "node_modules/", "tests/", "/*.test.ts", "/.spec.ts", "dist/", "build/", ".config.ts", ".config.js", ".next/", ".turbo/", ], include: ["src/**/.ts", "src/**/*.tsx"], all: true, thresholds: { lines: 80, functions: 80, branches: 80, statements: 80, }, }, }, });
Package-Specific Configs
API Package:
// apps/api/vitest.config.ts import { defineConfig } from "vitest/config";
export default defineConfig({ test: { coverage: { provider: "v8", reporter: ["text", "json", "html"], thresholds: { lines: 85, // Higher threshold for backend functions: 85, branches: 80, statements: 85, }, exclude: [ "tests/", "src/index.ts", // Exclude entry point "src/config/**", // Exclude config files ], }, }, });
Web Package:
// apps/web/vitest.config.ts import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()], test: { environment: "jsdom", coverage: { provider: "v8", reporter: ["text", "json", "html"], thresholds: { lines: 75, // Frontend may have lower threshold functions: 75, branches: 70, statements: 75, }, exclude: [ "tests/", "src/app/", // Exclude Next.js app directory "/.config.", ], }, }, });
Database Package:
// packages/database/vitest.config.ts import { defineConfig } from "vitest/config";
export default defineConfig({ test: { coverage: { provider: "v8", reporter: ["text", "json", "html"], thresholds: { lines: 90, // Very high threshold for critical package functions: 90, branches: 85, statements: 90, }, include: ["src//*.ts"], exclude: [ "tests/", "migrations/", // Exclude migrations ], }, }, });
Running Coverage
Common Commands
Generate coverage for all packages
pnpm test:coverage
Generate coverage for specific package
pnpm -F @sgcarstrends/api test:coverage pnpm -F @sgcarstrends/web test:coverage pnpm -F @sgcarstrends/database test:coverage
Generate coverage with specific reporters
pnpm test:coverage -- --coverage.reporter=html pnpm test:coverage -- --coverage.reporter=lcov
Run tests and generate coverage in watch mode
pnpm test:watch -- --coverage
Generate coverage for changed files only
pnpm test:coverage -- --changed
Package.json Scripts
{ "scripts": { "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:coverage:ui": "vitest --ui --coverage" } }
Coverage Reports
Text Report
Terminal output
pnpm test:coverage
Example output:
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 87.5 | 83.33 | 85.71 | 87.5 |
cars.ts | 90 | 85 | 100 | 90 | 45-47
coe.ts | 85 | 80 | 75 | 85 | 23, 56-58
----------|---------|----------|---------|---------|-------------------
HTML Report
Generate HTML report
pnpm test:coverage
Open in browser
open coverage/index.html # macOS xdg-open coverage/index.html # Linux start coverage/index.html # Windows
HTML report features:
- Interactive file browser
- Line-by-line coverage visualization
- Color-coded coverage (green = covered, red = uncovered)
- Branch coverage details
JSON Report
Generate JSON report
pnpm test:coverage -- --coverage.reporter=json
Output: coverage/coverage-final.json
{ "path/to/file.ts": { "lines": { "1": 1, "2": 1, "3": 0 }, "functions": { "functionName": 1 }, "branches": { "0": [1, 0] }, "statements": { "1": 1, "2": 1 } } }
LCOV Report
Generate LCOV format (for CI tools like Codecov, Coveralls)
pnpm test:coverage -- --coverage.reporter=lcov
Output: coverage/lcov.info
Coverage Thresholds
Setting Thresholds
// vitest.config.ts export default defineConfig({ test: { coverage: { thresholds: { // Global thresholds lines: 80, functions: 80, branches: 80, statements: 80,
// Per-file thresholds
perFile: true,
// Fail build if below thresholds
100: false, // Don't require 100% coverage
},
},
}, });
Enforcing Thresholds in CI
.github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs: coverage: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 - uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm"
- run: pnpm install
- run: pnpm test:coverage
# Fail if coverage below threshold
- name: Check coverage
run: |
if [ $(jq '.total.lines.pct' coverage/coverage-summary.json | cut -d. -f1) -lt 80 ]; then
echo "Coverage below 80%"
exit 1
fi
Analyzing Coverage
Identify Untested Files
Generate coverage for all files (including untested)
pnpm test:coverage -- --coverage.all=true
Find files with 0% coverage
grep -r '"pct": 0' coverage/coverage-final.json
Find Uncovered Lines
Generate HTML report and inspect
pnpm test:coverage open coverage/index.html
Look for red-highlighted lines in HTML report
These are uncovered lines that need tests
Check Branch Coverage
// Example: Find uncovered branches function processData(data: any) { // Branch 1: if condition if (data.value > 10) { return "high"; }
// Branch 2: else condition (uncovered) return "low"; }
// Test both branches describe("processData", () => { it("should return high for values > 10", () => { expect(processData({ value: 15 })).toBe("high"); });
it("should return low for values <= 10", () => { expect(processData({ value: 5 })).toBe("low"); }); });
Improving Coverage
Strategy 1: Test Untested Functions
// Find untested function export function calculateCOEPrice(quota: number, bids: number): number { // Untested return quota > 0 ? bids / quota : 0; }
// Add test describe("calculateCOEPrice", () => { it("should calculate price when quota is positive", () => { expect(calculateCOEPrice(100, 50000)).toBe(500); });
it("should return 0 when quota is 0", () => { expect(calculateCOEPrice(0, 50000)).toBe(0); }); });
Strategy 2: Test Error Paths
// Original: Only happy path tested
export async function fetchCarData(month: string) {
const res = await fetch(/api/cars?month=${month});
return res.json(); // What if fetch fails?
}
// Improved: Test error path describe("fetchCarData", () => { it("should fetch data successfully", async () => { // Happy path test });
it("should handle network errors", async () => { vi.spyOn(global, "fetch").mockRejectedValue(new Error("Network error"));
await expect(fetchCarData("2024-01")).rejects.toThrow("Network error");
});
it("should handle non-200 responses", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: false, status: 500, } as Response);
await expect(fetchCarData("2024-01")).rejects.toThrow();
}); });
Strategy 3: Test Edge Cases
// Original: Basic test
export function formatMonth(date: Date): string {
return ${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")};
}
// Improved: Test edge cases describe("formatMonth", () => { it("should format single-digit months", () => { expect(formatMonth(new Date("2024-01-01"))).toBe("2024-01"); });
it("should format double-digit months", () => { expect(formatMonth(new Date("2024-12-01"))).toBe("2024-12"); });
it("should handle leap years", () => { expect(formatMonth(new Date("2024-02-29"))).toBe("2024-02"); }); });
Strategy 4: Test Conditional Branches
// Function with multiple branches export function getVehicleCategory(type: string): string { if (type === "car") return "Category A"; if (type === "motorcycle") return "Category B"; if (type === "taxi") return "Category C"; return "Unknown"; // Often forgotten! }
// Test all branches describe("getVehicleCategory", () => { it.each([ ["car", "Category A"], ["motorcycle", "Category B"], ["taxi", "Category C"], ["bus", "Unknown"], ])("should return %s for %s", (type, expected) => { expect(getVehicleCategory(type)).toBe(expected); }); });
Coverage in CI/CD
GitHub Actions Integration
.github/workflows/coverage.yml
name: Coverage
on: [push, pull_request]
jobs: coverage: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 - uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm"
- run: pnpm install
- run: pnpm test:coverage
# Upload coverage to Codecov
- uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
flags: unittests
name: codecov-umbrella
# Upload coverage as artifact
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
Coverage Badges
Coverage Exclusions
Exclude Specific Code
// Exclude line /* v8 ignore next */ console.log("Debug statement");
// Exclude block /* v8 ignore start / if (process.env.NODE_ENV === "development") { console.log("Development only"); } / v8 ignore stop */
// Exclude function /* v8 ignore next 5 */ function debugHelper() { // This entire function is excluded console.log("Debug"); }
Exclude Files/Directories
// vitest.config.ts export default defineConfig({ test: { coverage: { exclude: [ // Test files "/tests/", "/*.test.ts", "/*.spec.ts",
// Config files
"**/*.config.ts",
"**/*.config.js",
// Build output
"dist/**",
"build/**",
".next/**",
// Specific files
"src/index.ts",
"src/generated/**",
// External dependencies
"node_modules/**",
],
},
}, });
Monorepo Coverage
Aggregate Coverage
Generate coverage for all packages
pnpm -r test:coverage
Merge coverage reports (requires custom script)
node scripts/merge-coverage.js
Custom Merge Script
// scripts/merge-coverage.ts import { readFileSync, writeFileSync } from "fs"; import { glob } from "glob";
const coverageFiles = glob.sync("/coverage/coverage-final.json", { ignore: ["node_modules/"], });
const merged: any = {};
for (const file of coverageFiles) { const coverage = JSON.parse(readFileSync(file, "utf-8")); Object.assign(merged, coverage); }
writeFileSync("coverage-merged.json", JSON.stringify(merged, null, 2)); console.log("Coverage merged successfully");
Best Practices
- Set Realistic Thresholds
// ❌ Too strict (100% is often impractical) thresholds: { lines: 100, functions: 100, branches: 100, statements: 100, }
// ✅ Realistic and achievable thresholds: { lines: 80, functions: 80, branches: 75, statements: 80, }
- Exclude Generated Code
// vitest.config.ts coverage: { exclude: [ "src/generated/", ".config.", "tests/", ], }
- Focus on Critical Paths
// Prioritize testing: // 1. Business logic // 2. Data transformations // 3. API endpoints // 4. Error handling
// Less critical: // - UI components (test functionality, not styling) // - Configuration files // - Type definitions
- Track Coverage Over Time
Store coverage in git (add to .gitignore exceptions)
!coverage/coverage-summary.json
Track changes
git diff coverage/coverage-summary.json
Troubleshooting
Coverage Not Generated
Issue: No coverage directory created
Solution: Ensure tests are running
pnpm test # First run tests pnpm test:coverage # Then generate coverage
Low Coverage Despite Tests
Issue: Coverage config excludes tested files
Solution: Check exclude patterns
vitest.config.ts
coverage: { exclude: [ // Remove overly broad patterns // "src/**", // ❌ This excludes everything! ], }
Coverage Report Empty
Issue: Tests passing but coverage 0%
Solution: Ensure coverage.all is true
coverage: { all: true, // Include all source files include: ["src/**/*.ts"], }
Threshold Failures
Issue: Coverage below threshold
Solution: Add missing tests or adjust thresholds
Lower threshold temporarily
thresholds: { lines: 70, // Reduced from 80 }
Or add tests to increase coverage
References
-
Vitest Coverage: https://vitest.dev/guide/coverage
-
V8 Coverage: https://v8.dev/blog/javascript-code-coverage
-
Istanbul (alternative): https://istanbul.js.org
-
Related files:
-
vitest.config.ts
-
Coverage configuration
-
Root CLAUDE.md - Testing guidelines
Best Practices Summary
-
Set Realistic Thresholds: 80% is good, 100% is often impractical
-
Exclude Non-Critical Code: Config files, generated code, tests
-
Focus on Critical Paths: Business logic, APIs, error handling
-
Test All Branches: Ensure conditional logic is tested
-
Track Over Time: Monitor coverage trends
-
Use HTML Reports: Visualize uncovered lines
-
Integrate with CI: Enforce thresholds in pipelines
-
Don't Game Coverage: Write meaningful tests, not just for coverage