Initial commit
This commit is contained in:
1081
commands/e2e-setup.md
Normal file
1081
commands/e2e-setup.md
Normal file
File diff suppressed because it is too large
Load Diff
979
commands/test-coverage.md
Normal file
979
commands/test-coverage.md
Normal 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\]\(.*?\)/,
|
||||
``
|
||||
);
|
||||
|
||||
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
1156
commands/test-generate.md
Normal file
File diff suppressed because it is too large
Load Diff
409
commands/test-init.md
Normal file
409
commands/test-init.md
Normal 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!
|
||||
Reference in New Issue
Block a user