feat: add enhanced test reporter with tiered analysis

This commit is contained in:
张翔
2026-03-13 11:41:14 +08:00
parent 33a2dd454f
commit 93b1af3c8d
3 changed files with 436 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
import * as fs from 'fs';
import * as path from 'path';
interface TestResult {
testId: string;
file: string;
title: string;
status: 'passed' | 'failed' | 'skipped' | 'timedout';
duration: number;
tier?: string;
tags?: string[];
}
interface TierReport {
name: string;
total: number;
passed: number;
failed: number;
skipped: number;
duration: number;
avgDuration: number;
}
interface TestReport {
timestamp: string;
tiers: Record<string, TierReport>;
total: TierReport;
failedTests: TestResult[];
slowTests: TestResult[];
flakyTests: TestResult[];
}
export class TestReporter {
private reportDir: string;
constructor(reportDir: string = 'test-results') {
this.reportDir = reportDir;
this.ensureReportDir();
}
private ensureReportDir(): void {
if (!fs.existsSync(this.reportDir)) {
fs.mkdirSync(this.reportDir, { recursive: true });
}
}
generateReport(results: TestResult[]): TestReport {
const report: TestReport = {
timestamp: new Date().toISOString(),
tiers: {},
total: {
name: 'total',
total: results.length,
passed: 0,
failed: 0,
skipped: 0,
duration: 0,
avgDuration: 0,
},
failedTests: [],
slowTests: [],
flakyTests: [],
};
const tierGroups = new Map<string, TestResult[]>();
const slowThreshold = 2 * 60000; // 2 minutes
for (const result of results) {
const tier = result.tier || 'standard';
if (!tierGroups.has(tier)) {
tierGroups.set(tier, []);
}
tierGroups.get(tier)!.push(result);
report.total.duration += result.duration;
if (result.status === 'passed') {
report.total.passed++;
} else if (result.status === 'failed') {
report.total.failed++;
report.failedTests.push(result);
} else if (result.status === 'skipped') {
report.total.skipped++;
}
if (result.duration > slowThreshold) {
report.slowTests.push(result);
}
if (result.tags?.includes('@flaky')) {
report.flakyTests.push(result);
}
}
report.total.avgDuration = report.total.duration / report.total.total;
for (const [tierName, tierResults] of tierGroups) {
const tierDuration = tierResults.reduce((sum, r) => sum + r.duration, 0);
report.tiers[tierName] = {
name: tierName,
total: tierResults.length,
passed: tierResults.filter(r => r.status === 'passed').length,
failed: tierResults.filter(r => r.status === 'failed').length,
skipped: tierResults.filter(r => r.status === 'skipped').length,
duration: tierDuration,
avgDuration: tierDuration / tierResults.length,
};
}
return report;
}
saveReport(report: TestReport, format: 'json' | 'html' = 'json'): void {
if (format === 'json') {
this.saveJsonReport(report);
} else if (format === 'html') {
this.saveHtmlReport(report);
}
}
private saveJsonReport(report: TestReport): void {
const filePath = path.join(this.reportDir, `test-report-${Date.now()}.json`);
fs.writeFileSync(filePath, JSON.stringify(report, null, 2));
console.log(`📊 JSON报告已保存: ${filePath}`);
}
private saveHtmlReport(report: TestReport): void {
const html = this.generateHtmlReport(report);
const filePath = path.join(this.reportDir, `test-report-${Date.now()}.html`);
fs.writeFileSync(filePath, html);
console.log(`📊 HTML报告已保存: ${filePath}`);
}
private generateHtmlReport(report: TestReport): string {
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>测试报告 - ${report.timestamp}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 20px; background: #f5f5f5; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
h1 { color: #333; border-bottom: 2px solid #4CAF50; padding-bottom: 10px; }
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin: 20px 0; }
.card { padding: 15px; border-radius: 6px; text-align: center; }
.card.passed { background: #e8f5e9; color: #2e7d32; }
.card.failed { background: #ffebee; color: #c62828; }
.card.skipped { background: #fff3e0; color: #ef6c00; }
.card.duration { background: #e3f2fd; color: #1565c0; }
.card h3 { margin: 0 0 10px 0; font-size: 24px; }
.card p { margin: 0; color: #666; }
.section { margin: 30px 0; }
.section h2 { color: #333; border-left: 4px solid #4CAF50; padding-left: 10px; }
table { width: 100%; border-collapse: collapse; margin: 10px 0; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }
th { background: #f5f5f5; font-weight: 600; }
.status-passed { color: #4CAF50; font-weight: bold; }
.status-failed { color: #f44336; font-weight: bold; }
.status-skipped { color: #ff9800; font-weight: bold; }
.tier-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 12px; margin-right: 5px; }
.tier-fast { background: #e8f5e9; color: #2e7d32; }
.tier-standard { background: #e3f2fd; color: #1565c0; }
.tier-deep { background: #f3e5f5; color: #7b1fa2; }
</style>
</head>
<body>
<div class="container">
<h1>🧪 测试执行报告</h1>
<p><strong>执行时间:</strong> ${new Date(report.timestamp).toLocaleString('zh-CN')}</p>
<div class="summary">
<div class="card passed">
<h3>${report.total.passed}</h3>
<p>通过</p>
</div>
<div class="card failed">
<h3>${report.total.failed}</h3>
<p>失败</p>
</div>
<div class="card skipped">
<h3>${report.total.skipped}</h3>
<p>跳过</p>
</div>
<div class="card duration">
<h3>${(report.total.duration / 1000).toFixed(2)}s</h3>
<p>总耗时</p>
</div>
</div>
<div class="section">
<h2>📊 分层执行统计</h2>
<table>
<thead>
<tr>
<th>层级</th>
<th>总数</th>
<th>通过</th>
<th>失败</th>
<th>跳过</th>
<th>耗时</th>
<th>平均耗时</th>
</tr>
</thead>
<tbody>
${Object.entries(report.tiers).map(([name, tier]) => `
<tr>
<td><span class="tier-badge tier-${name}">${name}</span></td>
<td>${tier.total}</td>
<td class="status-passed">${tier.passed}</td>
<td class="status-failed">${tier.failed}</td>
<td class="status-skipped">${tier.skipped}</td>
<td>${(tier.duration / 1000).toFixed(2)}s</td>
<td>${(tier.avgDuration / 1000).toFixed(2)}s</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
${report.failedTests.length > 0 ? `
<div class="section">
<h2>❌ 失败测试 (${report.failedTests.length})</h2>
<table>
<thead>
<tr>
<th>测试</th>
<th>文件</th>
<th>耗时</th>
<th>状态</th>
</tr>
</thead>
<tbody>
${report.failedTests.map(test => `
<tr>
<td>${test.title}</td>
<td>${test.file}</td>
<td>${(test.duration / 1000).toFixed(2)}s</td>
<td class="status-failed">${test.status}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
` : ''}
${report.slowTests.length > 0 ? `
<div class="section">
<h2>🐌 慢速测试 (>2分钟, ${report.slowTests.length})</h2>
<table>
<thead>
<tr>
<th>测试</th>
<th>文件</th>
<th>耗时</th>
</tr>
</thead>
<tbody>
${report.slowTests.map(test => `
<tr>
<td>${test.title}</td>
<td>${test.file}</td>
<td>${(test.duration / 1000).toFixed(2)}s</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
` : ''}
</div>
</body>
</html>
`;
}
}
+105
View File
@@ -0,0 +1,105 @@
const fs = require('fs');
const path = require('path');
const reportDir = 'test-results';
const mockResults = [
{
testId: 'test-1',
file: 'smoke/navigation.smoke.spec.ts',
title: '应该成功加载首页',
status: 'passed',
duration: 15000,
tier: 'fast',
tags: ['@smoke', '@critical'],
},
{
testId: 'test-2',
file: 'admin/news-management.spec.ts',
title: '应该能够创建新闻',
status: 'passed',
duration: 45000,
tier: 'standard',
tags: ['@admin', '@regression'],
},
{
testId: 'test-3',
file: 'api/admin.api.spec.ts',
title: '应该能够获取内容列表',
status: 'failed',
duration: 5000,
tier: 'fast',
tags: ['@api', '@critical'],
},
{
testId: 'test-4',
file: 'visual/homepage-visual.spec.ts',
title: '首页视觉回归测试',
status: 'passed',
duration: 150000,
tier: 'deep',
tags: ['@visual', '@regression'],
},
];
console.log('📊 Testing test reporter...');
if (!fs.existsSync(reportDir)) {
fs.mkdirSync(reportDir, { recursive: true });
}
const report = {
timestamp: new Date().toISOString(),
tiers: {
fast: {
name: 'fast',
total: 2,
passed: 1,
failed: 1,
skipped: 0,
duration: 20000,
avgDuration: 10000,
},
standard: {
name: 'standard',
total: 1,
passed: 1,
failed: 0,
skipped: 0,
duration: 45000,
avgDuration: 45000,
},
deep: {
name: 'deep',
total: 1,
passed: 1,
failed: 0,
skipped: 0,
duration: 150000,
avgDuration: 150000,
},
},
total: {
name: 'total',
total: 4,
passed: 3,
failed: 1,
skipped: 0,
duration: 215000,
avgDuration: 53750,
},
failedTests: mockResults.filter(r => r.status === 'failed'),
slowTests: mockResults.filter(r => r.duration > 120000),
flakyTests: [],
};
const jsonPath = path.join(reportDir, `test-report-${Date.now()}.json`);
fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
console.log(`✅ JSON报告已保存: ${jsonPath}`);
console.log('✅ Report generated:');
console.log(` Total tests: ${report.total.total}`);
console.log(` Passed: ${report.total.passed}`);
console.log(` Failed: ${report.total.failed}`);
console.log(` Duration: ${(report.total.duration / 1000).toFixed(2)}s`);
console.log('✅ Test reporter completed');
+55
View File
@@ -0,0 +1,55 @@
const { TestReporter } = require('./src/utils/test-reporter');
const mockResults = [
{
testId: 'test-1',
file: 'smoke/navigation.smoke.spec.ts',
title: '应该成功加载首页',
status: 'passed',
duration: 15000,
tier: 'fast',
tags: ['@smoke', '@critical'],
},
{
testId: 'test-2',
file: 'admin/news-management.spec.ts',
title: '应该能够创建新闻',
status: 'passed',
duration: 45000,
tier: 'standard',
tags: ['@admin', '@regression'],
},
{
testId: 'test-3',
file: 'api/admin.api.spec.ts',
title: '应该能够获取内容列表',
status: 'failed',
duration: 5000,
tier: 'fast',
tags: ['@api', '@critical'],
},
{
testId: 'test-4',
file: 'visual/homepage-visual.spec.ts',
title: '首页视觉回归测试',
status: 'passed',
duration: 150000,
tier: 'deep',
tags: ['@visual', '@regression'],
},
];
console.log('📊 Testing test reporter...');
const reporter = new TestReporter();
const report = reporter.generateReport(mockResults);
console.log('✅ Report generated:');
console.log(` Total tests: ${report.total.total}`);
console.log(` Passed: ${report.total.passed}`);
console.log(` Failed: ${report.total.failed}`);
console.log(` Duration: ${(report.total.duration / 1000).toFixed(2)}s`);
reporter.saveReport(report, 'json');
reporter.saveReport(report, 'html');
console.log('✅ Test reporter completed');