Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 17:57:09 +08:00
commit 205830d396
16 changed files with 8845 additions and 0 deletions

1081
commands/e2e-setup.md Normal file

File diff suppressed because it is too large Load Diff

979
commands/test-coverage.md Normal file
View File

@@ -0,0 +1,979 @@
# /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\]\(.*?\)/,
`![Coverage](${badge})`
);
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!

1156
commands/test-generate.md Normal file

File diff suppressed because it is too large Load Diff

409
commands/test-init.md Normal file
View File

@@ -0,0 +1,409 @@
# /specweave-testing:test-init
Initialize comprehensive testing infrastructure with Vitest, Playwright, and testing best practices.
You are an expert testing engineer who sets up production-ready test infrastructure.
## Your Task
Set up a complete testing framework covering unit tests, integration tests, and E2E tests.
### 1. Testing Stack
**Unit Testing (Vitest)**:
- Fast, Vite-powered test runner
- Compatible with Jest API
- Built-in coverage (c8/istanbul)
- ESM and TypeScript support
- Watch mode for TDD
**E2E Testing (Playwright)**:
- Cross-browser testing (Chromium, Firefox, WebKit)
- Reliable auto-wait mechanisms
- Powerful selectors and assertions
- Parallel test execution
- Screenshots and video recording
**Component Testing**:
- React Testing Library
- Vue Testing Library
- User-centric testing approach
- Accessibility testing integration
### 2. Vitest Configuration
**vitest.config.ts**:
```typescript
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
exclude: ['node_modules', 'dist', '.idea', '.git', '.cache'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/*.config.*',
'**/mockData',
],
all: true,
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
testTimeout: 10000,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
```
**tests/setup.ts**:
```typescript
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
// Cleanup after each test
afterEach(() => {
cleanup();
});
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
} as any;
```
### 3. Playwright Configuration
**playwright.config.ts**:
```typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Mobile viewports
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
```
### 4. Test Utilities
**tests/utils/test-utils.tsx** (React):
```typescript
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
function AllTheProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
{children}
</BrowserRouter>
</QueryClientProvider>
);
}
function customRender(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
return render(ui, { wrapper: AllTheProviders, ...options });
}
export * from '@testing-library/react';
export { customRender as render };
```
**tests/utils/mocks/handlers.ts** (MSW):
```typescript
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'John Doe' },
{ id: '2', name: 'Jane Smith' },
]);
}),
http.post('/api/login', async ({ request }) => {
const { email, password } = await request.json();
if (email === 'test@example.com' && password === 'password') {
return HttpResponse.json({
token: 'mock-jwt-token',
user: { id: '1', email },
});
}
return HttpResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}),
];
```
**tests/utils/mocks/server.ts**:
```typescript
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
```
### 5. Package Dependencies
```json
{
"devDependencies": {
"@playwright/test": "^1.40.0",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1",
"@vitest/coverage-v8": "^1.0.4",
"@vitest/ui": "^1.0.4",
"jsdom": "^23.0.1",
"msw": "^2.0.0",
"vitest": "^1.0.4"
}
}
```
### 6. NPM Scripts
```json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:all": "npm run test:coverage && npm run test:e2e"
}
}
```
### 7. CI/CD Configuration
**GitHub Actions (.github/workflows/test.yml)**:
```yaml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:coverage
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/
```
### 8. Testing Best Practices
**Unit Test Example**:
```typescript
import { render, screen, fireEvent } from './utils/test-utils';
import { LoginForm } from '@/components/LoginForm';
describe('LoginForm', () => {
it('renders login form correctly', () => {
render(<LoginForm />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
});
it('shows validation errors for empty fields', async () => {
render(<LoginForm />);
fireEvent.click(screen.getByRole('button', { name: /login/i }));
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
});
it('submits form with valid data', async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: 'test@example.com' },
});
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: 'password123' },
});
fireEvent.click(screen.getByRole('button', { name: /login/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
});
```
**E2E Test Example**:
```typescript
import { test, expect } from '@playwright/test';
test.describe('Authentication Flow', () => {
test('should allow user to login', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Dashboard');
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'wrong@example.com');
await page.fill('input[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('[role="alert"]')).toContainText(
'Invalid credentials'
);
});
});
```
## Workflow
1. Ask about testing requirements and existing setup
2. Install testing dependencies (Vitest, Playwright, Testing Library)
3. Create Vitest configuration
4. Create Playwright configuration
5. Set up test utilities and helpers
6. Configure MSW for API mocking
7. Add test scripts to package.json
8. Create example tests
9. Set up CI/CD workflow
10. Provide testing guidelines and best practices
## When to Use
- Starting new projects with testing
- Migrating from Jest to Vitest
- Adding E2E testing to existing projects
- Setting up CI/CD testing pipeline
- Improving test coverage
- Implementing TDD workflow
Initialize production-ready testing infrastructure with modern tools!