在Node.js项目里做单元测试,最麻烦的事情之一就是处理模块之间的硬依赖。很多老代码直接用require把数据库、网络请求或者第三方SDK塞进文件顶部,导致测试时必须启动一整套环境。Proxyquire这个库允许我们在加载模块时偷偷把某些依赖替换成桩对象,从而隔离外部影响。但配置Proxyquire的时候,往往只是一堆嵌套的对象字面量,看不出到底替换了什么、哪些没替换。Proxyquire2Image的目标,就是把这种隐形的桩配置变成一张看得见的图。

Proxyquire的工作原理与桩配置痛点
Proxyquire的核心机制是重写Node.js的模块加载路径。当我们调用proxyquire.load时,它会返回一个经过包装的require函数,这个函数内部维护了一张替换表。如果目标模块通过require('./db')引入依赖,而替换表中存在对应的键值,那么实际拿到的就是桩对象而不是真实模块。这种方式比手动依赖注入要轻量,因为不需要改动业务代码的结构。
不过在真实项目中,一个服务文件可能引入十几个底层模块,Proxyquire的配置对象就会变得很长。比如下面这段配置,如果不小心漏掉了./logger,测试时就会真的写日志。更隐蔽的是,Proxyquire支持全局替换和调用次数校验,这些选项混在对象里很难一眼看清。很多团队只能靠跑测试失败了再回头查配置,效率很低。
const proxyquire = require('proxyquire');
const path = require('path');
const stubs = {
'./db': {
query: () => Promise.resolve([]),
'@global': true
},
'./logger': {
info: () => {},
error: () => {}
},
'./client': {
get: () => Promise.resolve({ ok: true }),
'@times': 3
}
};
const service = proxyquire.load(path.join(__dirname, 'service.js'), stubs);
上面代码里的@global表示即使其他模块也require了./db都会用桩,@times要求get必须被调用三次。这些元信息在纯JS对象里只是字符串键,如果有一个工具能画成图,比如用不同颜色标出全局替换和次数限制,维护者就能在测试前发现配置错误。
用AST解析抽取依赖与桩映射关系
要实现Proxyquire2Image,第一步是把目标模块里所有的require调用找出来。最稳妥的办法是用抽象语法树(AST)解析,而不是简单正则匹配。我们可以借助@babel/parser把源码转成AST,然后遍历CallExpression节点,找出callee.name为require并且参数是字符串字面量的地方。这样不仅能拿到依赖路径,还能知道它在文件的哪一行。
拿到依赖列表后,再和Proxyquire的stubs对象做交集,就能知道哪些被替换了、哪些是真实的。对于没有被桩覆盖的依赖,图像里可以用灰色表示,提醒测试者可能引入了副作用。下面示例展示如何解析并输出依赖数组,这一步完全不涉及真正加载模块,所以非常安全。
const fs = require('fs');
const parser = require('@babel/parser');
function extractRequires(file) {
const code = fs.readFileSync(file, 'utf8');
const ast = parser.parse(code, { sourceType: 'commonjs' });
const requires = [];
ast.program.body.forEach(node => {
if (node.type === 'VariableDeclarator' &&
node.init &&
node.init.type === 'CallExpression' &&
node.init.callee.name === 'require') {
requires.push({
name: node.init.arguments[0].value,
line: node.loc.start.line
});
}
});
return requires;
}
console.log(extractRequires('./service.js'));
有了依赖清单和stubs对象,我们就可以构造一个映射结构。比如{ dep: './db', stubbed: true, global: true, times: 0 }。这个结构后面会直接喂给图像渲染层。值得注意的是,Proxyquire的stub里如果包含@global之类的键,它们在AST里并不会出现,需要从stubs对象自身读取,因此解析器和stubs读取要分开做,再在内存里合并。
将桩配置渲染为可视化图像的完整实现
图像渲染可以选SVG,因为它用纯字符串拼接就能生成,不需要原生依赖。我们为每个依赖画一个矩形,被桩替换的填绿色,全局替换加红边,带次数限制的在旁边写×N。Node.js里把字符串写进.svg文件后,再用sharp这类库转成PNG即可,这样非技术同学也能在报告里直接看。
下面给出一个简化版的渲染函数,它接收前面合并好的映射数组,返回SVG文本。实际项目里可以把字体、颜色提为配置,甚至画箭头表示依赖层级。关键是这个函数必须和Proxyquire加载解耦,也就是说先跑测试或先出图都行,图只是配置的镜像。
function renderSvg(map) {
let rects = '';
map.forEach((item, i) => {
const y = 30 + i * 40;
const fill = item.stubbed ? '#cfe8cf' : '#eeeeee';
const stroke = item.global ? 'red' : '#999';
const label = item.dep + (item.times ? ' ×' + item.times : '');
rects += `<rect x="10" y="${y}" width="300" height="30" fill="${fill}" stroke="${stroke}"/>`;
rects += `<text x="20" y="${y + 20}">${label}</text>`;
});
return `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="${30 + map.length * 40}">${rects}</svg>`;
}
const map = [
{ dep: './db', stubbed: true, global: true, times: 0 },
{ dep: './logger', stubbed: true, global: false, times: 0 },
{ dep: './util', stubbed: false, global: false, times: 0 }
];
const svg = renderSvg(map);
require('fs').writeFileSync('proxyquire-map.svg', svg);
最后把整套逻辑包成一个命令行工具:输入是目标模块路径和stubs定义文件,输出是图片和一份JSON。这样在CI里每次跑单测前生成图,提交到仓库,下次有人改了依赖就能从图的变化看出是否破坏隔离。Proxyquire2Image不是要替代Proxyquire,而是给它加一层可观测性,让单元测试的配置从黑盒变成白盒。
从架构角度看,这种思路也可以延伸到其他加载器,比如mock-require或者ESM的loader钩子。只要能拿到依赖表和替换表,渲染层几乎不用改。对于历史包袱重的Node.js应用,用图像降低理解成本,比写一长串文档更有效,也更容易让新成员上手改测试。
Node.jsProxyquireunit_test修改时间:2026-08-14 01:15:32