普通视图
bun 单元测试问题之 TypeError: First argument must be an Error object
💰 价值
本文我们将学会如何在无法给开源模块提交 MR 的情况下且仅针对测试运行时修改第三方模块。该模式可以帮助我们解决任何 node_modules
导致的测试报错。
我们通过对解法的深入探寻,从第一版到第五版,找到了隔离性最强安全性最高的解法。
最后将这种安全性高的修改三方模块源码模式,封装成成熟方法。故哪怕你没有遇到该问题本文最后总结的模式也是值得借鉴的。
🤔 问题
当你的 bun test 很大概率偶现 TypeError: First argument must be an Error object
:
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 源码来绕过。有问题的代码:
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 Error
为 true
。但就是报错,估计是 bun 的运行时没有兼容 node 的,在 bun issue 我有留言,希望官方可以调查下。
我们想要修改成如下,从而绕过报错:
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:
[test]
preload = ["./tests/fix.ts"]
tests/fix.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 方案,它只在测试阶段生效,因此是安全的。
重点讲一下两点:
-
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
帮我们做这个『脏活累活』。 -
process.on('exit')
是否可以帮助我们即使在异常情况下也能『复原』代码?
我们可以试一试,让单元测试断言失败或者主动抛出未被捕获的异常。
实验结果:断言失败、抛错以及正常情况下都没有复原,也就是信号监听并未触发。
尝试增加更多信号:
// 测试完成后恢复(需要监听进程退出)
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 二者都相同)。
process in plugin file: pid 52820 ppid 52896
process in test file: pid 52820 ppid 52896
第二版:afterAll
使用 bun 测试框架提供的钩子
// tests/fix.ts:
// 临时覆写模块
fs.writeFileSync(followRedirectsPath, modifiedCode)
const restoreFile = () => {
fs.writeFileSync(followRedirectsPath, originalCode)
}
+afterAll(() => {
+ restoreFile()
+})
经过测试(正常、异常:单测失败、主动抛错) afterAll
仍然被执行,也就是我们的『复原方案』是健壮的!
第三版:更安全的方案 —— 修改副本而非源码 & 覆盖 require 缓存
更安全的方法是创建临时副本而不是修改原文件:
// 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");
如果不加下面代码将报错无法找到自身依赖文件 ./debug
:Cannot 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")
。
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 缓存:缓存在内存中,单元测试一旦结束缓存自动清空,不会影响实际生产运行时。
- 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
核心方法:封装不变的部分 🗿
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
核心业务操作:隔离变化的部分 ⚡
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 🤝
beforeAll(() => {
fixFollowRedirectsFirstArgumentMustBeAnErrorObject()
})
function fixFollowRedirectsFirstArgumentMustBeAnErrorObject() {
return patchModule('follow-redirects', patch)
}
👨💻 完整代码
未封装版本
封装版本
🎯 最后
但是导致这个问题的根本原因还未排查到,我怀疑这是 bun 的 bug 没有完全兼容 Node.js,目前 bun 有一个 issue github.com/oven-sh/bun… ,我也留言了。
公众号『JavaScript与编程艺术』
写一个 bun 插件解决导入 svg 文件的问题 - bun 单元测试系列
💎 价值
本文通过自定义 bun 插件解决 bun 具名导入 svg 遇到的问题,同时通过灵活运用 onResolve
和 onLoad
钩子『编织出』导入关系图
,精准还原导入方式,让我们熟悉了插件的写法和生命周期。
🤕 问题
如果你在代码里面通过这种 svgr 自动转 React Component 的写法导入 svg 文件,代码运行没问题,但是 bun test 会失败。
import { ReactComponent as ApiKeyIcon } from './api-key.svg'
SyntaxError: Import named 'ReactComponent' not found in module '/path/to/repo/api-key.svg'.
早在 2023 年就有很多人在 bun 的 issue 反馈 bun test fails whenever it encounters an SVG #3673,这是从 CRA 项目转到 bun 遇到的第一个问题。
🧩 解法
写一个 bun 插件,通过 @svgr/core 将“不认识”的模块转成 React Component:
下面是 issue github.com/oven-sh/bun… 内给出的写法。
# bunfig.toml
[test]
preload = ["./bunSvgPlugin.ts"]
// bunSvgPlugin.ts
import { plugin } from 'bun';
plugin({
name: 'SVG',
async setup(build) {
const { transform } = await import('@svgr/core');
const { readFileSync } = await import('fs');
build.onLoad({ filter: /\.(svg)$/ }, async args => {
const text = readFileSync(args.path, 'utf8');
const contents = await transform(
text,
{
icon: true,
exportType: 'named',
plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'],
},
{ componentName: 'ReactComponent' }
);
return {
contents,
loader: 'js', // not sure why js and not jsx, but it works only this way
};
});
},
});
我对其做了一些修改,1)因为只是测试我们无需真正地将 svg 一比一转换,这样能减少包依赖且性能更好。2)且上述代码默认我们所有的引用方式都是命名导出 import as
,而其实默认引用无需转换,否则会导致 import fooIcon from 'path/to/foo.svg'
这种写法也会一并转换(默认导出通常会配合 <img src={fooIcon} />
使用),所以我们需要过滤出这些非命名导出这些才是我们的目标。
第一步:极简转换 svg
我们只是做单元测试或者集成测试,并非端到端测试,不是让真正让组件渲染出来,故可以简化 svg 的转换,只要能标识出 svg 的“身份”即可,“身份”用 svg 的路径唯一标识即可。
即这段代码的用途:
export function ReactComponent() {
return <svg
aria-label="${relativeSvgPath} simplified by bunSvgPlugin">
</svg>
}
故这些包都可以不用:
// package.json
- @svgr/core
- @svgr/plugin-svgo
- @svgr/plugin-jsx
// tests/svgPlugin.ts
-const { transform } = await import('@svgr/core');
-const contents = await transform(
- text,
- {
- icon: true,
- exportType: 'named',
- plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'],
- },
- { componentName: 'ReactComponent' }
-);
+ const relativeSvgPath = toUnixPath(path.relative('src', args.path))
+const contents = `export function ReactComponent() {
+ return <svg aria-label="${relativeSvgPath} simplified by +bunSvgPlugin"></svg>
+}`
注意:svg 的路径要相对路径,否则在你电脑上可以运行,但在 CI 或其他人的电脑上单测就失败。
第二步:识别出真正需要转换的 svg - 命名导出
这一步是本文最难的地方,我们既要拿到 svg 的路径,而且要拿到 import 这些 svg 的文件本身,然后分析其 import 方式,是否是严格的命名导出,即 import { ReactComponent as xx } from 'xxx'
。
首先我们要熟悉 bun 插件的生命周期:
插件可以注册回调到打包过程的不同阶段,即生命周期:
onStart()
: Run once the bundler has started a bundleonResolve()
: Run before a module is resolvedonLoad()
: Run before a module is loaded.onBeforeParse()
: Run zero-copy native addons in the parser thread before a file is parsed.
onResolve
和 onLoad
是我们本文的重点。
之前的代码仅使用了 onLoad
收集到了 svg 本身的路径,我们还需要知道导入这个 svg 的文件的路径才能识别是否符合我们的目标导入方式,可以用 ast 或者正则表达式,简单起见使用正则表达式即可。我们可以通过 onResolve
建立二者的导入关系图。
2.1 第一步:建立导入关系图
首先需要熟悉 onResolve
的参数:
type PluginBuilder = {
onStart(callback: () => void): void;
onResolve: (
args: { filter: RegExp; namespace?: string },
callback: (args: { path: string; importer: string }) => {
path: string;
namespace?: string;
} | void,
) => void;
onLoad: (
args: { filter: RegExp; namespace?: string },
defer: () => Promise<void>,
callback: (args: { path: string }) => {
loader?: Loader;
contents?: string;
exports?: Record<string, any>;
},
) => void;
config: BuildConfig;
};
type Loader = "js" | "jsx" | "ts" | "tsx" | "css" | "json" | "toml";
onResolve
的 callback
我们可以拿到:
-
importer
:命中 filter 的文件路径 -
path
:文件内的导入语句的 path
// 存储文件导入关系
const importGraph = new Map<string, Set<string>>()
const SVG_REGEXP = /^(?!.*node_modules).*\.(svg)$/
// 第一步:建立导入关系图
build.onResolve({ filter: SVG_REGEXP }, (args) => {
if (!importGraph.has(args.importer)) {
importGraph.set(args.importer, new Set())
}
importGraph.get(args.importer)?.add(args.path)
return null
})
importGraph
key 是文件路径,value 是文件内发生的导入事件。比如假设 foo.tsx 导入了两个命名 svg,一个默认导入:
// foo.tsx
import { ReactComponent as CopyIcon } from '@/assets/copy-gray.svg'
import { ReactComponent as ReloadIcon } from './reload.svg'
import userIcon from './user.svg'
那么形成的导入关系图如下:
// importGraph is
Map {
'/absolute/path/to/foo.tsx': Set {
'@/assets/copy-gray.svg',
'./reload.svg',
'./user.svg'
}
}
经过这一步我们就可以在 onLoad
中根据 svg 的 path 反查出引入其的文件路径,然后分析文件内容里面的导入方式即可确定是否需要转换。
2.1 第二步:反向查找哪些文件导入了当前 SVG
在 onLoad
回调中利用 onResolve
建立的引用关系反查导入方式是否符合目标。
build.onLoad({ filter: SVG_REGEXP }, (args) => {
// log('importGraph:', importGraph)
// 检查是否有文件通过 `{ ReactComponent }` 导入此 SVG
const shouldTransform = checkIfReactComponentImport(args.path)
if (!shouldTransform) {
log('not:', args.path)
return {
contents: `export default ${JSON.stringify(args.path)}`,
loader: 'js',
}
}
log('yes:', args.path)
const relativeSvgPath = toUnixPath(path.relative('src', args.path))
return {
contents: `export function ReactComponent() { return <svg aria-label="${relativeSvgPath}-simplified by bunSvgPlugin"></svg> }`,
loader: 'js', // not sure why js and not jsx, but it works only this way
}
})
关键代码在 checkIfReactComponentImport
:
// 第二步:反向查找哪些文件导入了当前 SVG
function checkIfReactComponentImport(svgPath: string): boolean {
for (const [importer, importedFiles] of importGraph) {
if (
Array.from(importedFiles).some((relativeSvgPath) =>
isPathEquivalent({ relativePath: relativeSvgPath, absolutePath: svgPath }),
)
) {
const code = readFileSync(importer, 'utf8')
if (hasReactComponentImport(code, svgPath)) {
return true
}
}
}
return false
}
读取文件内容然后验证导入方式是否符合目标 hasReactComponentImport
:
function hasReactComponentImport(code: string, svgPath: string) {
const relativeSvgPath = path.relative('src', svgPath)
return code.split('\n').some((line) => {
if (line.includes(`import { ReactComponent as `)) {
if (line.includes(toUnixPath(relativeSvgPath))) {
debug('line hit condition 1:', line)
return true
} else {
// @ts-expect-error
const importPath = line.split(' from ').at(-1).split("'").at(1) as string
if (isPathEquivalent({ absolutePath: svgPath, relativePath: importPath })) {
debug('line hit condition 2:', line)
return true
}
debug('line miss:', line)
}
}
})
}
逐行分析首先匹配到 import { ReactComponent as
这种导入模式,然后分析其导入的路径是否匹配 svg 路径。
完整代码,见 github github.com/legend80s/s… 。
🎯 总结
本文比较硬,开发了一个 bun svg plugin 解决了 bun test 遇到导入 svg 文件的报错问题 Import named 'ReactComponent' not found in module,使用了一种高效、隔离度高、精准的方式。注意本文的插件只适用于单元测试,如果是构建给生产环境用,仍然需要 svgr 一比一进行转换。
公众号『JavaScript与编程艺术』