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与编程艺术』

相关推荐
正义的大古39 分钟前
Vue 3 + TypeScript:深入理解组件引用类型
前端·vue.js·typescript
孟陬4 小时前
写一个 bun 插件解决导入 svg 文件的问题 - bun 单元测试系列
react.js·单元测试·bun
孟陬4 小时前
CRA 项目 create-react-app 请谨慎升级 TypeScript
react.js·typescript
孟陬4 小时前
TypeScript v5 一个非常有用的新语法:“const 类型参数”
typescript
JohnYan11 小时前
Bun技术评估 - 25 Utils(实用工具)
javascript·后端·bun
2025年一定要上岸15 小时前
【接口自动化】-5- 接口关联处理
功能测试·单元测试·自动化
我是火山呀2 天前
React+TypeScript代码注释规范指南
前端·react.js·typescript
泯泷3 天前
Tiptap 深度教程(二):构建你的第一个编辑器
前端·架构·typescript
一枚前端小能手3 天前
🔥 TypeScript高手都在用的4个类型黑科技
前端·typescript