clean-webpack-plugin 测试实战:从单元测试到集成测试的完整策略 直接说结论clean-webpack-plugin 这种“清理产物目录”的小插件听起来没啥技术含量可真要给它写一套靠谱的测试难度一点不比给业务代码写测试低。我最近在一个前端基建项目里专门补了这块测试从单元测试到集成测试折腾了一轮踩了不少坑也沉淀了一套能实际落地的方案。这篇文章就把完整的测试策略、代码细节和排错经验一次性讲清楚适合正在维护构建工具、或准备给自定义 webpack 插件补测试的同学参考。1. 测试策略的前置思考这个插件到底要测什么1.1 插件职责决定测试边界很多人拿到 clean-webpack-plugin 第一反应是它不就删个目录吗有什么好测的但等你真正去读它的源码就会发现事情没那么简单。这个插件在 webpack 编译流程里挂了两个关键钩子一个是编译开始前的清理一个是编译完成后的清理每个阶段还要处理静态匹配模式、动态匹配模式、排除规则、保护产物、dry run、是否允许清理项目目录之外的文件等一系列选项。就拿我项目里的实际场景举例。我们有一个多页面应用构建产物目录里有页面入口 HTML、带 hash 的 JS/CSS、以及其他平台生成的配置文件。如果只是简单粗暴地清空整个 dist那些由其他脚本生成的配置文件也会被误删。所以我们必须依赖cleanOnceBeforeBuildPatterns配合排除模式来精确控制清理范围。这就意味着测试要覆盖的不仅仅是“目录被清空”这个单一结果而是要验证哪些文件会被清理哪些文件会被保留排除规则和反向匹配是否按预期工作hash 文件、目录嵌套结构下的匹配是否正确dry run 模式下是否只输出不删除对 webpack 自身产物的保护机制会不会误伤。如果你直接把这一切都丢给集成测试去验证那测试用例会膨胀得非常厉害而且执行速度很慢。正确的做法是先把核心逻辑拆出来做单元测试再用少量的集成测试做全链路兜底。1.2 单元测试与集成测试的分工逻辑我习惯用一个简单的原则来划分这两类测试单元测试负责验证插件内部的逻辑规则集成测试负责验证插件和 webpack、文件系统之间的真实协作结果。打个比方单元测试就像一个乐器演奏者单独练指法你可以把某个小节反复打磨速度快、反馈直接集成测试则是整个乐队合奏验证每个声部配合起来会不会跑偏。练指法解决不了合奏问题合奏也替代不了单点精修。具体到 clean-webpack-plugin单元测试不启动真实 webpack 编译而是构造一个最小化的“伪编译器”和内存文件系统直接触发插件注册的钩子然后断言文件系统里哪些文件还在、哪些文件没了。这种测试跑得飞快几十个用例也就一两秒。集成测试启动一个真实的 webpack 编译器把输出目录指到临时目录完成一次真实构建再检查产物目录的实际变化。这种测试更接近用户视角但速度慢、跟环境耦合深所以不宜多写挑几个最能代表核心行为的场景就够了。这样分工之后每次改代码你都能通过单元测试精准定位问题又通过集成测试确认没有引入系统性破坏心里比较踏实。2. 单元测试把核心逻辑从编译器里“拎”出来2.1 环境搭建与依赖选型单元测试第一件事是解决“运行环境”问题。clean-webpack-plugin 依赖 webpack 的 tapable 钩子系统所以我们不能完全脱离编译器但也不需要真的去构建一个完整项目。你需要准备以下依赖npm install --save-dev jest memfs clean-webpack-plugin webpack这里的memfs是关键。它可以在内存里模拟一个完整的文件系统接口跟 Node 的fs基本一致但所有读写都在内存中完成既快又没有磁盘残留。测试结束不用专门清理目录非常适合这类插件的单元测试场景。Jest 配置我建议单独给测试文件设置环境保持默认的 node 环境即可不用开 jsdom。package.json 里脚本可以这么配{ scripts: { test: jest --runInBand, test:unit: jest test/unit, test:integration: jest test/integration --runInBand } }--runInBand在测试 webpack 插件时特别重要后面我会专门说并发冲突的坑这里先埋个伏笔。2.2 用内存文件系统模拟输出目录单元测试的核心思路是构造一个假的 compiler 对象让插件往上面挂钩子然后手动触发钩子再用 memfs 验证文件系统的变化。具体写法如下const { SyncHook } require(tapable); const { fs: memfs } require(memfs); const CleanWebpackPlugin require(clean-webpack-plugin); function createMockCompiler(fs, outputPath) { const hooks { emit: new SyncHook([compilation]), afterEmit: new SyncHook([compilation]), thisCompilation: new SyncHook([compilation, params]), compilation: new SyncHook([compilation, params]), done: new SyncHook([stats]), }; const compiler { hooks, options: { output: { path: outputPath }, }, outputPath, outputFileSystem: fs, }; return compiler; } function createCompilation() { return { assets: { index.html: { source: () html/html }, app.bundle.js: { source: () console.log(1) }, }, emittedAssets: [index.html, app.bundle.js], }; }这里几个点需要注意。插件读取的输出目录路径来自compiler.options.output.path也就是outputPath。我用outputFileSystem属性传入 memfs因为 webpack 自身在插件执行、文件写入时走的都是这个文件系统接口。你把 fs 注入的目的是让插件在清理时也能通过compiler.outputFileSystem读写文件这样我们才能断言“它到底干了什么”。触发插件逻辑的标准方式是这样test(清理默认输出目录中未受保护的文件, () { const outputPath /project/dist; const fs memfs; fs.mkdirSync(/project/dist/js, { recursive: true }); fs.writeFileSync(/project/dist/js/app.js, content); fs.writeFileSync(/project/dist/index.html, html); const compiler createMockCompiler(fs, outputPath); const plugin new CleanWebpackPlugin(); plugin.apply(compiler); // 模拟一次编译 const compilation createCompilation(); compiler.hooks.thisCompilation.call(compilation, {}); compiler.hooks.compilation.call(compilation, {}); compiler.hooks.emit.call(compilation); compiler.hooks.afterEmit.call(compilation); compiler.hooks.done.call({}); // 断言清理结果 expect(fs.existsSync(/project/dist/js/app.js)).toBe(false); expect(fs.existsSync(/project/dist/index.html)).toBe(true); });这个用例验证的是默认行为清理那些不在当前编译产物列表里的旧文件。index.html被保留是因为它出现在compilation.assets里受protectWebpackAssets保护js/app.js是旧产物会被清掉。通过控制 assets 内容你就能精确测试保护机制的边界。2.3 最值得写的几个单元测试用例实践下来我推荐至少覆盖下面这些核心场景它们在后续迭代中能帮你挡住大部分回归问题。测试场景验证重点关键配置默认清理旧产物未出现在 assets 中的文件被删除无特殊配置保护当前编译产物assets 中的文件不被删除默认 protectWebpackAssets排除规则生效!**/.git/**这类规则不被清理cleanOnceBeforeBuildPatternsdry run 不删除文件文件系统无变化但能拿到列表dry: true清理后重新构建afterEmit 阶段的行为正确cleanAfterEveryBuildPatterns自定义保留目录指定目录不被触碰自定义 patterns写排除规则用例时注意一个细节cleanOnceBeforeBuildPatterns的默认值是[**/*, !**/.git/**]前面是包含规则后面是排除规则。测试时要模拟出“dist 目录下存在一个.git目录和普通文件”的场景然后断言.git目录被保留、其他文件被删除。这个行为是插件最容易被误伤的经典场景很多项目把构建产物目录纳入 git 管理后都会依赖这条排除规则。我自己在写这类用例时吃过一个亏直接在真实文件系统上跑结果测试执行到一半进程崩溃或者被 CI 清理掉整个开发机上的 dist 都被删了。后来全部切换到 memfs 之后这个问题彻底消失而且测试速度从几十秒降到了一秒以内。这里强烈建议所有单元测试一律走内存文件系统永远不要碰真实磁盘。3. 集成测试贴近真实构建的全链路验证3.1 用最小 webpack 实例拉起真实编译单元测试跑得再爽也不能替代集成测试。因为插件最终是要活在 webpack 真实编译流程里的那些钩子触发的顺序、编译产物的内容、stats对象的结构都是我们自己造的 mock 无法 100% 还原的。所以我建议至少维护一组集成测试每次改动都用真实编译来兜底。集成测试的搭建思路是在测试目录下准备一个最小可用的 webpack 配置目标指向系统临时目录然后通过 webpack 的编程接口启动编译。核心代码如下const webpack require(webpack); const { createFsFromVolume, Volume } require(memfs); const path require(path); const CleanWebpackPlugin require(clean-webpack-plugin); async function runWebpackWithPlugin(options {}) { const outputPath path.join( process.cwd(), test, fixtures, dist-test-${Date.now()} ); const compiler webpack({ mode: development, entry: path.resolve(__dirname, fixtures/entry.js), output: { path: outputPath, filename: options.hashed ? [name].[contenthash].js : [name].js, }, plugins: [ new CleanWebpackPlugin(options.pluginConfig || {}), ], stats: errors-only, }); // 这里依然可以选择用 memfs 替代真实文件系统不过集成测试我更推荐真实目录 const stats await new Promise((resolve, reject) { compiler.run((err, res) { if (err) return reject(err); resolve(res); }); }); return { compiler, stats, outputPath }; }这里有三个实践层面的选择给你解释一下背后的原因。第一为什么集成测试里我反而用真实目录而不是 memfs因为集成测试的目的就是验证真实协作真实目录能暴露路径分隔符、权限、目录层级这些 mock 环境永远模拟不出来的问题。临时目录用完即删风险可控。唯一的代价是测试结束要清理我建议在每个测试的afterAll里用fs.rm递归删除或者干脆用--runInBand串行执行避免互相干扰。第二为什么入口文件要单独准备因为在test/fixtures/entry.js里放一个最简单的 JS 模块能保证 webpack 真实产出 bundle 文件这样才有的可清理。千万别直接用一个空文件或只有一个注释的入口那样 webpack 可能不会产出真实文件测试就没意义了。第三为什么配置里加stats: errors-only因为在测试输出里你只关心有没有编译报错不需要 webpack 打印一大段构建信息。测试失败的时候Jest 会把编译错误完整展示出来信息量足够定位问题了。3.2 对真实目录结构做断言等编译跑完就可以对真实产物目录做断言了。这里我惯用的检查项有三个维度目录内文件的有无、文件内容是否被更新、以及 hash 文件的清理行为。第一个维度最简单直接断言文件存在性test(默认配置会清空旧产物并保留新产物, async () { const { outputPath, compiler } await runWebpackWithPlugin(); // 编译完成产物目录里应该存在新生成的 bundle expect(fs.existsSync(path.join(outputPath, main.js))).toBe(true); // 手动模拟旧文件 fs.writeFileSync(path.join(outputPath, old-chunk.js), console.log(old)); expect(fs.existsSync(path.join(outputPath, old-chunk.js))).toBe(true); // 再次编译 await new Promise((resolve, reject) { compiler.run((err) { if (err) return reject(err); resolve(); }); }); // 旧文件被清理新文件保留 expect(fs.existsSync(path.join(outputPath, old-chunk.js))).toBe(false); expect(fs.existsSync(path.join(outputPath, main.js))).toBe(true); await compiler.close(); });第二个维度是验证“重新构建后旧 hash 文件被删除”这个关键场景。很多项目在第一次构建时生成main.a1b2c3.js第二次构建时内容变化hash 变成main.d4e5f6.js如果清理逻辑有 bug旧文件就会越积越多。集成测试里可以用两次编译来验证第一次编译记录当前 bundle 文件名修改入口文件内容让 hash 变化第二次编译断言旧文件名对应的文件已经不存在。这个用例可以说是 clean-webpack-plugin 的核心价值所在也是它相比 webpack 5 内置output.clean更值得测试的地方。第三我强烈建议在你自己的测试套件里对这种集成测试统一用test/fixtures目录来放所有静态资源和入口文件这样每个测试的输入都是可重复的不会因为代码变化导致测试之间相互污染。3.3 覆盖 watch 模式与多阶段构建集成测试还有一个容易漏掉的点watch 模式。监听模式下 webpack 会持续持有文件系统句柄如果测试不主动关闭编译器Jest 进程会一直挂着最后只能被迫--forceExit而--forceExit会掩盖真正的资源泄漏问题。这个问题我踩过不只一次。正确的做法是在afterAll或finally里显式调用compiler.close()afterAll(async () { if (compiler) { await compiler.close(); } fs.rmSync(outputPath, { recursive: true, force: true }); });注意compiler.close()只在 webpack 5 及以上版本可用。如果你还在维护 webpack 4 的老项目那测试结束时是没有优雅关闭接口的只能在 Jest 配置里设置testTimeout并期望进程自然退出或者用子进程的方式跑编译。我在一个老项目里就是被这个卡住最后改成把 webpack 编译放到一个独立 node 子进程里执行主进程等子进程结束才能正常退出虽然笨一点但至少测试是稳定的。另外多阶段构建场景也值得单独测一下。有的项目在同一个编译器上跑多 pass 编译比如先跑 SSR bundle 再跑客户端 bundle每次构建都触发清理逻辑。如果清理时机不对可能把上一阶段刚生成的产物删掉。这种场景用集成测试复现最直观test(多编译器实例互不干扰, async () { const compilerA await createCompiler(dist-a); const compilerB await createCompiler(dist-b); // 并行跑两个编译 await Promise.all([ runCompile(compilerA), runCompile(compilerB), ]); // 各自的输出目录只包含各自的产物 expect(fs.readdirSync(outputPathA).length).toBeGreaterThan(0); expect(fs.readdirSync(outputPathB).length).toBeGreaterThan(0); });这个用例的价值在于一旦有人在插件代码里用了全局状态或共享变量并行编译时就会出现输出目录互相污染的问题。跑一轮这种测试比审十遍代码都管用。4. 高频踩坑实录与排查思路4.1 glob 反向匹配失效的真相clean-webpack-plugin 的清理模式基于 glob 匹配最常见的问题是“我明明写了排除规则为什么文件还是被删了”。这里的坑在于反向匹配的写法必须和正向匹配同时出现而且顺序有讲究。// 错误写法只有排除规则 new CleanWebpackPlugin({ cleanOnceBeforeBuildPatterns: [!**/.git/**], }); // 正确写法先包含再排除 new CleanWebpackPlugin({ cleanOnceBeforeBuildPatterns: [**/*, !**/.git/**], });第一个写法之所以失效是因为整个模式列表的语义是“最终匹配到的文件”如果只有排除规则实际上没有定义“要清理什么”插件内部拿到空的有效匹配后可能就放弃了清理或者清理逻辑直接跳过。测试时一定要把这个场景覆盖进去我在实际项目中确实遇到过同事只写排除规则导致旧产物根本清不掉的诡异现象。另一个相关坑是嵌套排除。假设你想保留dist/static/images下的所有图片同时还想清理其他文件cleanOnceBeforeBuildPatterns: [ **/*, !**/static/images/**, ]这个写法看着没毛病但如果目录里还有static/images/logo.png和static/images/sub/icon.png根据 glob 匹配规则!**/static/images/**是一个多级通配符它能匹配到所有层级下的内容这个用例通常没问题。可一旦你的排除规则写成!static/images/**而实际的清理路径是以dist为根进行匹配的规则不生效图片被删就会直接炸。排查这类问题时最有效的办法是先写一个单元测试用 memfs 构造同样的目录结构然后一行行比对匹配结果不要在大项目里通过反复构建去猜。4.2 Windows 路径分隔符引发的玄学问题路径分隔符的坑在 CI 环境里特别容易爆发。开发者在 macOS 上跑测试一切正常一上了 Windows 的 CI runner测试就莫名其妙地失败文件删不掉或者匹配不到。原因很简单glob 模式统一用的是/作为分隔符但 Node 的path.relative和fs接口在 Windows 上返回的是反斜杠\。我在写测试时吃过一次大亏断言里直接拼接了path.join(outputPath, js/app.js)在 Windows 上变成js\app.js而插件内部的 glob 匹配是基于正斜杠的两边一对比自然对不上。解决办法是强制统一路径格式const normalizePath (p) p.split(path.sep).join(/); expect(normalizePath(filePath)).toBe(/project/dist/js/app.js);如果是直接读写 memfs还要注意 memfs 内部实现默认也按 POSIX 路径处理所以你在单元测试里写路径最好直接用/不要用path.join。这个细节能让你少折腾半天时间。4.3 protectWebpackAssets 与 dry 模式容易误判protectWebpackAssets默认是true含义是清理时跳过“本次编译产出的所有 asset”。这个机制本意是防止清理规则误伤当前构建结果但测试时容易产生认知偏差很多人以为关了它之后所有文件都会被清掉结果发现某些文件还是被保留了。实际原因是默认的cleanOnceBeforeBuildPatterns里有!**/.git/**这条排除规则它跟protectWebpackAssets是两套独立逻辑都作用于最终清理列表。测试时要分开验证保护机制测试只关闭保护不修改 patterns看 assets 是否被删排除规则测试保持保护开启修改 patterns看.git是否被保留。两者混在一起出了问题根本分不清是谁的锅。我后来养成了一个习惯每个测试用例只改一个变量这样信号极其干净调试效率翻倍。dry: true模式也是同理。dry 模式下插件不会真正删除文件只会把“将要删除的文件列表”输出到日志里。集成测试里验证 dry 模式正确的断言是“文件仍然存在”而不是“文件被删除”。如果你写反了测试当然会红。而单元测试里验证 dry 模式可以抓取插件内部暴露的日志方法或返回值断言列表内容包含预期文件。4.4 测试并发导致的多实例冲突最后必须重点说并发问题。Jest 默认会并行执行多个测试文件而 clean-webpack-plugin 的测试往往都往同一个输出目录写文件。一旦两个测试文件同时指向同一个 dist 目录就会出现“A 测试正在断言 main.js 还在B 测试已经把 main.js 删了”之类的随机失败。这类问题最恶心的地方在于本地跑偶尔过CI 上经常红而且报错信息乱七八糟一会儿是 ENOENT一会儿是目录非空。排查思路分两步输出目录唯一化每个测试用例都用Date.now()或随机数拼接独立的输出路径保证互不干扰串行执行集成测试脚本里加--runInBand牺牲一点速度换取确定性。如果两个方案都做了还出问题那多半是插件内部有共享状态被多实例改写了。这时候别绕弯子直接在测试里跑两个编译器实例做并发测试把冲突复现出来再回头改插件代码。这种“用测试去驱动重构”的方式逼着插件实现保持无状态长期看是收益最大的。5. 从实践里沉淀的几条测试心得前面把技术细节讲得差不多了最后说几句纯经验层面的东西。第一测试插件之前先把插件的生命周期摸清楚。clean-webpack-plugin 挂在编译开始前和编译结束后的不同钩子上每个钩子拿到的参数不一样能访问的编译状态也不一样。你只有在代码里完整看懂它在每个时机做了什么才能构造出对的测试场景。我写测试的节奏通常是这样先快速读一遍插件源码把钩子、配置项、分支逻辑全部列到一张表里然后逐个分支去写测试比上来就凭感觉写断言要高效得多。第二单元测试和集成测试的数量比例要有意识地控制。我的经验是单元测试 70%、集成测试 30%比例别反过来。不是集成测试不重要而是集成测试的执行成本和维护成本都高每个集成用例都要真实编译、真实写盘、真实等待跑一轮下来动不动几十秒。而单元测试因为跑得快你能放心大胆地写很多边界用例把逻辑死角都覆盖到。两者配合既快又不漏。第三测试代码本身也要有可读性。写插件测试时很容易堆砌一堆文件系统操作几分钟之后自己都看不懂在干嘛。我建议把“创建目录、写文件、断言文件是否存在”这类操作抽成几个私有函数再在测试里用“语义化”的命名来表达场景比如seedOldAsset(outputPath, app.js)这种保证三个月后回来看一眼能明白这个用例在验证什么。最后说一个小技巧。如果你维护的是一个社区仓库或者公司内部的公共构建工具每次升级 webpack 版本之后第一件事就是把集成测试跑一遍。webpack 的钩子参数结构、编译内部对象布局在跨版本升级时经常变化而这些变化往往是单元测试的 mock 结构发现不了的只有真实编译才能暴露。我确实经历过一次从 webpack 4 升到 5 的时候单元测试全绿、集成测试红了一片就是因为插件代码里读取的一个编译内部字段在新版本中已经不存在了。有了集成测试兜底那次升级才没有酿成生产事故。测试这种东西写的时候觉得费劲可真到关键时候它比任何代码 review 都靠谱。