feat: 添加测试框架和覆盖率报告功能
feat(测试): 新增Playwright和Vitest测试配置 feat(测试): 添加测试覆盖率报告生成功能 feat(测试): 实现前后端测试脚本集成 fix(测试): 修复测试密码不匹配问题 fix(测试): 修正URL等待策略 fix(测试): 调整错误消息选择器 refactor(测试): 重构测试目录结构 refactor(测试): 优化测试用例组织方式 docs: 更新测试报告文档 docs: 添加测试覆盖率报告模板 ci: 添加Docker测试环境配置 ci: 实现测试自动化脚本 chore: 更新依赖版本 chore: 添加测试相关配置文件
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 测试趋势分析工具
|
||||
* 收集和分析历史测试数据,识别测试质量变化趋势
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
class TestTrendAnalyzer {
|
||||
constructor() {
|
||||
this.trendDataPath = path.join(process.cwd(), 'test-results', 'trends.json');
|
||||
this.historyDataPath = path.join(process.cwd(), 'test-results', 'history');
|
||||
this.trendData = this.loadTrendData();
|
||||
}
|
||||
|
||||
loadTrendData() {
|
||||
try {
|
||||
if (fs.existsSync(this.trendDataPath)) {
|
||||
return JSON.parse(fs.readFileSync(this.trendDataPath, 'utf-8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('加载趋势数据失败:', error.message);
|
||||
}
|
||||
return {
|
||||
runs: [],
|
||||
summary: {
|
||||
totalRuns: 0,
|
||||
avgPassRate: 0,
|
||||
avgDuration: 0,
|
||||
trend: 'stable',
|
||||
lastUpdated: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
saveTrendData() {
|
||||
const dir = path.dirname(this.trendDataPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(this.trendDataPath, JSON.stringify(this.trendData, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
addTestRun(testResults) {
|
||||
const run = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary: {
|
||||
total: testResults.summary?.total || 0,
|
||||
passed: testResults.summary?.passed || 0,
|
||||
failed: testResults.summary?.failed || 0,
|
||||
skipped: testResults.summary?.skipped || 0,
|
||||
flaky: testResults.summary?.flaky || 0,
|
||||
passRate: testResults.summary?.passRate || 0,
|
||||
failRate: testResults.summary?.failRate || 0,
|
||||
skipRate: testResults.summary?.skipRate || 0,
|
||||
flakyRate: testResults.summary?.flakyRate || 0,
|
||||
totalDuration: testResults.summary?.totalDuration || 0,
|
||||
avgDuration: testResults.summary?.avgDuration || 0,
|
||||
},
|
||||
failedTests: testResults.failedTests || [],
|
||||
slowestTests: testResults.slowestTests || [],
|
||||
environment: this.getEnvironmentInfo(),
|
||||
};
|
||||
|
||||
this.trendData.runs.push(run);
|
||||
this.updateSummary();
|
||||
this.saveTrendData();
|
||||
this.saveHistory(run);
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
updateSummary() {
|
||||
const runs = this.trendData.runs;
|
||||
const recentRuns = runs.slice(-10);
|
||||
|
||||
this.trendData.summary.totalRuns = runs.length;
|
||||
this.trendData.summary.avgPassRate = this.calculateAverage(recentRuns, 'passRate');
|
||||
this.trendData.summary.avgDuration = this.calculateAverage(recentRuns, 'totalDuration');
|
||||
this.trendData.summary.trend = this.analyzeTrend();
|
||||
this.trendData.summary.lastUpdated = new Date().toISOString();
|
||||
}
|
||||
|
||||
calculateAverage(runs, field) {
|
||||
if (runs.length === 0) return 0;
|
||||
const sum = runs.reduce((acc, run) => acc + (run.summary[field] || 0), 0);
|
||||
return sum / runs.length;
|
||||
}
|
||||
|
||||
analyzeTrend() {
|
||||
const runs = this.trendData.runs;
|
||||
if (runs.length < 3) return 'stable';
|
||||
|
||||
const recentPassRates = runs.slice(-5).map(r => r.summary.passRate);
|
||||
const avgPassRate = recentPassRates.reduce((a, b) => a + b, 0) / recentPassRates.length;
|
||||
const latestPassRate = recentPassRates[recentPassRates.length - 1];
|
||||
|
||||
if (latestPassRate < avgPassRate - 5) {
|
||||
return 'degrading';
|
||||
} else if (latestPassRate > avgPassRate + 5) {
|
||||
return 'improving';
|
||||
} else {
|
||||
return 'stable';
|
||||
}
|
||||
}
|
||||
|
||||
saveHistory(run) {
|
||||
const dir = this.historyDataPath;
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const filename = `run-${Date.now()}.json`;
|
||||
const filepath = path.join(dir, filename);
|
||||
fs.writeFileSync(filepath, JSON.stringify(run, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
getEnvironmentInfo() {
|
||||
return {
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
nodeVersion: process.version,
|
||||
hostname: os.hostname(),
|
||||
cpus: os.cpus().length,
|
||||
totalMemory: os.totalmem(),
|
||||
freeMemory: os.freemem(),
|
||||
};
|
||||
}
|
||||
|
||||
generateTrendReport() {
|
||||
const runs = this.trendData.runs;
|
||||
const summary = this.trendData.summary;
|
||||
|
||||
if (runs.length === 0) {
|
||||
console.log('暂无测试数据');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('═══════════════════════════════════════════');
|
||||
console.log('📈 测试趋势分析报告');
|
||||
console.log('═══════════════════════════════════════════');
|
||||
console.log('');
|
||||
console.log(`📊 总运行次数: ${summary.totalRuns}`);
|
||||
console.log(`📈 平均通过率: ${summary.avgPassRate.toFixed(2)}%`);
|
||||
console.log(`⏱️ 平均耗时: ${this.formatDuration(summary.avgDuration)}`);
|
||||
console.log(`📉 趋势: ${this.getTrendEmoji(summary.trend)} ${summary.trend.toUpperCase()}`);
|
||||
console.log('');
|
||||
|
||||
const recentRuns = runs.slice(-10);
|
||||
console.log('📅 最近10次运行:');
|
||||
recentRuns.forEach((run, index) => {
|
||||
const date = new Date(run.timestamp);
|
||||
const dateStr = date.toLocaleString('zh-CN');
|
||||
const passRate = run.summary.passRate.toFixed(2);
|
||||
const duration = this.formatDuration(run.summary.totalDuration);
|
||||
console.log(` ${index + 1}. ${dateStr} - 通过率: ${passRate}% - 耗时: ${duration}`);
|
||||
});
|
||||
console.log('');
|
||||
|
||||
this.analyzeFlakyTests();
|
||||
this.analyzeSlowTests();
|
||||
this.analyzeFailedTests();
|
||||
this.generateRecommendations();
|
||||
}
|
||||
|
||||
analyzeFlakyTests() {
|
||||
const runs = this.trendData.runs;
|
||||
const flakyTestMap = new Map();
|
||||
|
||||
runs.forEach(run => {
|
||||
run.failedTests.forEach(test => {
|
||||
const key = `${test.title}`;
|
||||
if (!flakyTestMap.has(key)) {
|
||||
flakyTestMap.set(key, {
|
||||
title: test.title,
|
||||
failures: 0,
|
||||
runs: 0,
|
||||
});
|
||||
}
|
||||
flakyTestMap.get(key).failures++;
|
||||
});
|
||||
flakyTestMap.forEach(test => {
|
||||
test.runs++;
|
||||
});
|
||||
});
|
||||
|
||||
const flakyTests = Array.from(flakyTestMap.values())
|
||||
.filter(test => test.failures >= 2)
|
||||
.sort((a, b) => b.failures - a.failures)
|
||||
.slice(0, 10);
|
||||
|
||||
if (flakyTests.length > 0) {
|
||||
console.log('🔄 不稳定测试 (失败2次以上):');
|
||||
flakyTests.forEach((test, index) => {
|
||||
const failRate = ((test.failures / test.runs) * 100).toFixed(2);
|
||||
console.log(` ${index + 1}. ${test.title} - 失败: ${test.failures}/${test.runs} (${failRate}%)`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
analyzeSlowTests() {
|
||||
const runs = this.trendData.runs;
|
||||
const slowTestMap = new Map();
|
||||
|
||||
runs.forEach(run => {
|
||||
run.slowestTests.forEach(test => {
|
||||
const key = `${test.title}`;
|
||||
if (!slowTestMap.has(key)) {
|
||||
slowTestMap.set(key, {
|
||||
title: test.title,
|
||||
durations: [],
|
||||
});
|
||||
}
|
||||
slowTestMap.get(key).durations.push(test.duration);
|
||||
});
|
||||
});
|
||||
|
||||
const slowTests = Array.from(slowTestMap.values())
|
||||
.map(test => ({
|
||||
title: test.title,
|
||||
avgDuration: test.durations.reduce((a, b) => a + b, 0) / test.durations.length,
|
||||
maxDuration: Math.max(...test.durations),
|
||||
runs: test.durations.length,
|
||||
}))
|
||||
.sort((a, b) => b.avgDuration - a.avgDuration)
|
||||
.slice(0, 10);
|
||||
|
||||
if (slowTests.length > 0) {
|
||||
console.log('🐌 最慢的测试 (平均耗时):');
|
||||
slowTests.forEach((test, index) => {
|
||||
console.log(` ${index + 1}. ${test.title} - 平均: ${this.formatDuration(test.avgDuration)} - 最大: ${this.formatDuration(test.maxDuration)}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
analyzeFailedTests() {
|
||||
const runs = this.trendData.runs;
|
||||
const failedTestMap = new Map();
|
||||
|
||||
runs.forEach(run => {
|
||||
run.failedTests.forEach(test => {
|
||||
const key = `${test.title}`;
|
||||
if (!failedTestMap.has(key)) {
|
||||
failedTestMap.set(key, {
|
||||
title: test.title,
|
||||
failures: 0,
|
||||
lastFailure: null,
|
||||
errorMessages: new Set(),
|
||||
});
|
||||
}
|
||||
failedTestMap.get(key).failures++;
|
||||
failedTestMap.get(key).lastFailure = run.timestamp;
|
||||
if (test.error) {
|
||||
failedTestMap.get(key).errorMessages.add(test.error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const failedTests = Array.from(failedTestMap.values())
|
||||
.sort((a, b) => b.failures - a.failures)
|
||||
.slice(0, 10);
|
||||
|
||||
if (failedTests.length > 0) {
|
||||
console.log('❌ 最常失败的测试:');
|
||||
failedTests.forEach((test, index) => {
|
||||
const lastFailure = new Date(test.lastFailure).toLocaleString('zh-CN');
|
||||
console.log(` ${index + 1}. ${test.title} - 失败: ${test.failures}次 - 最后失败: ${lastFailure}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
generateRecommendations() {
|
||||
const summary = this.trendData.summary;
|
||||
const runs = this.trendData.runs;
|
||||
const recommendations = [];
|
||||
|
||||
if (summary.trend === 'degrading') {
|
||||
recommendations.push('⚠️ 测试通过率呈下降趋势,建议检查最近的代码变更');
|
||||
}
|
||||
|
||||
const recentFlakyRate = runs.slice(-5).reduce((sum, run) => sum + run.summary.flakyRate, 0) / 5;
|
||||
if (recentFlakyRate > 10) {
|
||||
recommendations.push('🔄 不稳定测试比例较高,建议优化测试稳定性');
|
||||
}
|
||||
|
||||
const recentAvgDuration = runs.slice(-5).reduce((sum, run) => sum + run.summary.totalDuration, 0) / 5;
|
||||
if (recentAvgDuration > 300000) {
|
||||
recommendations.push('⏱️ 测试执行时间较长,建议优化测试性能');
|
||||
}
|
||||
|
||||
if (recommendations.length > 0) {
|
||||
console.log('💡 改进建议:');
|
||||
recommendations.forEach(rec => {
|
||||
console.log(` ${rec}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
getTrendEmoji(trend) {
|
||||
switch (trend) {
|
||||
case 'improving':
|
||||
return '📈';
|
||||
case 'degrading':
|
||||
return '📉';
|
||||
default:
|
||||
return '➡️';
|
||||
}
|
||||
}
|
||||
|
||||
formatDuration(ms) {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
} else if (ms < 60000) {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
} else {
|
||||
return `${(ms / 60000).toFixed(1)}m`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 命令行接口
|
||||
if (require.main === module) {
|
||||
const analyzer = new TestTrendAnalyzer();
|
||||
const command = process.argv[2];
|
||||
|
||||
switch (command) {
|
||||
case 'add':
|
||||
const resultsFile = process.argv[3];
|
||||
if (resultsFile && fs.existsSync(resultsFile)) {
|
||||
const testResults = JSON.parse(fs.readFileSync(resultsFile, 'utf-8'));
|
||||
analyzer.addTestRun(testResults);
|
||||
console.log('✅ 测试数据已添加');
|
||||
} else {
|
||||
console.error('❌ 错误: 请提供有效的测试结果文件');
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'report':
|
||||
analyzer.generateTrendReport();
|
||||
break;
|
||||
|
||||
case 'export':
|
||||
const exportFile = process.argv[3] || 'test-trends.json';
|
||||
fs.writeFileSync(exportFile, JSON.stringify(analyzer.trendData, null, 2), 'utf-8');
|
||||
console.log(`✅ 趋势数据已导出到: ${exportFile}`);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('测试趋势分析工具');
|
||||
console.log('');
|
||||
console.log('用法:');
|
||||
console.log(' node testTrendAnalyzer.js add <results.json> - 添加测试结果');
|
||||
console.log(' node testTrendAnalyzer.js report - 生成趋势报告');
|
||||
console.log(' node testTrendAnalyzer.js export [file.json] - 导出趋势数据');
|
||||
console.log('');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TestTrendAnalyzer;
|
||||
Reference in New Issue
Block a user