一、为什么测试报告没人看?

大家都有过这样的经历:跑完一次性上千个测试用例,控制台刷出密密麻麻的日志,绿色通过、红色失败混在一起,一眼看过去根本找不到重点。项目经理催着问“冒烟过了没”,你只能硬着头皮翻半天,最后丢一句“大部分都过了,有几个小问题”。其实问题就出在报告本身——太长了,没人愿意逐行去读。尤其是团队里每个人都要关注自己负责模块的失败信息,但是默认的Jest输出会把所有通过的和跳过的测试也完整列出来,加上进度条和统计信息,真正有价值的失败信息被淹没在信息海里。这种情况下,不如定制一个Jest reporter,让输出只保留失败测试的详细内容,看一眼就能知道要修哪些问题。

二、Jest reporter的工作原理

Jest跑完测试后会调用一系列钩子函数,比如onTestResultonRunComplete等。每一个reporter都是一个类,实现了这几个钩子,就能拿到每个测试文件的结果、每个测试用例的状态、错误信息等。默认的jest-jasmine2jest-circus内置了defaultReporter,它会基于这些数据输出格式化的内容。我们要做的,就是写一个自己的reporter,只订阅失败事件,然后把关键的堆栈、断言失败消息打印出来,其他的全部忽略。

2.1 Jets reporter的基本结构

一个Jest reporter其实就是一个构造函数,挂载onTestResultonRunStartonRunComplete等方法。我们不需要全部都实现,只需要拿到失败用例的failureMessages属性。每个测试用例对象里都有status字段,值为'passed''failed''pending'等。我们只关心'failed'

为了方便理解,先看一个最简单的reporter骨架:

/**
 * 技术栈:Node.js / JavaScript (Jest)
 * 文件名:myReporter.js
 * 作用:一个最小化的自定义Jest reporter,输出测试开始和结束的计时信息。
 */
class MyReporter {
  constructor(globalConfig, options) {
    // globalConfig是Jest的全局配置,options是用户在jest.config.js中传给reporter的参数
    this._globalConfig = globalConfig;
    this._options = options;
  }

  onRunStart(results, options) {
    // 整个测试套件开始的时候调用
    console.log('测试开始');
  }

  onTestResult(test, testResult, aggregatedResult) {
    // 每个测试文件执行完毕后调用
    // test: 文件信息,testResult: 该文件内所有测试用例的结果
    console.log(`文件 ${test.path} 执行完成,通过 ${testResult.numPassingTests} 个`);
  }

  onRunComplete(contexts, results) {
    // 所有测试跑完之后调用
    console.log(`测试结束,通过 ${results.numPassedTests} 个,失败 ${results.numFailedTests} 个`);
  }
}

module.exports = MyReporter;

这个reporter可以在jest.config.js里配置:

{
  "reporters": [
    "default",
    "./myReporter.js"
  ]
}

注意配置里可以保留默认的reporter,也可以只用自己的。我们后面要实现只输出失败信息,所以通常会替换掉默认的default,否则默认的还会输出一大堆。

三、写一个只输出失败信息的自定义reporter

3.1 明确需求

我们希望报告里只有:

  • 每个失败测试的全名(describe + it的描述)
  • 断言失败的具体消息(比如expect(received).toBe(expected)里的区别)
  • 堆栈跟踪(栈顶几行就够了,去掉node_modules里的干扰)
  • 所有通过和跳过的测试一律不输出

这样的报告长度会大大缩短,而且还方便粘贴到群聊或者工单里。

3.2 代码实现

我们用class继承一个基类(或者直接写),重点处理onTestResult。这里要注意,Jest会把每个测试文件的结果以testResult.testResults数组返回,每个元素包含title(测试名)、statusfailureMessages(失败时会有数组)。我们遍历这个数组,只打印status === 'failed'的项。

另外,为了不产生冗余换行,我们直接拼装字符串,最后一次性输出。

下面是完整的代码:

/**
 * 技术栈:Node.js / JavaScript (Jest)
 * 文件名:failOnlyReporter.js
 * 功能:只输出失败的测试用例及其失败原因,跳过所有通过和跳过的测试。
 */
class FailOnlyReporter {
  constructor(globalConfig, options) {
    this._globalConfig = globalConfig;
    this._options = options;
    // 用来收集所有失败测试的输出,最后一次性打印
    this._outputLines = [];
    // 标记是否已经输出过标题行
    this._hasHeader = false;
  }

  onTestResult(test, testResult) {
    // 每个测试文件运行完触发
    const failures = testResult.testResults.filter(t => t.status === 'failed');
    if (failures.length === 0) {
      // 这个文件没有失败,直接跳过,不产生任何输出
      return;
    }

    // 如果有失败,先输出一个文件分隔行,方便定位
    const relativePath = test.path.replace(this._globalConfig.rootDir, '');
    this._outputLines.push(`\n--- ${relativePath} ---`);

    for (const testCase of failures) {
      // 获取完整测试名:describe块的名字 + it块的名字
      const fullName = testCase.ancestorTitles.concat([testCase.title]).join(' > ');
      this._outputLines.push(`  FAIL  ${fullName}`);

      // 处理失败消息:可能有多个断言错误,我们取第一个就够了
      if (testCase.failureMessages && testCase.failureMessages.length > 0) {
        const firstFail = testCase.failureMessages[0];
        // 从堆栈中提取有用的错误消息(通常第一行是AssertionError)
        const errorMessage = firstFail.split('\n')[0]; // 只取第一行
        this._outputLines.push(`    ${errorMessage}`);
        // 再取堆栈的第一行调用位置(来自用户代码,不是node_modules)
        const stackLines = firstFail.split('\n').slice(1).filter(line => {
          // 过滤掉node_modules和node内部的栈帧
          return !line.includes('node_modules') && !line.includes('/node/');
        });
        // 最多输出3行堆栈,避免太长
        stackLines.slice(0, 3).forEach(line => {
          this._outputLines.push(`    ${line.trim()}`);
        });
      }
    }
  }

  onRunComplete(contexts, results) {
    // 所有文件跑完后,统一输出收集到的失败信息
    if (this._outputLines.length > 0) {
      console.log('\n================ 失败测试摘要 ================');
      console.log(this._outputLines.join('\n'));
      console.log('\n================ 共 ' + results.numFailedTests + ' 个测试失败 ================');
    } else {
      console.log('\n所有测试通过,没有失败信息需要输出。');
    }
  }
}

module.exports = FailOnlyReporter;

3.3 配置和使用

jest.config.js中将默认reporter替换成我们自己的:

{
  "reporters": [
    "./failOnlyReporter.js"
  ]
}

注意不要同时保留default,否则默认的还会输出全部信息。如果还想保留一些简单的进度提示,可以在自定义reporter里增加onRunStart打印一条“开始测试”就够了。

运行npx jest,你会在控制台看到类似这样的输出:

================ 失败测试摘要 ================

--- /tests/user.test.js ---
  FAIL  UserService > login > should reject invalid password
    expect(received).toBe(expected) – expected true but received false
    at UserService.login (src/services/user.js:42:17)

--- /tests/order.test.js ---
  FAIL  OrderService > createOrder > should throw when stock insufficient
    Error: Stock insufficient for product "P001"
    at new StockError (src/errors/StockError.js:5:13)
    at OrderService.createOrder (src/services/order.js:89:23)

================ 共 2 个测试失败 ================

这样的报告清晰明了,一眼就能看出哪些文件出了问题、具体是什么断言失败、代码位置在哪。团队里其他人只看这个摘要就够了,不用再翻看几千行的原始日志。

四、应用场景分析

这种只输出失败信息的reporter特别适合以下场景:

  • 持续集成(CI)环境:比如Jenkins、GitHub Actions里跑测试,日志通常会被截断或者只能查看最后几行。如果测试报告太长,CI控制台只显示末尾几百行,经常把重要的失败信息切掉。换成自定义reporter后,末尾就是干干净净的失败摘要,运维看日志再也不会漏掉关键错误。
  • 日常开发中的快速验证:本地开发时,我们经常需要只运行某几个测试文件,但有时候还是会有旧测试失败。这时候默认的输出会刷满整个窗口,心烦。用自定义reporter,只看红色部分,心态好很多。
  • 团队周会展示:以前汇报测试进度,要截很多图。现在直接复制粘贴失败列表,两句话就能说清楚“这周有三个bug导致测试失败,分布在两个模块”。
  • 监控告警系统:有些团队把测试结果接入告警机器人(比如钉钉、Slack),如果报告太长,机器人消息会被截断。我们只能输出关键失败信息,消息简洁,适合自动推送。

五、技术优缺点

优点

  1. 大幅减少输出量:只保留失败信息,输出行数通常只有默认的十分之一甚至更少,读起来轻松。
  2. 提升反馈效率:开发者看一眼就知道要修什么,不需要在滚动条里找。
  3. 易于集成:Jest的reporter机制很简单,写一个类就能用,没有额外依赖。
  4. 可扩展性强:可以在reporter里加入自定义逻辑,比如按模块分类输出、统计失败原因分布、发送告警等。

缺点

  1. 丢失通过测试的上下文:如果团队需要知道哪些测试被跳过了(pending状态),或者想查看通过测试的执行顺序,自定义reporter不会输出这些信息。不过通常这些信息只在调试时有用,日常关注失败就够了。
  2. 堆栈过滤可能过于激进:上面我们只取了前3行非node_modules的堆栈,有时候真正的错误在深层调用,被砍掉后可能看不清原因。可以根据需要调整行数或使用更智能的过滤策略。
  3. 无法一键定位到源码:默认的reporter会输出可点击的文件超链接(如果终端支持),自定义reporter如果只是简单打印路径,就不能直接点了。不过可以通过在路径前加上file://或者使用IDE的快捷键跳转(比如VS Code里Ctrl+点击路径)。
  4. 名字可能冲突:如果多个reporter都输出相同的标题,会导致控制台混乱。所以要确保自定义reporter在配置中是唯一的或者排在最前面。

六、注意事项

  • 版本兼容性:Jest从25版本开始reporter接口基本稳定,但仍然建议查阅你使用的Jest版本的文档。onTestResultonRunComplete的参数结构在不同版本可能有细微变化,比如testResult.testResults里的对象字段名。可以先用console.log(JSON.stringify(testResult, null, 2))调试一下实际数据格式。
  • 异步处理:如果reporter内部有异步操作(比如网络请求),需要返回Promise。Jest的reporter钩子本身是异步安全的,但要注意不要在onTestResult里做阻塞操作,否则会影响测试执行速度。
  • 测试覆盖:自定义reporter本身也应该经过测试。可以编写一个简单的测试脚本,调用reporter的方法传入模拟的测试结果,验证输出是否符合预期。因为reporter只是纯函数,测起来很容易。
  • 性能影响:每个测试文件都会触发onTestResult,如果文件很多,字符串拼接可能会有一点开销。但相比测试执行时间,这点开销可以忽略不计。注意避免在循环里做正则匹配或者大量对象复制。
  • 环境变量:可以支持通过环境变量控制是否启用精简模式,比如JEST_FAIL_ONLY=true,这样在CI里默认开启,本地开发时可以选择默认输出。

七、总结

测试报告太长是很多团队都遇到过的问题,但解决起来并不复杂。利用Jest提供的reporter接口,我们可以用几十行代码写一个只输出失败信息的自定义reporter,把无关信息过滤掉,让关键失败信息一目了然。这样不仅减少了阅读负担,也加快了问题定位速度。当然,这个方案也不是银弹——它牺牲了通过测试的可见性,但绝大多数场景下,我们只需要关注失败。如果你的团队正在被超长测试报告困扰,不妨试试这个思路,花半小时写一个专属reporter,说不定能省下每天翻日志的十分钟。

最后,记住扩展你的reporter功能:比如按模块分组、输出错误出现次数、甚至结合邮件通知。插件化的思想是开发效率的体现,我们用最少的代码换来最大的收益。