☰
clean-webpack-plugin 测试实战:从单元测试到集成测试的完整策略
2026/10/22 11:46:41 网站建设 项目流程

直接说结论: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/**这类规则不被清理cleanOnceBeforeBuildPatterns
dry 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,一会儿是目录非空。排查思路分两步:

  1. 输出目录唯一化:每个测试用例都用Date.now()或随机数拼接独立的输出路径,保证互不干扰;
  2. 串行执行集成测试:脚本里加--runInBand,牺牲一点速度换取确定性。

如果两个方案都做了还出问题,那多半是插件内部有共享状态被多实例改写了。这时候别绕弯子,直接在测试里跑两个编译器实例做并发测试,把冲突复现出来,再回头改插件代码。这种“用测试去驱动重构”的方式,逼着插件实现保持无状态,长期看是收益最大的。

5. 从实践里沉淀的几条测试心得

前面把技术细节讲得差不多了,最后说几句纯经验层面的东西。

第一,测试插件之前,先把插件的生命周期摸清楚。clean-webpack-plugin 挂在编译开始前和编译结束后的不同钩子上,每个钩子拿到的参数不一样,能访问的编译状态也不一样。你只有在代码里完整看懂它在每个时机做了什么,才能构造出对的测试场景。我写测试的节奏通常是这样:先快速读一遍插件源码,把钩子、配置项、分支逻辑全部列到一张表里,然后逐个分支去写测试,比上来就凭感觉写断言要高效得多。

第二,单元测试和集成测试的数量比例要有意识地控制。我的经验是单元测试 70%、集成测试 30%,比例别反过来。不是集成测试不重要,而是集成测试的执行成本和维护成本都高,每个集成用例都要真实编译、真实写盘、真实等待,跑一轮下来动不动几十秒。而单元测试因为跑得快,你能放心大胆地写很多边界用例,把逻辑死角都覆盖到。两者配合,既快又不漏。

第三,测试代码本身也要有可读性。写插件测试时很容易堆砌一堆文件系统操作,几分钟之后自己都看不懂在干嘛。我建议把“创建目录、写文件、断言文件是否存在”这类操作抽成几个私有函数,再在测试里用“语义化”的命名来表达场景,比如seedOldAsset(outputPath, 'app.js')这种,保证三个月后回来看,一眼能明白这个用例在验证什么。

最后说一个小技巧。如果你维护的是一个社区仓库或者公司内部的公共构建工具,每次升级 webpack 版本之后,第一件事就是把集成测试跑一遍。webpack 的钩子参数结构、编译内部对象布局在跨版本升级时经常变化,而这些变化往往是单元测试的 mock 结构发现不了的,只有真实编译才能暴露。我确实经历过一次从 webpack 4 升到 5 的时候,单元测试全绿、集成测试红了一片,就是因为插件代码里读取的一个编译内部字段在新版本中已经不存在了。有了集成测试兜底,那次升级才没有酿成生产事故。测试这种东西,写的时候觉得费劲,可真到关键时候,它比任何代码 review 都靠谱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询