Identify untested code paths, uncovered branches, and missing test scenarios using coverage analysis, risk mapping, and change-based coverage tracking
Test coverage analysis identifies which parts of the codebase are exercised by the test suite and, more importantly, which parts are not. Raw coverage percentages are misleading without context: 80% statement coverage might mean the most critical error-handling paths are completely untested while trivial getters are thoroughly covered. This skill guides AI coding agents through comprehensive coverage gap analysis that goes beyond percentages to identify high-risk untested code, enforce coverage on new changes, and generate actionable test recommendations.
Coverage Is a Diagnostic Tool, Not a Goal: High coverage does not guarantee test quality. A test that executes every line without meaningful assertions provides coverage with zero defect detection capability. Use coverage to find what is missing, not as proof of quality.
Branch Coverage Over Statement Coverage: Statement coverage counts whether a line executed; branch coverage counts whether both true and false paths of every conditional executed. A function with an early return can have 100% statement coverage but 50% branch coverage if only one path is tested.
Risk-Weighted Coverage: Not all code carries equal risk. Payment processing, authentication, and data validation deserve 100% coverage. Configuration constants and simple data transfer objects do not. Prioritize coverage gaps by business risk.
Change-Based Coverage Is Non-Negotiable: Tracking coverage of new and modified code ensures that every change ships with tests. Legacy code coverage gaps are inherited, but new gaps are preventable.
Dead Code Is Not a Coverage Gap: Code that is never reached in production is not an untested path needing tests; it is dead code needing removal. Distinguish between untested live code and genuinely unreachable code before writing tests.
Coverage Trends Matter More Than Snapshots: A codebase at 70% coverage and improving is healthier than one at 85% and declining. Track coverage over time to detect erosion before it becomes a problem.
Exclude What Does Not Belong: Generated code, vendor libraries, type definitions, and configuration files inflate or deflate coverage numbers without providing signal. Exclude them to keep coverage metrics meaningful.
project-root/
├── src/
│ ├── controllers/
│ │ ├── user.controller.ts
│ │ └── order.controller.ts
│ ├── services/
│ │ ├── payment.service.ts
│ │ └── notification.service.ts
│ ├── utils/
│ │ ├── validators.ts
│ │ └── formatters.ts
│ └── types/
│ └── index.ts
├── tests/
│ ├── unit/
│ │ ├── payment.test.ts
│ │ └── validators.test.ts
│ └── integration/
│ └── order-flow.test.ts
├── coverage/
│ ├── lcov.info
│ ├── coverage-summary.json
│ └── html/
│ └── index.html
├── scripts/
│ ├── coverage-gap-analysis.ts
│ ├── change-coverage.ts
│ ├── risk-coverage-map.ts
│ └── coverage-trend.ts
├── .nycrc.json
├── jest.config.ts
├── vitest.config.ts
└── coverage.config.ts
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverage: true,
coverageProvider: 'v8', // V8 is faster and more accurate than Istanbul for Node.js
// Coverage collection targets
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts', // Exclude type definitions
'!src/**/index.ts', // Exclude barrel files
'!src/types/**', // Exclude type-only files
'!src/**/*.stories.{ts,tsx}', // Exclude Storybook stories
'!src/**/mocks/**', // Exclude test mocks
'!src/generated/**', // Exclude generated code
],
// Coverage output formats
coverageReporters: [
'text', // Console summary
'text-summary', // Brief console summary
'lcov', // For CI tools (SonarQube, Codecov)
'json-summary', // Machine-readable summary
'json', // Detailed per-file data
'html', // Interactive HTML report
'clover', // Clover XML format
],
// Coverage thresholds
coverageThreshold: {
global: {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
// Per-directory thresholds for critical paths
'./src/services/payment*.ts': {
branches: 95,
functions: 100,
lines: 95,
statements: 95,
},
'./src/utils/validators.ts': {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},
coverageDirectory: 'coverage',
};
export default config;
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
enabled: true,
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.d.ts',
'src/types/**',
'src/**/*.test.{ts,tsx}',
'src/**/*.spec.{ts,tsx}',
'src/generated/**',
'src/**/index.ts',
'node_modules/**',
],
// Report formats
reporter: ['text', 'json-summary', 'lcov', 'html'],
reportsDirectory: './coverage',
// Thresholds
thresholds: {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
// Fail the test run if thresholds are not met
thresholdAutoUpdate: false,
// Show uncovered lines in console output
all: true, // Include files with zero coverage
},
},
});
# pytest.ini or pyproject.toml [tool.pytest.ini_options]
[pytest]
addopts =
--cov=src
--cov-report=term-missing
--cov-report=html:coverage/html
--cov-report=json:coverage/coverage.json
--cov-report=lcov:coverage/lcov.info
--cov-branch
--cov-fail-under=80
# pyproject.toml
[tool.coverage.run]
source = ["src"]
branch = true
omit = [
"src/types/*",
"src/generated/*",
"src/**/test_*.py",
"src/**/__init__.py",
]
[tool.coverage.report]
fail_under = 80
show_missing = true
skip_covered = false
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"@overload",
]
[tool.coverage.html]
directory = "coverage/html"
Understanding the differences between coverage types is essential for accurate gap analysis.
// src/services/payment.service.ts
export class PaymentService {
async processPayment(amount: number, method: string): Promise<PaymentResult> {
// Statement: this line is line 1
if (amount <= 0) {
// Branch A (true): amount is invalid
throw new PaymentError('Invalid amount');
}
// Branch A (false): amount is valid - falls through
// Statement: this line is line 2
if (method === 'credit_card') {
// Branch B (true): credit card path
return this.processCreditCard(amount);
} else if (method === 'paypal') {
// Branch C (true): PayPal path
return this.processPayPal(amount);
} else {
// Branch D (default): unsupported method
throw new PaymentError(`Unsupported payment method: ${method}`);
}
}
}
// TEST: Only tests the credit card happy path
describe('PaymentService', () => {
it('processes credit card payment', async () => {
const service = new PaymentService();
const result = await service.processPayment(100, 'credit_card');
expect(result.status).toBe('success');
});
});
// Coverage analysis:
// Statement coverage: ~60% (lines 1-2 executed, but PayPal and error paths not)
// Branch coverage: ~33% (only Branch A-false and Branch B-true)
// Function coverage: ~33% (processPayment called, but not processPayPal)
// GAPS: negative amount, PayPal path, unsupported method path
// scripts/coverage-gap-analysis.ts
import * as fs from 'fs';
import * as path from 'path';
interface CoverageEntry {
path: string;
statementMap: Record<string, { start: Location; end: Location }>;
s: Record<string, number>; // Statement hit counts
branchMap: Record<string, { type: string; loc: Location; locations: Location[] }>;
b: Record<string, number[]>; // Branch hit counts per branch
fnMap: Record<string, { name: string; loc: Location; decl: Location }>;
f: Record<string, number>; // Function hit counts
}
interface Location {
line: number;
column: number;
}
interface CoverageGap {
file: string;
type: 'statement' | 'branch' | 'function';
location: { startLine: number; endLine: number };
description: string;
riskLevel: 'critical' | 'high' | 'medium' | 'low';
suggestion: string;
}
function analyzeCoverageGaps(coverageJsonPath: string): CoverageGap[] {
const coverageData: Record<string, CoverageEntry> = JSON.parse(
fs.readFileSync(coverageJsonPath, 'utf-8')
);
const gaps: CoverageGap[] = [];
for (const [filePath, entry] of Object.entries(coverageData)) {
const relativePath = path.relative(process.cwd(), filePath);
// Find uncovered statements
for (const [stmtId, hitCount] of Object.entries(entry.s)) {
if (hitCount === 0) {
const loc = entry.statementMap[stmtId];
gaps.push({
file: relativePath,
type: 'statement',
location: { startLine: loc.start.line, endLine: loc.end.line },
description: `Uncovered statement at line ${loc.start.line}`,
riskLevel: assessRisk(relativePath, loc.start.line),
suggestion: `Add a test that exercises the code path at line ${loc.start.line}`,
});
}
}
// Find uncovered branches
for (const [branchId, hitCounts] of Object.entries(entry.b)) {
const branchInfo = entry.branchMap[branchId];
hitCounts.forEach((count, index) => {
if (count === 0) {
const loc = branchInfo.locations[index] || branchInfo.loc;
const branchType = index === 0 ? 'true' : 'false';
gaps.push({
file: relativePath,
type: 'branch',
location: { startLine: loc.line, endLine: loc.line },
description: `Uncovered ${branchType} branch of ${branchInfo.type} at line ${branchInfo.loc.line}`,
riskLevel: assessRisk(relativePath, loc.line),
suggestion: `Add a test for the ${branchType} path of the ${branchInfo.type} conditional at line ${branchInfo.loc.line}`,
});
}
});
}
// Find uncovered functions
for (const [fnId, hitCount] of Object.entries(entry.f)) {
if (hitCount === 0) {
const fnInfo = entry.fnMap[fnId];
gaps.push({
file: relativePath,
type: 'function',
location: { startLine: fnInfo.loc.start.line, endLine: fnInfo.loc.end.line },
description: `Uncovered function "${fnInfo.name}" at line ${fnInfo.loc.start.line}`,
riskLevel: assessRisk(relativePath, fnInfo.loc.start.line),
suggestion: `Add tests for the "${fnInfo.name}" function covering its main paths`,
});
}
}
}
return gaps.sort((a, b) => {
const riskOrder = { critical: 0, high: 1, medium: 2, low: 3 };
return riskOrder[a.riskLevel] - riskOrder[b.riskLevel];
});
}
function assessRisk(filePath: string, line: number): 'critical' | 'high' | 'medium' | 'low' {
// Critical: payment, auth, security
if (/payment|billing|charge|refund/i.test(filePath)) return 'critical';
if (/auth|login|session|token|password/i.test(filePath)) return 'critical';
if (/security|encrypt|decrypt|hash/i.test(filePath)) return 'critical';
// High: data validation, API controllers
if (/valid|sanitiz|controller|handler/i.test(filePath)) return 'high';
if (/service/i.test(filePath)) return 'high';
// Medium: utilities, helpers
if (/util|helper|format/i.test(filePath)) return 'medium';
// Low: configuration, constants, types
if (/config|constant|type|interface/i.test(filePath)) return 'low';
return 'medium';
}
// Run analysis
const gaps = analyzeCoverageGaps('coverage/coverage-final.json');
console.log(`\nCoverage Gap Analysis Report`);
console.log(`${'='.repeat(60)}`);
console.log(`Total gaps found: ${gaps.length}`);
console.log(` Critical: ${gaps.filter((g) => g.riskLevel === 'critical').length}`);
console.log(` High: ${gaps.filter((g) => g.riskLevel === 'high').length}`);
console.log(` Medium: ${gaps.filter((g) => g.riskLevel === 'medium').length}`);
console.log(` Low: ${gaps.filter((g) => g.riskLevel === 'low').length}`);
console.log(`\nTop Priority Gaps:`);
gaps.slice(0, 20).forEach((gap, i) => {
console.log(` ${i + 1}. [${gap.riskLevel.toUpperCase()}] ${gap.file}:${gap.location.startLine}`);
console.log(` ${gap.description}`);
console.log(` Suggestion: ${gap.suggestion}`);
});
fs.writeFileSync('coverage/gap-analysis.json', JSON.stringify(gaps, null, 2));
Change-based coverage tracks whether newly added or modified lines are covered by tests. This is the most actionable form of coverage enforcement because it prevents new gaps without requiring retroactive testing of legacy code.
// scripts/change-coverage.ts
import { execSync } from 'child_process';
import * as fs from 'fs';
interface ChangedLine {
file: string;
line: number;
type: 'added' | 'modified';
}
interface ChangeCoverageResult {
totalChangedLines: number;
coveredLines: number;
uncoveredLines: ChangedLine[];
coveragePercentage: number;
}
function getChangedLines(baseBranch: string = 'main'): ChangedLine[] {
const diffOutput = execSync(`git diff ${baseBranch}...HEAD --unified=0 --diff-filter=AM`, {
encoding: 'utf-8',
});
const changedLines: ChangedLine[] = [];
let currentFile = '';
for (const line of diffOutput.split('\n')) {
// Match file header
const fileMatch = line.match(/^\+\+\+ b\/(.+)$/);
if (fileMatch) {
currentFile = fileMatch[1];
continue;
}
// Match hunk header: @@ -oldStart,oldCount +newStart,newCount @@
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
if (hunkMatch) {
const startLine = parseInt(hunkMatch[1], 10);
const lineCount = parseInt(hunkMatch[2] || '1', 10);
// Only track source files, not tests
if (
currentFile.match(/\.(ts|tsx|js|jsx|py|java)$/) &&
!currentFile.match(/\.(test|spec|__test__|_test)\./i) &&
!currentFile.includes('__mocks__')
) {
for (let i = 0; i < lineCount; i++) {
changedLines.push({
file: currentFile,
line: startLine + i,
type: 'added',
});
}
}
}
}
return changedLines;
}
function checkChangeCoverage(baseBranch: string = 'main'): ChangeCoverageResult {
const changedLines = getChangedLines(baseBranch);
if (changedLines.length === 0) {
console.log('No source file changes detected.');
return { totalChangedLines: 0, coveredLines: 0, uncoveredLines: [], coveragePercentage: 100 };
}
// Load coverage data
const coverageData = JSON.parse(
fs.readFileSync('coverage/coverage-final.json', 'utf-8')
);
const uncoveredLines: ChangedLine[] = [];
let coveredCount = 0;
for (const change of changedLines) {
const absolutePath = `${process.cwd()}/${change.file}`;
const fileCoverage = coverageData[absolutePath];
if (!fileCoverage) {
// File has no coverage data at all
uncoveredLines.push(change);
continue;
}
// Check if this specific line is covered
let lineCovered = false;
for (const [stmtId, stmtLoc] of Object.entries(fileCoverage.statementMap)) {
const loc = stmtLoc as any;
if (change.line >= loc.start.line && change.line <= loc.end.line) {
if (fileCoverage.s[stmtId] > 0) {
lineCovered = true;
break;
}
}
}
if (lineCovered) {
coveredCount++;
} else {
uncoveredLines.push(change);
}
}
const result: ChangeCoverageResult = {
totalChangedLines: changedLines.length,
coveredLines: coveredCount,
uncoveredLines,
coveragePercentage:
changedLines.length > 0 ? (coveredCount / changedLines.length) * 100 : 100,
};
return result;
}
// Run change-based coverage check
const result = checkChangeCoverage(process.argv[2] || 'main');
console.log('\nChange-Based Coverage Report');
console.log('='.repeat(50));
console.log(`Changed lines: ${result.totalChangedLines}`);
console.log(`Covered: ${result.coveredLines}`);
console.log(`Uncovered: ${result.uncoveredLines.length}`);
console.log(`Coverage: ${result.coveragePercentage.toFixed(1)}%`);
if (result.uncoveredLines.length > 0) {
console.log('\nUncovered changed lines:');
const byFile = new Map<string, number[]>();
for (const line of result.uncoveredLines) {
if (!byFile.has(line.file)) byFile.set(line.file, []);
byFile.get(line.file)!.push(line.line);
}
for (const [file, lines] of byFile) {
console.log(` ${file}: lines ${lines.join(', ')}`);
}
}
// Enforce minimum change coverage
const MIN_CHANGE_COVERAGE = 90;
if (result.coveragePercentage < MIN_CHANGE_COVERAGE) {
console.error(
`\nFAILED: Change coverage ${result.coveragePercentage.toFixed(1)}% is below minimum ${MIN_CHANGE_COVERAGE}%`
);
process.exit(1);
} else {
console.log(`\nPASSED: Change coverage meets minimum threshold of ${MIN_CHANGE_COVERAGE}%`);
}
# .github/workflows/coverage-check.yml
name: Coverage Check
on:
pull_request:
branches: [main]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for git diff
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test -- --coverage
- name: Check change-based coverage
run: npx ts-node scripts/change-coverage.ts origin/main
- name: Comment coverage on PR
uses: actions/github-script@v7
if: always()
with:
script: |
const fs = require('fs');
const summary = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf-8'));
const total = summary.total;
const body = `## Coverage Report
| Metric | Coverage | Threshold |
|--------|----------|-----------|
| Statements | ${total.statements.pct}% | 85% |
| Branches | ${total.branches.pct}% | 80% |
| Functions | ${total.functions.pct}% | 85% |
| Lines | ${total.lines.pct}% | 85% |`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
// scripts/module-coverage.ts
import * as fs from 'fs';
import * as path from 'path';
interface ModuleCoverage {
module: string;
statements: { total: number; covered: number; percentage: number };
branches: { total: number; covered: number; percentage: number };
functions: { total: number; covered: number; percentage: number };
files: number;
risk: string;
}
function analyzeModuleCoverage(): ModuleCoverage[] {
const summaryData = JSON.parse(
fs.readFileSync('coverage/coverage-summary.json', 'utf-8')
);
const modules = new Map<string, ModuleCoverage>();
for (const [filePath, data] of Object.entries(summaryData)) {
if (filePath === 'total') continue;
const relativePath = path.relative(process.cwd(), filePath);
const parts = relativePath.split(path.sep);
// Extract module from path (e.g., src/services -> services)
const moduleName = parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
if (!modules.has(moduleName)) {
modules.set(moduleName, {
module: moduleName,
statements: { total: 0, covered: 0, percentage: 0 },
branches: { total: 0, covered: 0, percentage: 0 },
functions: { total: 0, covered: 0, percentage: 0 },
files: 0,
risk: '',
});
}
const mod = modules.get(moduleName)!;
const fileData = data as any;
mod.statements.total += fileData.statements.total;
mod.statements.covered += fileData.statements.covered;
mod.branches.total += fileData.branches.total;
mod.branches.covered += fileData.branches.covered;
mod.functions.total += fileData.functions.total;
mod.functions.covered += fileData.functions.covered;
mod.files++;
}
// Calculate percentages and assign risk
for (const mod of modules.values()) {
mod.statements.percentage = safeDivide(mod.statements.covered, mod.statements.total);
mod.branches.percentage = safeDivide(mod.branches.covered, mod.branches.total);
mod.functions.percentage = safeDivide(mod.functions.covered, mod.functions.total);
const avgCoverage =
(mod.statements.percentage + mod.branches.percentage + mod.functions.percentage) / 3;
if (avgCoverage < 50) mod.risk = 'CRITICAL';
else if (avgCoverage < 70) mod.risk = 'HIGH';
else if (avgCoverage < 85) mod.risk = 'MEDIUM';
else mod.risk = 'LOW';
}
return [...modules.values()].sort(
(a, b) => a.branches.percentage - b.branches.percentage
);
}
function safeDivide(numerator: number, denominator: number): number {
return denominator === 0 ? 100 : Math.round((numerator / denominator) * 10000) / 100;
}
const modules = analyzeModuleCoverage();
console.log('\nModule Coverage Report');
console.log('='.repeat(80));
console.log(
`${'Module'.padEnd(30)} ${'Stmts'.padStart(8)} ${'Branch'.padStart(8)} ${'Funcs'.padStart(8)} ${'Risk'.padStart(10)}`
);
console.log('-'.repeat(80));
for (const mod of modules) {
console.log(
`${mod.module.padEnd(30)} ${(mod.statements.percentage + '%').padStart(8)} ${(mod.branches.percentage + '%').padStart(8)} ${(mod.functions.percentage + '%').padStart(8)} ${mod.risk.padStart(10)}`
);
}
// scripts/dead-code-detector.ts
import * as fs from 'fs';
import { execSync } from 'child_process';
interface DeadCodeCandidate {
file: string;
functionName: string;
line: number;
reason: 'no-references' | 'no-exports' | 'unreachable-branch';
confidence: 'high' | 'medium' | 'low';
}
/**
* Distinguish between dead code (should be removed) and
* untested code (needs tests). Uses a combination of coverage
* data and static analysis.
*/
function detectDeadCode(): DeadCodeCandidate[] {
const coverageData = JSON.parse(
fs.readFileSync('coverage/coverage-final.json', 'utf-8')
);
const candidates: DeadCodeCandidate[] = [];
for (const [filePath, entry] of Object.entries(coverageData)) {
const fileEntry = entry as any;
const relativePath = filePath.replace(process.cwd() + '/', '');
// Check each uncovered function
for (const [fnId, hitCount] of Object.entries(fileEntry.f)) {
if ((hitCount
<!-- Content truncated for initial SEO render. Open the source file tab for the full file. -->
npx skills add PramodDutta/Test Coverage Gap Finder下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer
Tags:test-coverage, code-coverage, branch-coverage, gap-analysis, coverage-report, untested-code, risk-coverage