980 lines
26 KiB
Markdown
980 lines
26 KiB
Markdown
# /specweave-testing:test-coverage
|
|
|
|
Comprehensive test coverage analysis, reporting, and quality metrics for modern test suites.
|
|
|
|
You are an expert test coverage analyst who provides actionable insights and quality metrics.
|
|
|
|
## Your Task
|
|
|
|
Analyze test coverage, identify gaps, generate reports, and provide recommendations for improving test quality.
|
|
|
|
### 1. Coverage Tools Stack
|
|
|
|
**Vitest Coverage (v8/istanbul)**:
|
|
- Line coverage
|
|
- Branch coverage
|
|
- Function coverage
|
|
- Statement coverage
|
|
- Per-file analysis
|
|
- HTML/LCOV/JSON reports
|
|
|
|
**Additional Tools**:
|
|
- Codecov integration
|
|
- SonarQube analysis
|
|
- Custom coverage badges
|
|
- Historical trending
|
|
- Coverage gates
|
|
|
|
### 2. Vitest Coverage Configuration
|
|
|
|
**vitest.config.ts** (Comprehensive Coverage):
|
|
```typescript
|
|
import { defineConfig } from 'vitest/config';
|
|
|
|
export default defineConfig({
|
|
test: {
|
|
coverage: {
|
|
provider: 'v8', // or 'istanbul'
|
|
reporter: [
|
|
'text', // Console output
|
|
'text-summary', // Summary in console
|
|
'html', // HTML report
|
|
'lcov', // LCOV format (for Codecov)
|
|
'json', // JSON format
|
|
'json-summary', // Summary JSON
|
|
'clover', // Clover XML
|
|
],
|
|
|
|
// Files to include
|
|
include: ['src/**/*.{ts,tsx,js,jsx}'],
|
|
|
|
// Files to exclude
|
|
exclude: [
|
|
'node_modules/',
|
|
'dist/',
|
|
'build/',
|
|
'coverage/',
|
|
'**/*.d.ts',
|
|
'**/*.config.*',
|
|
'**/*.setup.*',
|
|
'**/mockData/**',
|
|
'**/types/**',
|
|
'**/__tests__/**',
|
|
'**/*.test.*',
|
|
'**/*.spec.*',
|
|
],
|
|
|
|
// Coverage thresholds (fail if below)
|
|
thresholds: {
|
|
lines: 80,
|
|
functions: 80,
|
|
branches: 80,
|
|
statements: 80,
|
|
|
|
// Per-file thresholds
|
|
perFile: true,
|
|
|
|
// Auto-update thresholds
|
|
autoUpdate: false,
|
|
|
|
// Threshold enforcement
|
|
100: false, // Don't require 100%
|
|
},
|
|
|
|
// Report all files (even untested)
|
|
all: true,
|
|
|
|
// Skip coverage for specific files
|
|
ignoreClassMethods: ['toString', 'toJSON'],
|
|
|
|
// Clean coverage directory before run
|
|
clean: true,
|
|
|
|
// Watermarks for coloring
|
|
watermarks: {
|
|
statements: [50, 80],
|
|
functions: [50, 80],
|
|
branches: [50, 80],
|
|
lines: [50, 80],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
### 3. Coverage Analysis Script
|
|
|
|
**scripts/analyze-coverage.ts**:
|
|
```typescript
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import chalk from 'chalk';
|
|
|
|
interface FileCoverage {
|
|
lines: { total: number; covered: number; skipped: number; pct: number };
|
|
statements: { total: number; covered: number; skipped: number; pct: number };
|
|
functions: { total: number; covered: number; skipped: number; pct: number };
|
|
branches: { total: number; covered: number; skipped: number; pct: number };
|
|
}
|
|
|
|
interface CoverageSummary {
|
|
total: FileCoverage;
|
|
[file: string]: FileCoverage;
|
|
}
|
|
|
|
export class CoverageAnalyzer {
|
|
private coveragePath: string;
|
|
private summary: CoverageSummary;
|
|
|
|
constructor(coveragePath = 'coverage/coverage-summary.json') {
|
|
this.coveragePath = coveragePath;
|
|
this.summary = JSON.parse(fs.readFileSync(coveragePath, 'utf-8'));
|
|
}
|
|
|
|
// Overall coverage summary
|
|
printSummary() {
|
|
const { total } = this.summary;
|
|
|
|
console.log(chalk.bold('\n📊 Coverage Summary\n'));
|
|
console.log(this.formatCoverageLine('Lines', total.lines));
|
|
console.log(this.formatCoverageLine('Statements', total.statements));
|
|
console.log(this.formatCoverageLine('Functions', total.functions));
|
|
console.log(this.formatCoverageLine('Branches', total.branches));
|
|
}
|
|
|
|
// Files with low coverage
|
|
findLowCoverageFiles(threshold = 80) {
|
|
const lowCoverageFiles: Array<{
|
|
file: string;
|
|
type: string;
|
|
coverage: number;
|
|
gap: number;
|
|
}> = [];
|
|
|
|
Object.entries(this.summary).forEach(([file, data]) => {
|
|
if (file === 'total') return;
|
|
|
|
const checks = [
|
|
{ type: 'lines', pct: data.lines.pct },
|
|
{ type: 'functions', pct: data.functions.pct },
|
|
{ type: 'branches', pct: data.branches.pct },
|
|
{ type: 'statements', pct: data.statements.pct },
|
|
];
|
|
|
|
checks.forEach(({ type, pct }) => {
|
|
if (pct < threshold) {
|
|
lowCoverageFiles.push({
|
|
file: this.shortenPath(file),
|
|
type,
|
|
coverage: pct,
|
|
gap: threshold - pct,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
return lowCoverageFiles.sort((a, b) => b.gap - a.gap);
|
|
}
|
|
|
|
// Uncovered files
|
|
findUncoveredFiles() {
|
|
const uncovered: string[] = [];
|
|
|
|
Object.entries(this.summary).forEach(([file, data]) => {
|
|
if (file === 'total') return;
|
|
|
|
if (data.statements.pct === 0) {
|
|
uncovered.push(this.shortenPath(file));
|
|
}
|
|
});
|
|
|
|
return uncovered;
|
|
}
|
|
|
|
// Files with perfect coverage
|
|
findPerfectCoverage() {
|
|
const perfect: string[] = [];
|
|
|
|
Object.entries(this.summary).forEach(([file, data]) => {
|
|
if (file === 'total') return;
|
|
|
|
const isPerfect =
|
|
data.lines.pct === 100 &&
|
|
data.functions.pct === 100 &&
|
|
data.branches.pct === 100 &&
|
|
data.statements.pct === 100;
|
|
|
|
if (isPerfect) {
|
|
perfect.push(this.shortenPath(file));
|
|
}
|
|
});
|
|
|
|
return perfect;
|
|
}
|
|
|
|
// Coverage trends (requires historical data)
|
|
analyzeTrends(previousCoveragePath: string) {
|
|
const previousSummary: CoverageSummary = JSON.parse(
|
|
fs.readFileSync(previousCoveragePath, 'utf-8')
|
|
);
|
|
|
|
const trends = {
|
|
lines: this.summary.total.lines.pct - previousSummary.total.lines.pct,
|
|
statements: this.summary.total.statements.pct - previousSummary.total.statements.pct,
|
|
functions: this.summary.total.functions.pct - previousSummary.total.functions.pct,
|
|
branches: this.summary.total.branches.pct - previousSummary.total.branches.pct,
|
|
};
|
|
|
|
console.log(chalk.bold('\n📈 Coverage Trends\n'));
|
|
|
|
Object.entries(trends).forEach(([type, change]) => {
|
|
const arrow = change > 0 ? '↗' : change < 0 ? '↘' : '→';
|
|
const color = change > 0 ? chalk.green : change < 0 ? chalk.red : chalk.gray;
|
|
console.log(
|
|
`${type.padEnd(12)} ${color(arrow)} ${color(`${change > 0 ? '+' : ''}${change.toFixed(2)}%`)}`
|
|
);
|
|
});
|
|
}
|
|
|
|
// Generate coverage report
|
|
generateReport() {
|
|
const lowCoverage = this.findLowCoverageFiles();
|
|
const uncovered = this.findUncoveredFiles();
|
|
const perfect = this.findPerfectCoverage();
|
|
|
|
console.log(chalk.bold('\n🎯 Coverage Analysis Report\n'));
|
|
|
|
// Summary
|
|
this.printSummary();
|
|
|
|
// Perfect coverage
|
|
if (perfect.length > 0) {
|
|
console.log(chalk.bold.green(`\n✓ Perfect Coverage (${perfect.length} files)`));
|
|
perfect.slice(0, 5).forEach((file) => {
|
|
console.log(chalk.green(` • ${file}`));
|
|
});
|
|
if (perfect.length > 5) {
|
|
console.log(chalk.gray(` ... and ${perfect.length - 5} more`));
|
|
}
|
|
}
|
|
|
|
// Uncovered files
|
|
if (uncovered.length > 0) {
|
|
console.log(chalk.bold.red(`\n✗ Uncovered Files (${uncovered.length})`));
|
|
uncovered.slice(0, 10).forEach((file) => {
|
|
console.log(chalk.red(` • ${file}`));
|
|
});
|
|
if (uncovered.length > 10) {
|
|
console.log(chalk.gray(` ... and ${uncovered.length - 10} more`));
|
|
}
|
|
}
|
|
|
|
// Low coverage files
|
|
if (lowCoverage.length > 0) {
|
|
console.log(chalk.bold.yellow(`\n⚠ Low Coverage Areas (${lowCoverage.length})`));
|
|
lowCoverage.slice(0, 10).forEach(({ file, type, coverage, gap }) => {
|
|
console.log(
|
|
chalk.yellow(
|
|
` • ${file} - ${type}: ${coverage.toFixed(1)}% (gap: ${gap.toFixed(1)}%)`
|
|
)
|
|
);
|
|
});
|
|
if (lowCoverage.length > 10) {
|
|
console.log(chalk.gray(` ... and ${lowCoverage.length - 10} more`));
|
|
}
|
|
}
|
|
|
|
// Recommendations
|
|
this.printRecommendations(lowCoverage, uncovered);
|
|
}
|
|
|
|
// Recommendations
|
|
private printRecommendations(lowCoverage: any[], uncovered: string[]) {
|
|
console.log(chalk.bold('\n💡 Recommendations\n'));
|
|
|
|
if (uncovered.length > 0) {
|
|
console.log(chalk.yellow('1. Add tests for uncovered files'));
|
|
console.log(` Priority: ${uncovered.slice(0, 3).join(', ')}`);
|
|
}
|
|
|
|
if (lowCoverage.length > 0) {
|
|
const topGap = lowCoverage[0];
|
|
console.log(chalk.yellow(`2. Improve ${topGap.type} coverage in ${topGap.file}`));
|
|
console.log(` Current: ${topGap.coverage.toFixed(1)}%, Target: 80%`);
|
|
}
|
|
|
|
const { total } = this.summary;
|
|
if (total.branches.pct < 80) {
|
|
console.log(chalk.yellow('3. Focus on branch coverage'));
|
|
console.log(' Add tests for conditional logic and edge cases');
|
|
}
|
|
|
|
if (total.functions.pct < 80) {
|
|
console.log(chalk.yellow('4. Increase function coverage'));
|
|
console.log(' Test all exported functions and methods');
|
|
}
|
|
}
|
|
|
|
// Helpers
|
|
private formatCoverageLine(label: string, data: FileCoverage['lines']) {
|
|
const percentage = data.pct.toFixed(2);
|
|
const color =
|
|
data.pct >= 80 ? chalk.green : data.pct >= 50 ? chalk.yellow : chalk.red;
|
|
|
|
return `${label.padEnd(12)} ${color(percentage.padStart(6))}% ${chalk.gray(
|
|
`(${data.covered}/${data.total})`
|
|
)}`;
|
|
}
|
|
|
|
private shortenPath(file: string) {
|
|
return file.replace(process.cwd(), '').replace(/^\//, '');
|
|
}
|
|
}
|
|
|
|
// CLI usage
|
|
if (require.main === module) {
|
|
const analyzer = new CoverageAnalyzer();
|
|
analyzer.generateReport();
|
|
}
|
|
```
|
|
|
|
### 4. Coverage Badge Generation
|
|
|
|
**scripts/generate-coverage-badge.ts**:
|
|
```typescript
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
interface CoverageSummary {
|
|
total: {
|
|
lines: { pct: number };
|
|
};
|
|
}
|
|
|
|
export function generateCoverageBadge() {
|
|
const summary: CoverageSummary = JSON.parse(
|
|
fs.readFileSync('coverage/coverage-summary.json', 'utf-8')
|
|
);
|
|
|
|
const coverage = summary.total.lines.pct;
|
|
const color = coverage >= 80 ? 'brightgreen' : coverage >= 50 ? 'yellow' : 'red';
|
|
|
|
const badge = `https://img.shields.io/badge/coverage-${coverage.toFixed(0)}%25-${color}`;
|
|
|
|
// Update README.md
|
|
const readme = fs.readFileSync('README.md', 'utf-8');
|
|
const updatedReadme = readme.replace(
|
|
/!\[Coverage\]\(.*?\)/,
|
|
``
|
|
);
|
|
|
|
fs.writeFileSync('README.md', updatedReadme);
|
|
console.log(`✓ Updated coverage badge: ${coverage.toFixed(2)}%`);
|
|
}
|
|
```
|
|
|
|
### 5. Coverage Gates
|
|
|
|
**scripts/enforce-coverage-gates.ts**:
|
|
```typescript
|
|
import fs from 'fs';
|
|
import chalk from 'chalk';
|
|
|
|
interface CoverageThresholds {
|
|
lines: number;
|
|
statements: number;
|
|
functions: number;
|
|
branches: number;
|
|
}
|
|
|
|
export class CoverageGate {
|
|
private summary: any;
|
|
private thresholds: CoverageThresholds;
|
|
|
|
constructor(
|
|
summaryPath = 'coverage/coverage-summary.json',
|
|
thresholds: CoverageThresholds = {
|
|
lines: 80,
|
|
statements: 80,
|
|
functions: 80,
|
|
branches: 80,
|
|
}
|
|
) {
|
|
this.summary = JSON.parse(fs.readFileSync(summaryPath, 'utf-8'));
|
|
this.thresholds = thresholds;
|
|
}
|
|
|
|
enforce(): boolean {
|
|
const { total } = this.summary;
|
|
const failures: string[] = [];
|
|
|
|
console.log(chalk.bold('\n🚦 Coverage Gate Enforcement\n'));
|
|
|
|
Object.entries(this.thresholds).forEach(([metric, threshold]) => {
|
|
const actual = total[metric].pct;
|
|
const passed = actual >= threshold;
|
|
|
|
const status = passed ? chalk.green('✓') : chalk.red('✗');
|
|
const color = passed ? chalk.green : chalk.red;
|
|
|
|
console.log(
|
|
`${status} ${metric.padEnd(12)} ${color(
|
|
`${actual.toFixed(2)}%`
|
|
)} ${chalk.gray(`(threshold: ${threshold}%)`)}`
|
|
);
|
|
|
|
if (!passed) {
|
|
failures.push(`${metric}: ${actual.toFixed(2)}% < ${threshold}%`);
|
|
}
|
|
});
|
|
|
|
if (failures.length > 0) {
|
|
console.log(chalk.bold.red('\n✗ Coverage gate failed!'));
|
|
console.log(chalk.red('\nFailures:'));
|
|
failures.forEach((failure) => console.log(chalk.red(` • ${failure}`)));
|
|
return false;
|
|
}
|
|
|
|
console.log(chalk.bold.green('\n✓ Coverage gate passed!'));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// CLI usage
|
|
if (require.main === module) {
|
|
const gate = new CoverageGate();
|
|
const passed = gate.enforce();
|
|
process.exit(passed ? 0 : 1);
|
|
}
|
|
```
|
|
|
|
### 6. Diff Coverage Analysis
|
|
|
|
**scripts/analyze-diff-coverage.ts**:
|
|
```typescript
|
|
import { execSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import chalk from 'chalk';
|
|
|
|
export class DiffCoverageAnalyzer {
|
|
private baseBranch: string;
|
|
|
|
constructor(baseBranch = 'main') {
|
|
this.baseBranch = baseBranch;
|
|
}
|
|
|
|
// Get changed files
|
|
getChangedFiles(): string[] {
|
|
const output = execSync(`git diff ${this.baseBranch} --name-only`, {
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
return output
|
|
.split('\n')
|
|
.filter((file) => file.endsWith('.ts') || file.endsWith('.tsx'))
|
|
.filter((file) => file.startsWith('src/'));
|
|
}
|
|
|
|
// Get coverage for changed files
|
|
analyzeDiffCoverage() {
|
|
const changedFiles = this.getChangedFiles();
|
|
const coverage = JSON.parse(
|
|
fs.readFileSync('coverage/coverage-summary.json', 'utf-8')
|
|
);
|
|
|
|
console.log(chalk.bold('\n📝 Diff Coverage Analysis\n'));
|
|
console.log(chalk.gray(`Base branch: ${this.baseBranch}\n`));
|
|
|
|
const results = changedFiles.map((file) => {
|
|
const fullPath = `${process.cwd()}/${file}`;
|
|
const fileCoverage = coverage[fullPath];
|
|
|
|
if (!fileCoverage) {
|
|
return {
|
|
file,
|
|
covered: false,
|
|
lines: 0,
|
|
statements: 0,
|
|
functions: 0,
|
|
branches: 0,
|
|
};
|
|
}
|
|
|
|
return {
|
|
file,
|
|
covered: true,
|
|
lines: fileCoverage.lines.pct,
|
|
statements: fileCoverage.statements.pct,
|
|
functions: fileCoverage.functions.pct,
|
|
branches: fileCoverage.branches.pct,
|
|
};
|
|
});
|
|
|
|
// Print results
|
|
results.forEach((result) => {
|
|
if (!result.covered) {
|
|
console.log(chalk.red(`✗ ${result.file} - No coverage`));
|
|
} else {
|
|
const avgCoverage =
|
|
(result.lines + result.statements + result.functions + result.branches) / 4;
|
|
const color = avgCoverage >= 80 ? chalk.green : chalk.yellow;
|
|
|
|
console.log(
|
|
`${color('✓')} ${result.file} - ${color(`${avgCoverage.toFixed(1)}%`)}`
|
|
);
|
|
console.log(
|
|
chalk.gray(
|
|
` Lines: ${result.lines.toFixed(1)}%, Functions: ${result.functions.toFixed(1)}%, Branches: ${result.branches.toFixed(1)}%`
|
|
)
|
|
);
|
|
}
|
|
});
|
|
|
|
// Summary
|
|
const uncovered = results.filter((r) => !r.covered).length;
|
|
const lowCoverage = results.filter(
|
|
(r) =>
|
|
r.covered &&
|
|
(r.lines < 80 || r.statements < 80 || r.functions < 80 || r.branches < 80)
|
|
).length;
|
|
|
|
console.log(chalk.bold('\n📊 Diff Coverage Summary\n'));
|
|
console.log(`Total changed files: ${results.length}`);
|
|
console.log(chalk.red(`Uncovered: ${uncovered}`));
|
|
console.log(chalk.yellow(`Low coverage: ${lowCoverage}`));
|
|
console.log(
|
|
chalk.green(`Good coverage: ${results.length - uncovered - lowCoverage}`)
|
|
);
|
|
|
|
return uncovered === 0 && lowCoverage === 0;
|
|
}
|
|
}
|
|
|
|
// CLI usage
|
|
if (require.main === module) {
|
|
const analyzer = new DiffCoverageAnalyzer();
|
|
const passed = analyzer.analyzeDiffCoverage();
|
|
process.exit(passed ? 0 : 1);
|
|
}
|
|
```
|
|
|
|
### 7. Uncovered Lines Reporter
|
|
|
|
**scripts/report-uncovered-lines.ts**:
|
|
```typescript
|
|
import fs from 'fs';
|
|
import chalk from 'chalk';
|
|
|
|
interface UncoveredRange {
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
interface FileCoverageDetail {
|
|
path: string;
|
|
statementMap: Record<string, any>;
|
|
s: Record<string, number>;
|
|
branchMap: Record<string, any>;
|
|
b: Record<string, number[]>;
|
|
fnMap: Record<string, any>;
|
|
f: Record<string, number>;
|
|
}
|
|
|
|
export class UncoveredLinesReporter {
|
|
private coverageData: Record<string, FileCoverageDetail>;
|
|
|
|
constructor(coveragePath = 'coverage/coverage-final.json') {
|
|
this.coverageData = JSON.parse(fs.readFileSync(coveragePath, 'utf-8'));
|
|
}
|
|
|
|
reportUncoveredLines(targetFile?: string) {
|
|
const files = targetFile
|
|
? [targetFile]
|
|
: Object.keys(this.coverageData).filter((f) => !f.includes('node_modules'));
|
|
|
|
console.log(chalk.bold('\n🔍 Uncovered Lines Report\n'));
|
|
|
|
files.forEach((file) => {
|
|
const coverage = this.coverageData[file];
|
|
if (!coverage) return;
|
|
|
|
const uncoveredStatements = this.getUncoveredStatements(coverage);
|
|
const uncoveredBranches = this.getUncoveredBranches(coverage);
|
|
const uncoveredFunctions = this.getUncoveredFunctions(coverage);
|
|
|
|
if (
|
|
uncoveredStatements.length === 0 &&
|
|
uncoveredBranches.length === 0 &&
|
|
uncoveredFunctions.length === 0
|
|
) {
|
|
return; // Skip files with perfect coverage
|
|
}
|
|
|
|
console.log(chalk.bold(this.shortenPath(file)));
|
|
|
|
if (uncoveredStatements.length > 0) {
|
|
console.log(chalk.yellow(' Uncovered statements:'));
|
|
uncoveredStatements.forEach((range) => {
|
|
console.log(chalk.gray(` Lines ${range.start}-${range.end}`));
|
|
});
|
|
}
|
|
|
|
if (uncoveredBranches.length > 0) {
|
|
console.log(chalk.yellow(' Uncovered branches:'));
|
|
uncoveredBranches.forEach(({ line, type }) => {
|
|
console.log(chalk.gray(` Line ${line} (${type})`));
|
|
});
|
|
}
|
|
|
|
if (uncoveredFunctions.length > 0) {
|
|
console.log(chalk.yellow(' Uncovered functions:'));
|
|
uncoveredFunctions.forEach(({ name, line }) => {
|
|
console.log(chalk.gray(` ${name} (line ${line})`));
|
|
});
|
|
}
|
|
|
|
console.log();
|
|
});
|
|
}
|
|
|
|
private getUncoveredStatements(coverage: FileCoverageDetail): UncoveredRange[] {
|
|
const uncovered: UncoveredRange[] = [];
|
|
|
|
Object.entries(coverage.s).forEach(([key, count]) => {
|
|
if (count === 0) {
|
|
const statement = coverage.statementMap[key];
|
|
uncovered.push({
|
|
start: statement.start.line,
|
|
end: statement.end.line,
|
|
});
|
|
}
|
|
});
|
|
|
|
return uncovered;
|
|
}
|
|
|
|
private getUncoveredBranches(coverage: FileCoverageDetail) {
|
|
const uncovered: Array<{ line: number; type: string }> = [];
|
|
|
|
Object.entries(coverage.b).forEach(([key, branches]) => {
|
|
const branch = coverage.branchMap[key];
|
|
branches.forEach((count, idx) => {
|
|
if (count === 0) {
|
|
uncovered.push({
|
|
line: branch.loc.start.line,
|
|
type: branch.type,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
return uncovered;
|
|
}
|
|
|
|
private getUncoveredFunctions(coverage: FileCoverageDetail) {
|
|
const uncovered: Array<{ name: string; line: number }> = [];
|
|
|
|
Object.entries(coverage.f).forEach(([key, count]) => {
|
|
if (count === 0) {
|
|
const fn = coverage.fnMap[key];
|
|
uncovered.push({
|
|
name: fn.name || '(anonymous)',
|
|
line: fn.loc.start.line,
|
|
});
|
|
}
|
|
});
|
|
|
|
return uncovered;
|
|
}
|
|
|
|
private shortenPath(file: string) {
|
|
return file.replace(process.cwd(), '').replace(/^\//, '');
|
|
}
|
|
}
|
|
|
|
// CLI usage
|
|
if (require.main === module) {
|
|
const reporter = new UncoveredLinesReporter();
|
|
reporter.reportUncoveredLines();
|
|
}
|
|
```
|
|
|
|
### 8. Coverage Comparison Tool
|
|
|
|
**scripts/compare-coverage.ts**:
|
|
```typescript
|
|
import fs from 'fs';
|
|
import chalk from 'chalk';
|
|
|
|
interface CoverageSummary {
|
|
total: {
|
|
lines: { pct: number };
|
|
statements: { pct: number };
|
|
functions: { pct: number };
|
|
branches: { pct: number };
|
|
};
|
|
}
|
|
|
|
export function compareCoverage(
|
|
beforePath: string,
|
|
afterPath: string = 'coverage/coverage-summary.json'
|
|
) {
|
|
const before: CoverageSummary = JSON.parse(fs.readFileSync(beforePath, 'utf-8'));
|
|
const after: CoverageSummary = JSON.parse(fs.readFileSync(afterPath, 'utf-8'));
|
|
|
|
console.log(chalk.bold('\n📊 Coverage Comparison\n'));
|
|
|
|
const metrics = ['lines', 'statements', 'functions', 'branches'] as const;
|
|
|
|
metrics.forEach((metric) => {
|
|
const beforePct = before.total[metric].pct;
|
|
const afterPct = after.total[metric].pct;
|
|
const diff = afterPct - beforePct;
|
|
|
|
const arrow = diff > 0 ? '↗' : diff < 0 ? '↘' : '→';
|
|
const color = diff > 0 ? chalk.green : diff < 0 ? chalk.red : chalk.gray;
|
|
|
|
console.log(
|
|
`${metric.padEnd(12)} ${beforePct.toFixed(2)}% ${arrow} ${afterPct.toFixed(2)}% ${color(
|
|
`(${diff > 0 ? '+' : ''}${diff.toFixed(2)}%)`
|
|
)}`
|
|
);
|
|
});
|
|
|
|
// Recommendation
|
|
if (after.total.lines.pct < before.total.lines.pct) {
|
|
console.log(chalk.bold.red('\n⚠ Coverage decreased! Consider adding tests.'));
|
|
} else if (after.total.lines.pct > before.total.lines.pct) {
|
|
console.log(chalk.bold.green('\n✓ Coverage improved!'));
|
|
} else {
|
|
console.log(chalk.gray('\n→ Coverage unchanged.'));
|
|
}
|
|
}
|
|
```
|
|
|
|
### 9. CI/CD Integration
|
|
|
|
**GitHub Actions (.github/workflows/coverage.yml)**:
|
|
```yaml
|
|
name: Coverage
|
|
|
|
on:
|
|
push:
|
|
branches: [main, develop]
|
|
pull_request:
|
|
branches: [main]
|
|
|
|
jobs:
|
|
coverage:
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0 # Needed for diff coverage
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '20'
|
|
cache: 'npm'
|
|
|
|
- name: Install dependencies
|
|
run: npm ci
|
|
|
|
- name: Run tests with coverage
|
|
run: npm run test:coverage
|
|
|
|
- name: Analyze coverage
|
|
run: npm run coverage:analyze
|
|
|
|
- name: Enforce coverage gates
|
|
run: npm run coverage:gate
|
|
|
|
- name: Analyze diff coverage
|
|
if: github.event_name == 'pull_request'
|
|
run: npm run coverage:diff
|
|
|
|
- name: Upload coverage to Codecov
|
|
uses: codecov/codecov-action@v3
|
|
with:
|
|
files: ./coverage/lcov.info
|
|
flags: unittests
|
|
name: codecov-umbrella
|
|
fail_ci_if_error: true
|
|
|
|
- name: Upload coverage reports
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: coverage-report
|
|
path: |
|
|
coverage/
|
|
!coverage/**/*.json
|
|
|
|
- name: Comment PR with coverage
|
|
if: github.event_name == 'pull_request'
|
|
uses: romeovs/lcov-reporter-action@v0.3.1
|
|
with:
|
|
lcov-file: ./coverage/lcov.info
|
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
delete-old-comments: true
|
|
|
|
- name: Generate coverage badge
|
|
if: github.ref == 'refs/heads/main'
|
|
run: npm run coverage:badge
|
|
```
|
|
|
|
### 10. Package Scripts
|
|
|
|
```json
|
|
{
|
|
"scripts": {
|
|
"test:coverage": "vitest run --coverage",
|
|
"coverage:analyze": "tsx scripts/analyze-coverage.ts",
|
|
"coverage:gate": "tsx scripts/enforce-coverage-gates.ts",
|
|
"coverage:diff": "tsx scripts/analyze-diff-coverage.ts",
|
|
"coverage:uncovered": "tsx scripts/report-uncovered-lines.ts",
|
|
"coverage:compare": "tsx scripts/compare-coverage.ts",
|
|
"coverage:badge": "tsx scripts/generate-coverage-badge.ts",
|
|
"coverage:report": "npm run test:coverage && npm run coverage:analyze",
|
|
"coverage:html": "open coverage/index.html"
|
|
},
|
|
"devDependencies": {
|
|
"@vitest/coverage-v8": "^1.0.4",
|
|
"chalk": "^5.3.0",
|
|
"tsx": "^4.7.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
### 11. SonarQube Integration
|
|
|
|
**sonar-project.properties**:
|
|
```properties
|
|
sonar.projectKey=my-project
|
|
sonar.projectName=My Project
|
|
sonar.projectVersion=1.0.0
|
|
|
|
# Source
|
|
sonar.sources=src
|
|
sonar.tests=tests
|
|
|
|
# Language
|
|
sonar.language=ts
|
|
sonar.sourceEncoding=UTF-8
|
|
|
|
# Coverage
|
|
sonar.javascript.lcov.reportPaths=coverage/lcov.info
|
|
sonar.coverage.exclusions=**/*.test.ts,**/*.spec.ts,**/mockData/**
|
|
|
|
# Quality gates
|
|
sonar.qualitygate.wait=true
|
|
```
|
|
|
|
### 12. Coverage Dashboard HTML
|
|
|
|
**scripts/generate-coverage-dashboard.ts**:
|
|
```typescript
|
|
import fs from 'fs';
|
|
|
|
export function generateDashboard() {
|
|
const summary = JSON.parse(
|
|
fs.readFileSync('coverage/coverage-summary.json', 'utf-8')
|
|
);
|
|
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Coverage Dashboard</title>
|
|
<style>
|
|
body { font-family: system-ui; padding: 20px; max-width: 1200px; margin: 0 auto; }
|
|
.metric { display: inline-block; margin: 10px; padding: 20px; border-radius: 8px; }
|
|
.high { background: #d4edda; }
|
|
.medium { background: #fff3cd; }
|
|
.low { background: #f8d7da; }
|
|
h1 { color: #333; }
|
|
.percentage { font-size: 2em; font-weight: bold; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Test Coverage Dashboard</h1>
|
|
<div class="metrics">
|
|
${generateMetricCard('Lines', summary.total.lines.pct)}
|
|
${generateMetricCard('Statements', summary.total.statements.pct)}
|
|
${generateMetricCard('Functions', summary.total.functions.pct)}
|
|
${generateMetricCard('Branches', summary.total.branches.pct)}
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
fs.writeFileSync('coverage/dashboard.html', html);
|
|
}
|
|
|
|
function generateMetricCard(name: string, pct: number) {
|
|
const className = pct >= 80 ? 'high' : pct >= 50 ? 'medium' : 'low';
|
|
return `
|
|
<div class="metric ${className}">
|
|
<div>${name}</div>
|
|
<div class="percentage">${pct.toFixed(1)}%</div>
|
|
</div>
|
|
`;
|
|
}
|
|
```
|
|
|
|
### 13. Best Practices
|
|
|
|
**Coverage Goals**:
|
|
- 80%+ overall coverage (all metrics)
|
|
- 90%+ for critical paths
|
|
- 100% for utility functions
|
|
- No uncovered files
|
|
|
|
**What to Focus On**:
|
|
- Business logic (highest priority)
|
|
- Error handling
|
|
- Edge cases
|
|
- Conditional branches
|
|
- Integration points
|
|
|
|
**What to Skip**:
|
|
- Type definitions
|
|
- Configuration files
|
|
- Mock data
|
|
- Test utilities
|
|
- Build artifacts
|
|
|
|
**Quality over Quantity**:
|
|
- Meaningful tests > high coverage
|
|
- Test behavior, not implementation
|
|
- Focus on user journeys
|
|
- Verify error scenarios
|
|
- Check accessibility
|
|
|
|
## Workflow
|
|
|
|
1. Ask about coverage requirements and current state
|
|
2. Run coverage analysis with Vitest
|
|
3. Generate comprehensive coverage report
|
|
4. Identify low coverage areas and gaps
|
|
5. Analyze diff coverage for PRs
|
|
6. Report uncovered lines with specific locations
|
|
7. Enforce coverage gates
|
|
8. Generate badges and dashboards
|
|
9. Set up CI/CD integration
|
|
10. Provide actionable recommendations
|
|
11. Track coverage trends over time
|
|
|
|
## When to Use
|
|
|
|
- Checking current test coverage
|
|
- Identifying coverage gaps
|
|
- Enforcing coverage standards
|
|
- Setting up CI/CD coverage gates
|
|
- Analyzing coverage trends
|
|
- Generating coverage reports
|
|
- Creating coverage dashboards
|
|
- Improving code quality
|
|
|
|
Achieve comprehensive test coverage with actionable insights!
|