做完UI自动化测试后,很多人会习惯性地打开Cypress的screenshots目录,翻看那些以随机字符串命名的PNG文件。截图确实生成了,但要用的时候却得逐个打开确认,时间一长目录也会变得混乱。其实Cypress截图功能本身足够可靠,问题在于缺少一套围绕截图文件的后处理机制。用Node.js写一个独立的脚本,在测试结束后统一收集、重命名、归档截图,并生成一份可读的报告索引,远比在测试用例里反复调用截图命令更实用。

先厘清一个基本事实:Cypress的截图分为手动截图和自动截图两类。手动截图通过cy.screenshot()触发,文件名可以通过参数指定,例如cy.screenshot('login-form')会生成login-form.png。自动截图则由配置项screenshotOnRunFailure控制,默认值为true,也就是说任何一个用例失败时,Cypress都会在失败瞬间自动截取页面状态,文件名采用测试套件名加用例名的拼接规则,中间用短横线连接。理解这两类截图的命名差异,是后续用Node.js做文件处理的基础。
截图的生成路径与命名规则
Cypress默认把截图存放在项目根目录下的cypress/screenshots文件夹中。手动截图会直接落在该目录下,而自动截图则会根据测试文件路径创建对应的子目录。举个例子,如果测试文件位于cypress/e2e/checkout/payment.cy.js,其中某个用例失败触发了自动截图,那么最终文件会出现在cypress/screenshots/checkout/payment.cy.js/payment-失败用例名.png这样的层级中。
这种嵌套结构在Cypress自带的Dashboard里浏览不算麻烦,但如果你想把截图推送到CI的构件存储、上传到对象存储,或者给测试报告系统做附件,就需要先将它们展平到一个统一目录。Node.js脚本在这个环节的价值就体现出来了:遍历screenshots目录时,使用fs.readdir配合recursive: true选项可以一次性拿到所有层级的文件路径,然后根据原路径中的关键信息重新组织文件名。
需要特别注意Windows环境下的路径分隔符问题。如果测试脚本跑在Windows的CI节点上,path.sep返回的值是反斜杠,而远端报告系统通常期望正斜杠格式的相对路径。在拼接输出路径时,统一使用path.posix.join比直接做字符串替换更稳妥,可以避免C:\这类盘符前缀被错误处理。
构建Node.js截图后处理脚本
脚本的核心逻辑可以拆成四步:扫描截图目录、解析文件名中的用例信息、复制或移动文件到目标目录、生成索引HTML。下面是一个可直接运行的实现,假设Node.js版本不低于18。
const fs = require('fs');
const path = require('path');
const SOURCE_DIR = path.resolve(process.cwd(), 'cypress/screenshots');
const OUTPUT_DIR = path.resolve(process.cwd(), 'artifacts/screenshots');
const REPORT_FILE = path.resolve(OUTPUT_DIR, 'index.html');
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function collectScreenshots(sourceDir) {
if (!fs.existsSync(sourceDir)) {
console.warn('截图目录不存在,跳过收集。');
return [];
}
return fs.readdirSync(sourceDir, { recursive: true })
.filter(file => typeof file === 'string' && file.toLowerCase().endsWith('.png'));
}
function parseCaseInfo(relativePath) {
const normalized = relativePath.replace(/\\/g, '/');
const parts = normalized.split('/');
const fileName = parts.pop();
const specName = parts.length > 0 ? parts.join('_') : 'root';
const baseName = fileName.replace(/\.png$/i, '');
const hashIndex = baseName.lastIndexOf(' (');
const cleanName = hashIndex > -1 ? baseName.slice(0, hashIndex) : baseName;
return { specName, cleanName };
}
function processScreenshots() {
ensureDir(OUTPUT_DIR);
const files = collectScreenshots(SOURCE_DIR);
const records = [];
for (const relPath of files) {
const sourcePath = path.join(SOURCE_DIR, relPath);
const { specName, cleanName } = parseCaseInfo(relPath);
const outputName = `${specName}__${cleanName}.png`;
const outputPath = path.join(OUTPUT_DIR, outputName);
fs.copyFileSync(sourcePath, outputPath);
records.push({ outputName, specName, cleanName, sourcePath: relPath });
}
const listItems = records.map(record => {
return `<li><strong>${record.cleanName}</strong><br /><span>来源:${record.specName}</span></li>`;
}).join('\n');
const htmlContent = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>截图报告</title>
</head>
<body>
<h1>测试截图索引</h1>
<p>共 ${records.length} 张截图</p>
<ul>
${listItems}
</ul>
</body>
</html>`;
fs.writeFileSync(REPORT_FILE, htmlContent, 'utf8');
console.log(`截图处理完成,共导出 ${records.length} 个文件。`);
}
processScreenshots();
上面的脚本把每个PNG文件从嵌套目录复制到artifacts/screenshots下,重命名为“测试套件名__用例名.png”的格式。双下划线用作分隔符,可以避免用例名中本身包含连字符时与原有命名规则混淆。索引HTML里只列出了文件名和来源套件,你可以在此基础上扩展,加入缩略图展示或截图时间排序。
还有一个细节值得关注:Cypress在文件重名时会自动追加时间戳和随机后缀,形如login-form (2024-11-03T08_12_45_123Z).png。如果脚本不处理这些后缀,同一个用例多次运行产生的截图会堆积成多个文件。上面的正则逻辑会把最后一个“ (”之后的内容裁掉,使同一用例的截图始终映射到同一个输出文件名,后一次运行自然覆盖前一次。如果你需要保留历史截图用于趋势对比,可以关掉这个裁剪逻辑,改用带时间戳的归档目录来区分批次。
集成到CI流水线并控制截图规模
在本地开发时截图越多越便于排查,但CI流水线里的截图如果无差别上传,会显著拖慢任务结束时间。一个实用的策略是根据测试结果决定是否执行后处理脚本:只有存在失败用例时才收集自动截图,全部通过时只保留手动截图或直接跳过。Cypress模块化API提供了cypress.run()的返回值,其中totalFailed字段可以用于这种条件判断。
const cypress = require('cypress');
const { execSync } = require('child_process');
async function runTests() {
const result = await cypress.run({
config: {
screenshotOnRunFailure: true,
video: false
}
});
if (result.totalFailed > 0) {
console.log('检测到失败用例,执行截图导出。');
execSync('node scripts/export-screenshots.js', { stdio: 'inherit' });
} else {
console.log('全部用例通过,跳过截图导出。');
}
process.exit(result.totalFailed > 0 ? 1 : 0);
}
runTests().catch(err => {
console.error(err);
process.exit(1);
});
这段代码展示了如何在同一个Node.js进程中驱动Cypress并衔接后处理逻辑。cypress.run()返回的对象包含runs数组和汇总统计字段,totalFailed是其中最常用的一个。你还可以读取result.runs[0].tests拿到每个用例的执行状态,进一步筛选出仅与失败用例相关的截图,减少无用文件的导出数量。
至于截图占用的存储空间问题,建议在每次流水线开始前清理上一次的screenshots目录。可以在package.json的scripts里组合命令,例如"test:ci": "node scripts/clean-screenshots.js && node scripts/run-tests.js"。清理脚本同样是用fs.rmSync配合recursive: true,几行代码就能完成。如果截图需要长期保存用于视觉回归训练集,则应当推送到专用的存储服务,而不是留在CI工作区内。
常见问题与边界场景
第一个容易踩坑的地方是fs.readdirSync的recursive选项。该选项返回的元素类型在旧版本Node.js中只有字符串,相对较高的版本中如果设置了withFileTypes: true则会返回Dirent对象,脚本里写死typeof file === 'string'的判断就是为了兼容这种差异。如果你的Node.js版本固定在20以上,可以简化这部分逻辑。
第二个问题是截图文件名中可能包含中文或特殊字符。Cypress对非ASCII字符的用例名会做转义处理,例如空格被替换为短横线,但中文通常保留原样。在生成索引HTML时需要注意字符编码,上面脚本中明确写出了meta charset="UTF-8",并且writeFileSync指定了utf8编码,可以避免浏览器打开报告时出现乱码。如果截图名称中包含HTML敏感字符,还应该在写入索引前做一次实体转义,否则报告页面可能显示异常。
第三个边界场景是多个测试套件共用同一个用例名的情况。重命名后的文件可能因为来自不同套件而产生冲突,此时双下划线前面的套件名就起到了区分作用。但如果套件名本身也相同,例如不同目录下存在两个同名测试文件,脚本会把它们归并到同一个输出文件。遇到这种情况可以在parseCaseInfo返回的对象中保留更完整的原始路径信息,并在输出文件名中追加短哈希,保证每个来源都有独立归档。
总的来说,Node.js加Cypress的组合并不复杂,关键在于把截图从测试框架的内部产物提升为一等公民数据。通过规范的命名、集中的归档和可读的索引,截图才能从被动排查工具变成测试质量分析的输入源。这套脚本可以直接放进现有的测试工程里,也可以改造成CLI工具供多个项目复用。
Cypress截图Node.js自动化测试可视化修改时间:2026-09-26 23:21:09