bun 单元测试问题之 TypeError: First argument must be an Error object

💰 价值

本文我们将学会如何在无法给开源模块提交 MR 的情况下 仅针对测试运行时修改第三方模块。该模式可以帮助我们解决任何 node_modules 导致的测试报错。

我们通过对解法的深入探寻,从第一版到第五版,找到了隔离性最强安全性最高的解法。

最后将这种安全性高的修改三方模块源码模式,封装成成熟方法。故哪怕你没有遇到该问题本文最后总结的模式也是值得借鉴的。

🤔 问题

当你的 bun test 很大概率偶现 TypeError: First argument must be an Error object

ts 复制代码
16 | function AxiosError(message, code, config, request, response) {
17 |   Error.call(this);
18 |
19 |   if (Error.captureStackTrace) {
20 |     console.log(typeof this)
21 |     Error.captureStackTrace(this, this.constructor);
               ^
TypeError: First argument must be an Error object
      at new AxiosError (/path/to/project/node_modules/axios/lib/core/AxiosError.js:21:11)
      at settle (/path/to/project/node_modules/axios/lib/core/settle.js:19:12)
      at handleStreamEnd (/path/to/project/node_modules/axios/lib/adapters/http.js:599:11)
      at emit (node:events:90:22)
      at endReadableNT (internal:streams/readable:862:50)

Bun v1.2.14 (macOS arm64)

这个错误是 follow-redirects 抛出的,follow-redirects 是 axios-mock-adapter 调用的,而 axios-mock-adapter 用来帮助我们更容易 mock axios 发起的请求来做测试。

升级 bun 并不能解决,那我们只能通过修改 follow-redirects 源码来绕过。有问题的代码:

github.com/follow-redi...

ts 复制代码
function CustomError(properties) {
  if (isFunction(Error.captureStackTrace)) {
    Error.captureStackTrace(this, this.constructor);
    // ^ TypeError: First argument must be an Error object
  }
}

为什么 follow-redirects 要这么写,有什么好处我们下篇文章讲。

这里有个诡异的地方,我们打印了 this 然后 this instanceOf Errortrue。但就是报错,估计是 bun 的运行时没有兼容 node 的,在 bun issue 我有留言,希望官方可以调查下。

我们想要修改成如下,从而绕过报错:

diff 复制代码
function CustomError(properties) {
  // istanbul ignore else
  if (isFunction(Error.captureStackTrace)) {
-    Error.captureStackTrace(this, this.constructor);
+    Error.captureStackTrace(new Error(), this.constructor);
  }
  Object.assign(this, properties || {});
  this.code = code;
  this.message = this.cause ? message + ": " + this.cause.message : message;
}

如果是自己的文件那倒方便,但这却是三方依赖,而且在实际生产过程运行没有报错,仅在单元测试过程会报错,那么如何精确修改变成了一个棘手的事情。

🕵️‍♂️ 解法

思路是仅让这次修改发生在单元测试过程,测试结束自动复原,否则可能会影响生产环境,说到这里很容想到利用 bun test 的 preload。

重点:我们要确保无论任何异常都要能复原!

第一版:覆盖源文件 & 监听进程退出复原

bunfig.toml:

toml 复制代码
[test]
preload = ["./tests/fix.ts"]

tests/fix.ts:

ts 复制代码
/**  635 |       Error.captureStackTrace(this, this.constructor);
 *                   ^
 * TypeError: First argument must be an Error object
 *       at new CustomError (D:\xxx\node_modules\follow-redirects\index.js:635:13)
 *       at createErrorType (D:\xxx\node_modules\follow-redirects\index.js:643:27)
 *       at <anonymous> (D:\xxx\node_modules\follow-redirects\index.js:64:29)
 */
function fixFollowRedirectsFirstArgumentMustBeAnErrorObject() {
  const fs = require('node:fs')
  const followRedirectsPath = require.resolve('follow-redirects')

  // 备份原始模块
  const originalCode = fs.readFileSync(followRedirectsPath, 'utf8')

  // 修改模块内容
  const modifiedCode = originalCode.replace(
    /Error\.captureStackTrace\(this, this\.constructor\);/g,
    'Error.captureStackTrace(new Error(), this.constructor);',
  )

  // 临时覆写模块
  fs.writeFileSync(followRedirectsPath, modifiedCode)

  // 测试完成后恢复(需要监听进程退出)
  const restoreFile = (signal: string) => {
    console.log('Receiving signal', signal, 'restoring file...')
    fs.writeFileSync(followRedirectsPath, originalCode)
  }
  process.on('exit', () => {  
    restoreFile('exit')
  })
}

我用了 AI DeepSeek 提供的 monkey-patch 方案,它只在测试阶段生效,因此是安全的。

重点讲一下两点:

  1. const followRedirectsPath = require.resolve('follow-redirects') 这里巧妙使用 require.resolve 获取 node_modules 下的 follow-redirects 入口文件 而非通过拼接字符串 D:\workspace\your-project\node_modules\follow-redirects\index.js 这样更加灵活无论 follow-redirects 入口文件路径以后如何变化都可以"以不变应万变",而且可以规避操作系统以及包管理器带来的差异,让 Node.js 模块解析 require.resolve 帮我们做这个『脏活累活』。

  2. process.on('exit') 是否可以帮助我们即使在异常情况下也能『复原』代码?

我们可以试一试,让单元测试断言失败或者主动抛出未被捕获的异常。

实验结果:断言失败、抛错以及正常情况下都没有复原,也就是信号监听并未触发

尝试增加更多信号:

ts 复制代码
// 测试完成后恢复(需要监听进程退出)
process.on('SIGINT', restoreFile.bind(null, 'SIGINT'))
process.on('SIGTERM', restoreFile.bind(null, 'SIGTERM'))
process.on('exit', restoreFile.bind(null, 'exit'))
process.on('uncaughtException', restoreFile.bind(null, 'uncaughtException'))

依然无法复原(原因未知,我分别在 plugin 和单测文件内打印了 process.pid 和 process.ppid 二者都相同)。

ts 复制代码
process in plugin file: pid 52820 ppid 52896
process in test file:   pid 52820 ppid 52896

第二版:afterAll

使用 bun 测试框架提供的钩子

diff 复制代码
// tests/fix.ts:

// 临时覆写模块
fs.writeFileSync(followRedirectsPath, modifiedCode)

const restoreFile = () => {
  fs.writeFileSync(followRedirectsPath, originalCode)
}

+afterAll(() => {
+  restoreFile()
+})

经过测试(正常、异常:单测失败、主动抛错) afterAll 仍然被执行,也就是我们的『复原方案』是健壮的!

第三版:更安全的方案 ------ 修改副本而非源码 & 覆盖 require 缓存

更安全的方法是创建临时副本而不是修改原文件:

ts 复制代码
// test/fix.ts
import fs from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { Logger, toUnixPath } from './utils'

const logger = new Logger('[bun][fix]')
logger.debugging = true

beforeAll(() => {
  fixFollowRedirectsFirstArgumentMustBeAnErrorObject()
})

function fixFollowRedirectsFirstArgumentMustBeAnErrorObject() {
  const originalPath = require.resolve('follow-redirects')
  const originalDir = path.dirname(originalPath)

  const tempPath = path.join(tmpdir(), 'follow-redirects-index.js')
  logger.log('tempPath:', tempPath)

  // 备份原始模块
  const originalCode = fs.readFileSync(originalPath, 'utf8')

  // 修改模块内容
  // fix error: Cannot find module './debug' from 'C:\Users\foo\AppData\Local\Temp\follow-redirects-index.js'
  let modifiedCode = originalCode.replace(/require\(['"](\.\/[^'"]+)['"]\)/g, (_, relPath) => {
    const absPath = path.join(originalDir, relPath)
    const unixPath = toUnixPath(absPath)
    logger.log('absPath:', absPath)
    logger.log('unixPath:', unixPath)

    return `require('${unixPath}')`
  })

  modifiedCode = modifiedCode.replace(
    /Error\.captureStackTrace\(this, this\.constructor\);/g,
    'Error.captureStackTrace(new Error(), this.constructor);',
  )
  fs.writeFileSync(tempPath, modifiedCode)

  // 覆盖 require 缓存
  const tempModule = require(tempPath)
  const originalModule = require(originalPath)

  originalModule.exports = tempModule

  afterAll(() => {
    restoreFile()
  })

  function restoreFile() {
    logger.log('restoring...')
    delete require.cache[originalPath]

    try {
      fs.unlinkSync(tempPath)
    } catch (unlinkError) {
      logger.error('不应该报错 Error restoring original file:', unlinkError)

      throw unlinkError
    }
  }
}

如果是单文件那没问题,假如 follow-redirects/index.js 依赖了其他模块,比如内置模块、三方模块、自身文件,该方案是否可以正常运行?

事实上 follow-redirects/index.js 三种模块都依赖了,var url = require("url");var debug = require("./debug");require("debug")("follow-redirects");

------ github.com/follow-redi...

------ github.com/follow-redi...

------ github.com/follow-redi...

如果不加下面代码将报错无法找到自身依赖文件 ./debugCannot find module './debug' from 'C:\Users\foo\AppData\Local\Temp\follow-redirects-index.js'

通过将相对路径改成原来的绝对路径可以规避该报错,即让副本内的 debug 正常寻址,回退到 node_modules/follow-redirects/debug.js,这样的好处是无需复制整个 follow-redirects 目录。

/require\(['"](\.\/[^'"]+)['"]\)/require("./debug") 替换成 require("/project/node_modules/follow-redirects/debug")

ts 复制代码
  let modifiedCode = originalCode.replace(/require\(['"](\.\/[^'"]+)['"]\)/g, (_, relPath) => {
    const absPath = path.join(originalDir, relPath)
    const unixPath = toUnixPath(absPath)
    logger.log('absPath:', absPath)
    logger.log('unixPath:', unixPath)

    return `require('${unixPath}')`
  })

第四版:精简版无需复原

复原更好,但是针对我们的清形不复原也可以。为什么无需复原,因为:

1)无需删除临时文件:修改的是系统临时目录下文件,无需删除(系统会自动清理)那如果系统不清除重名怎么办?没问题,重名会覆盖不会报错,而且复用的是同一个文件不会导致文件越来越多;

2)无需删除 require 缓存:缓存在内存中,单元测试一旦结束缓存自动清空,不会影响实际生产运行时。

diff 复制代码
-  afterAll(() => {
-    restoreFile()
-  })
-
-  function restoreFile() {
-    logger.log('restoring...')
-    delete require.cache[originalPath]
-
-    try {
-      fs.unlinkSync(tempPath)
-    } catch (unlinkError) {
-      logger.error('不应该报错 Error restoring original file:', unlinkError)
-
-      throw unlinkError
-    }
-  }

第五版:封装 📦

这种修改三方模块源码的方式具备通用性,我们最后在进阶一版,将这种隔离性强安全的修改三方模块源码模式封装成方法。

一、patchModule 核心方法:封装不变的部分 🗿

ts 复制代码
type IPath = string
type ISourceCode = string


function patchModule(
  moduleName: string,
  modifySourceCode: ({ originalPath }: { originalPath: IPath }) => ISourceCode,
): void {
  const originalPath = require.resolve(moduleName)

  const tempPath = path.join(tmpdir(), `${moduleName}-index.js`)
  logger.log('tempPath:', tempPath)

  fs.writeFileSync(tempPath, modifySourceCode({ originalPath }))

  // 覆盖 require 缓存
  const tempModule = require(tempPath)
  const originalModule = require(originalPath)

  originalModule.exports = tempModule

  afterAll(() => {
    restoreFile()
  })

  function restoreFile() {
    logger.log('restoring...')
    delete require.cache[originalPath]

    try {
      fs.unlinkSync(tempPath)
    } catch (unlinkError) {
      logger.error('不应该报错 Error restoring original file:', unlinkError)

      throw unlinkError
    }
  }
}

二、patch 核心业务操作:隔离变化的部分 ⚡

ts 复制代码
function patch({ originalPath }: { originalPath: IPath }): ISourceCode {
  // 备份原始模块
  const originalCode = fs.readFileSync(originalPath, 'utf8')
  const originalDir = path.dirname(originalPath)

  // 修改模块内容
  // fix error: Cannot find module './debug' from 'C:\Users\foo\AppData\Local\Temp\follow-redirects-index.js'
  let modifiedCode = originalCode.replace(/require\(['"](\.\/[^'"]+)['"]\)/g, (_, relPath) => {
    const absPath = path.join(originalDir, relPath)
    const unixPath = toUnixPath(absPath)
    logger.log('absPath:', absPath)
    logger.log('unixPath:', unixPath)

    return `require('${unixPath}')`
  })

  modifiedCode = modifiedCode.replace(
    /Error\.captureStackTrace\(this, this\.constructor\);/g,
    'Error.captureStackTrace(new Error(), this.constructor);',
  )

  return modifiedCode
}

三、二者结合完成需求 Mission Complete 🤝

ts 复制代码
beforeAll(() => {
  fixFollowRedirectsFirstArgumentMustBeAnErrorObject()
})

function fixFollowRedirectsFirstArgumentMustBeAnErrorObject() {
  return patchModule('follow-redirects', patch)
}

👨‍💻 完整代码

未封装版本

封装版本

github.com/legend80s/s...

🎯 最后

但是导致这个问题的根本原因还未排查到,我怀疑这是 bun 的 bug 没有完全兼容 Node.js,目前 bun 有一个 issue github.com/oven-sh/bun... ,我也留言了。

公众号『JavaScript与编程艺术』

相关推荐
HokKeung11 小时前
Go 项目难测试,问题通常出在代码结构
单元测试·go
@@@@@@@@5412 小时前
JavaScript 的缺点与 TypeScript 的弥补
开发语言·javascript·typescript
用户09340777351414 小时前
HarmonyOS WPS Open SDK 实践:最小打开闭环与 enableEdit 分支处理
typescript·harmonyos
柯克七七1 天前
我把 bug 修得太干净了,测试组以为这个模块本来就没问题
前端·javascript·typescript
退休倒计时1 天前
【每日一题】LeetCode 39. 组合总和 TypeScript
算法·leetcode·typescript
yyt3630458411 天前
KlineChartQuant Tooltip 高频交互 200FPS 性能优化完整复盘
前端·经验分享·性能优化·typescript·vue·交互·chrome devtools
博观而约取厚积而薄发1 天前
Pytest 从入门到精通,一篇就够(超详细实战教程)
python·测试工具·单元测试·自动化·pytest
gis开发之家2 天前
《Vue3 从入门到大神29篇》单元测试与 E2E 测试 —— 保障 Vue3 项目的质量
前端·javascript·vue.js·单元测试·前端框架·vue3
用户0934077735142 天前
HarmonyOS WPS Open SDK 实践:办公审批附件「打开—编辑—回传」代码实现
typescript·harmonyos