你好,我是杨文坚。
回顾前面Vue.js 3.x自研组件库的几节课,我们分别学习了如何开发主题方案、基础组件、动态渲染组件、布局组件和表单组件,这些都是构成基础组件库的主要因素,也是我们后续开发业务组件库和打造一个运营搭建平台的前端“基石”。
要知道,在开发业务组件库和打造运营搭建平台的时候,组件库代码的“稳定性”和“健壮性”是非常重要的。如果基础组件库不稳定,经常出问题,那么基于它构成的业务组件或前端页面就会频繁出Bug。那么,组件库出问题会有哪些原因呢?
一般是组件的“逻辑分支多”和“测试不彻底”。举个例子,假设你开发了一个按钮组件(Button),按钮组件又被对话框组件、表单组件使用。这时候如果你给按钮组件添加一个监听键盘快捷键的功能,开发完成后,经过人工验证保证了按钮组件本身原有功能一切正常,但使用了按钮组件的对话框和表单组件,也能正常使用吗?是不是也得人工验证一遍?如果按钮组件被十多个其它基础组件引用,是不是也得逐个人工验证?
**这里的组件设计和内部依赖使用出现了“逻辑分支多”的问题,涉及的逻辑功能都要人工验证,容易导致“测试不彻底”的隐患。**随着组件库里的组件积少成多,这类隐患也越来越多,最终可能“量变引起质变”,导致“千里之堤,溃于蚁穴”的生产故障。
那么,有办法打破这一困境吗?答案当然是有的,我们可以使用“单元测试”,通过技术的手段来自动化“测试代码”。
什么是单元测试?
单元测试,英文是Unit Test,也可以称之为“模块测试”,主要是对代码最小单位逐一进行测试验证功能。这里的“代码最小单位”可以是一个函数、一个组件、一个类,甚至是一个变量。只要是能执行功能的代码模块,都可以称之为一个“最小单位”。
单元测试里的“单元”,是代码里可以执行的“单位”,测试就是验证这个最小单位的代码执行完后的结果是否符合预期。
举个例子,如果我们要开发一个数学加减乘除的功能代码,加法函数就是其中一个可执行的最小单位:
1 | // 这是一个加减乘除的函数集合对象 |
这时候,要对加法函数这个“单元”进行单元测试,如果测试成功,就输出成功提示,如果测试失败,也就是测试结果不符合预期,就抛出错误(throw Error)。我们可以这么来实现测试代码:
1 | const result = myMath.add(1, 2); |
上述代码在Node.js环境里测试成功的效果,如下图所示:

这时候,如果我们将 expect 变量修改一下,期待值就不符合预期,触发测试失败,报错效果如下:

不过,上述测试代码使用throw Error,会中断JavaScript的执行流程。如果我们要测试所有方法,并且要收集结果,也要throw出错误,那可以这么实现一个最简单的单元测试管理方法,代码如下所示:
1 | const allUnitTestResults = []; |
测试效果运行如下图所示:
你可以看到,“单元测试”是我人工写JavaScript代码来管理的,那么,能否有对应的JavaScript框架可以来统一管理呢?
答案是肯定的,接下来我们就来看看前端单元测试都要准备什么工具。
前端单元测试需要用什么工具?
我们先来看下,前端单元测试有什么必备的要素。前面的“数学加减乘除”功能代码的测试实现,可以分成三个过程:
- 第一步:“输入”要执行的单元代码,等带执行完的“输出”结果;
- 第二步:执行后的“输出”进行“断言”,这里的“断言”就是指结果是否符合预期;
- 第三步:逐个收集所有单元测试结果,并做最后的统计处理。
知道了前端单元测试的核心过程后,你还需要注意前端代码的运行环境。因为在绝对大多数的前端项目中,JavaScript的运行环境主要有Node.js和浏览器这两种,这两种环境有比较大的API支持差异。例如,在Node.js环境中,没有浏览器环境里的操作DOM的API。
我们现在做的是Vue.js相关的单元测试,Vue.js是支持在浏览器操作DOM的框架,所以我们在选择单元测试工具时候,必须支持浏览器的API。
目前,市面支持测试“断言”或“测试管理”的主流前端JavaScript单元测试工具,有Mocha、Jest和Vitest:
- Mocha 是面向Node.js环境的JavaScript单元测试,不能直接支持浏览器的API,断言可以使用Node.js自带assert模块或者第三方断言工具,例如Chai;
- Jest 是同时支持Node.js和在Node.js里模拟浏览器API的测试工具,内部自带测试“断言”和“管理”工具,是React.js官方维护的测试工具。
- Vitest 跟Jest一样,都能支持Node.js和浏览器API,也自带测试“断言”和“管理”工具,是Vue.js官方维护的测试工具,对Vue.js的支持能力比较友好。
既然Vitest是Vue.js官方支持和维护,那么显而易见,我们选择Vitest是比较有优势的。所以这节课的单元测试,我们都围绕Vue.js官方测试工具Vitest来进行。
那么,如何用Vitest,给Vue.js 3.x组件库做单元测试呢?
如何用Vitest给Vue.js 3.x组件库做单元测试?
使用Vitest来做单元测试,我们首先要做的是环境准备。环境准备主要分成两个步骤,安装相关npm模块依赖和做Vitest的项目配置。
先来看第一步,安装相关npm模块依赖:
1 | 基于 npm 安装 |
每个npm模块的作用:
- vitest, 是Vitest测试工具核心模块,提供了单元测试管理和断言等工具;
- @vue/test-utils,是Vue.js测试工具,辅助处理Vue.js在Node.js环境里操作DOM渲染和DOM事件等操作;
- @vitejs/plugin-vue,是Vitest的插件,支持直接构建运行Vue.js模板代码;
- @vitejs/plugin-vue-jsx,是Vitest的插件,支持直接构建运行Vue.js的JSX代码;
- jsdom,用来在Node.js环境中模拟浏览器的原生API,例如操作DOM的原生API等。
**第二步,在安装依赖后,我们就需要做Vitest的项目配置。**先在项目根目录创建文件 ./vitest.config.js:
1 | import { defineConfig } from 'vitest/config'; |
然后在根目录的 ./package.json 添加测试脚本:
1 | { |
现在我们可以开始来写一个单元测试,例如新建文件 ./packages/components/__tests__/demo.test.ts,小试一下单元测试::
1 | import { describe, test, expect } from 'vitest'; |
执行测试命令 npm run test,vitest会自动识别当前项目中所有 *.test.ts 的测试文件进行执行测试,统计测试结果最后反馈出来,具体效果如下所示:

Vitest项目的单元测试基础配置就弄好了。接下来,我们就要进入今天的重点,也是难点,Vue.js组件的场景。
Vue.js组件的场景,比前面举例的“数学加减乘除”的功能更加复杂,多了DOM渲染、DOM事件操作、请求处理等浏览器里独有的特性。这些特性不是一个简单的“输入待测试代码”和“断言输出结果”的操作就能满足的,那么这些特性难点要怎么进行单元测试呢?
我们先对这些“测试难点”分类,分成不同测试场景类型,然后找到每个场景类型中的一个典型案例,举一反三就能覆盖绝大部分的“测试难点”了。这里,我划分成四种测试场景类型:
- 渲染测试场景;
- 行为测试场景;
- 请求测试场景;
- 兜底测试场景。
我们逐一看看。
1. 渲染测试场景
渲染测试场景,主要是验证Vue.js组件渲染后的DOM结构是否符合预期,也就是组件在渲染后的HTML结构是否符合预期,一般会直接用“快照比对”的方式进行断言。
渲染场景单元测试的“输入”是组件,输出是“快照”,具体测试操作就是断言“快照”是否符合预期,也就是说,我们需要有个符合预期的“正确快照”进行对比。一般这个“正确快照”是首次测试时候就生成的,修改代码后,再次执行单元测试会跟这个“正确快照”进行对比,而且这个“正确快照”首次生成后是不会自动更新的,如果有需要必须自己手动强行更新。
我拿前几节课的基础组件库的Box组件来做一次快照测试,具体单元测试就在文件 ./packages/components/__tests__/box/snapshot1.test.ts 里,具体测试代码如下:
1 | import { describe, test, expect } from 'vitest'; |
测试所用的输入案例代码在文件 ./packages/components/__tests__/box/index.test.vue 里:
1 | <template> |
执行单元测试后就会自动生成快照文件,会跟单元测试文件名称同名,自动生成在目录 ./packages/components/__tests__/box/__snapshots__ 里。

这时候,我们再修改Box里个别DOM的className,执行默认单元测试操作时就会报错,也就是说,生成的快照与首次的快照不一样就会报错,如下所示:

这个时候,如果你认为DOM内容变更是必须的,那意味着,期待结果的正确快照也要被更新,那你就可以执行这个Vitest命令 vitest --update,在这节课的项目中,我把它封装成了脚本命令 npm run test:update,执行一下就可以更新快照。
看到这儿,你可能会问:总是这么生成快照、对比快照,有需要就要更新快照,这个操作有点繁琐,还有其他的渲染测试方法吗?
答案是有的。这个测试的“输入”是组件,“输出”是快照,“断言”是快照,所以只要从“输入”、“输出”和“断言”这三个因素入手调整就行了。渲染测试,就是要看DOM或者HTML结构是否符合预期,那么我们可以选择一些重要的DOM或HTML标签,作为断言标准。
回到这个Box组件,我们可以选择className作为重要指标,那么这个单元测试的因素就变成了:“输入”是组件,“输出”是DOM结构,“断言”是判断className的DOM是否存在。我将它写在了这节课代码案例的 ./packages/components/__tests__/box/render.test.ts 文件里,具体代码如下所示:
1 | import { describe, test, expect } from 'vitest'; |
通过调整后,不需要频繁生成快照和对比快照了。
2. 行为测试场景
行为测试场景,主要是验证Vue.js组件渲染后,在用户行为操作DOM后触发的DOM结构的变化。例如,用户点击了组件的按钮,触发了组件内部其他渲染变化,这个时候“输入”是组件和行为操作,“输出”是变化后的DOM结构,“断言”是判断变化后的DOM结构快照或者DOM变化指标。
我们就怎么简单怎么来,按照DOM变化的指标来做断言测试。这里,我们测试一下Button按钮的点击行为操作是否正常,需要写一个按钮的计数器来作为单元测试验证,待输入的测试代码(在案例 ./packages/components/__tests__/button/index.test.vue 文件中)如下所示:
1 | <template> |
具体单元测试代码(在案例 ./packages/components/__tests__/button/index.test.ts 文件中)如下所示:
1 | import { describe, test, expect } from 'vitest'; |
代码里,我用前几节课里的Button组件和Input组件,写了一个计数器高阶组件,来实现点击计数效果,验证用户操作Button和Input组件是否正常。用@vue/test-utils来渲染组件和触发组件里的操作事件,就是常见的模拟用户行为的单元测试。
3. 请求测试场景
请求测试场景,比较特殊,主要适用于组件内部有涉及请求数据,例如图片请求的情况。那么在单元测试的流程里,“输入”就是组件和数据请求,“输出”就是数据请求结果,“断言”就是判断请求结果是否符合预期。
那么问题来了,Node.js环境里模拟浏览器操作,请求行为是无法模拟的,所以这时候就需要我们人工来模拟请求操作。
举个例子,我这里开发了一个基础组件Image,要写个图片组件的请求图片成功和失败的单元测试:
1 | import { describe, test, expect } from 'vitest'; |
上述测试过程中,你会发现,我是重写模拟了Image的虚拟节点创建操作,模拟触发了图片成功请求以及请求失败的操作。
实际开发中,Ajax请求也可以做类似的模拟,主要是模拟重写HttpRequestXML这个全局对象,一般有现成的npm模块,例如xhr-mock。
4. 兜底测试场景
兜底测试场景,就是要做前面三个场景都无法覆盖全面的活,利用Vitest的模拟浏览器环境,用Vue.js传统渲染方式作为“输入”,“输出”是一张在Node.js环境里模拟浏览器里的页面,“断言”就是判断在这个“模拟页面”上是否存在我们想要的结果指标。
例如我们要测试某个DOM是否存在,就可以这么实现代码:
1 | import { describe, test, expect } from 'vitest'; |
代码直接利用浏览器的API,在Node.js单元测试环境中直接调用,验证渲染后DOM是否存在。
这四个测试场景类型,已经能覆盖绝对大多数的组件场景了。基于单元测试,每次修改代码后,我们都能用自动单元测试,自动验证所有功能是否正常,不再需要人工形式来测试验证,极大地解放开发者的生产力。
但不知道你有没有发现一个问题,**作为一个保护代码功能质量的屏障,我们能用什么来衡量单元测试的质量呢?**换句话说,我们能用什么指标来衡量单元测试的测试效果呢?这个指标就是单元测试的“覆盖率”。
单元测试覆盖率
单元测试覆盖率,指的是在被测试的代码中,被执行的代码占所有代码的比例。我们可以通过这个指标,找出哪些代码还没被测试覆盖到,避免出现功能逻辑分支被遗漏的问题。
测试覆盖率一般有四个指标:
- 状态覆盖率;
- 代码行数覆盖率;
- 逻辑分支覆盖率;
- 方法覆盖率。
Vitest配置覆盖率的方式很简单,只要做以下三个配置步骤就可以了。
第一步,安装覆盖率的统计npm模块,@vitest/coverage-c8 。
第二步,修改配置vitest.config.js,修改结果如下所示:
1 | import { defineConfig } from 'vitest/config'; |
第三步,配置package.json单元测试执行脚本。在根目录的 ./package.json 添加测试覆盖率脚本:
1 | { |
接下来就是执行单元测试操作,执行命令 npm run coverage 后,会自动生成覆盖率统计报告。具体结果如下图所示:

测试覆盖率报告在 coverage/ 目录里,我们可以用浏览器直接打开 coverage/index.html 页。用浏览器访问后如下图所示:


通过测试报告截图和提示,我们就可以根据这个测试覆盖率情况,找到没被覆盖到的代码,补充对应代码逻辑的单元测试。
好,到这里,我们就已经从Vue.js组件单元测试验证功能,再到覆盖率验证测试质量,走了一遍一个企业级Vue.js组件库项目,所需的完整单元测试流程。
但是,单元测试,只是验证代码“输入”后的“输出”是否符合预期,它的上限就是验证核心功能逻辑,所以说,单元测试也存在一定的局限性。
前端的单元测试,只能通过“数据”形式来保证测试结果和测试断言,无法验证最终渲染的视觉效果。而且,目前大多数前端单元测试都是在Node.js环境里进行的,跟实际浏览器还是存在差异。如果要验证最终视觉效果,我们就要用到E2E测试,也就是“End to End”的端对端测试,你可以选择使用 Cypress 这个E2E测试工具。
此外,单元测试还有另一个局限性,在频繁变更功能的需求场景下,每次变更功能,我们都必须重写测试用例,这样时间成本会大大增加。所以,单元测试等这些测试操作,大多数用于比较稳定的代码,例如我们举例的组件库代码。当然了,这个局限性也不仅仅局限于单元测试,E2E测试等测试操作都有。
总之,测试不是万能的。我们目标是追求稳定健壮的代码功能,测试只是达到目的的一个比较高效的方式,并不是一个完美的方式。
总结
通过这节课对前端单元测试的分析,以及基于Vitest来实现Vue.js 3.x组件库的单元测试,相信你已经深刻理解了前端单元测试。
前端单元测试的核心流程,就是有“输入”和“输出”,最后“断言”来验证“输出”是否符合预期。Vue.js 3.x组件单元测试分析的四种场景类型,分别有:
- 渲染测试场景,验证Vue.js组件代码最终DOM是否符合预期;
- 行为测试场景,验证用户操作Vue.js组件后最终变化是否符合预期;
- 请求测试场景,用模拟操作方式,验证组件里数据请求逻辑是否符合预期;
- 兜底测试场景,用传统Vue.js渲染方式,间接验证组件功能是否符合预期。
单元测试覆盖率,就是用覆盖率作为验证单元测试效果的指标。理解了单元测试的作用,对提高开发者的工作效率很有帮助,但也要记得,单元测试不是万能的,存在局限性,你需要根据实际情况做出选择和判断。
思考
这节课都是模板语法写的单元测试,Vue.js 3.x的JSX语法单元测试要怎么写呢?
欢迎你留言参与讨论,如果有疑问也欢迎评论,下一讲见。