14 代码单元测试:如何轻松地保证自己的代码质量?
你好,我是杨文坚。
回顾前面Vue.js 3.x自研组件库的几节课,我们分别学习了如何开发主题方案、基础组件、动态渲染组件、布局组件和表单组件,这些都是构成基础组件库的主要因素,也是我们后续开发业务组件库和打造一个运营搭建平台的前端“基石”。
要知道,在开发业务组件库和打造运营搭建平台的时候,组件库代码的“稳定性”和“健壮性”是非常重要的。如果基础组件库不稳定,经常出问题,那么基于它构成的业务组件或前端页面就会频繁出Bug。那么,组件库出问题会有哪些原因呢?
一般是组件的“逻辑分支多”和“测试不彻底”。举个例子,假设你开发了一个按钮组件(Button),按钮组件又被对话框组件、表单组件使用。这时候如果你给按钮组件添加一个监听键盘快捷键的功能,开发完成后,经过人工验证保证了按钮组件本身原有功能一切正常,但使用了按钮组件的对话框和表单组件,也能正常使用吗?是不是也得人工验证一遍?如果按钮组件被十多个其它基础组件引用,是不是也得逐个人工验证?
这里的组件设计和内部依赖使用出现了“逻辑分支多”的问题,涉及的逻辑功能都要人工验证,容易导致“测试不彻底”的隐患。随着组件库里的组件积少成多,这类隐患也越来越多,最终可能“量变引起质变”,导致“千里之堤,溃于蚁穴”的生产故障。
那么,有办法打破这一困境吗?答案当然是有的,我们可以使用“单元测试”,通过技术的手段来自动化“测试代码”。
什么是单元测试?
单元测试,英文是Unit Test,也可以称之为“模块测试”,主要是对代码最小单位逐一进行测试验证功能。这里的“代码最小单位”可以是一个函数、一个组件、一个类,甚至是一个变量。只要是能执行功能的代码模块,都可以称之为一个“最小单位”。
单元测试里的“单元”,是代码里可以执行的“单位”,测试就是验证这个最小单位的代码执行完后的结果是否符合预期。
举个例子,如果我们要开发一个数学加减乘除的功能代码,加法函数就是其中一个可执行的最小单位:
// 这是一个加减乘除的函数集合对象
const myMath = {
// 这里加法函数,可以当做是最小的测试单位
add(num1, num2) {
return num1 + num2;
},
subtract(num1, num2) {
return num1 - num2;
},
multiply(num1, num2) {
return num1 * num2;
},
divide(num1, num2) {
return num1 / num2;
}
};
这时候,要对加法函数这个“单元”进行单元测试,如果测试成功,就输出成功提示,如果测试失败,也就是测试结果不符合预期,就抛出错误(throw Error)。我们可以这么来实现测试代码:
const result = myMath.add(1, 2);
const expect = 3;
if (result === expect) {
console.log('myMath.add 加法测试成功!');
} else {
throw Error(
`myMath.add 加法测试失败,期待结果应该是:${expect},但实际结果为:${result}`
);
}
上述代码在Node.js环境里测试成功的效果,如下图所示:
这时候,如果我们将 expect 变量修改一下,期待值就不符合预期,触发测试失败,报错效果如下:
不过,上述测试代码使用throw Error,会中断JavaScript的执行流程。如果我们要测试所有方法,并且要收集结果,也要throw出错误,那可以这么实现一个最简单的单元测试管理方法,代码如下所示:
const allUnitTestResults = [];
function unitTest(name, callback) {
let success = false;
let error = null;
try {
callback();
success = true;
} catch (err) {
error = err;
}
allUnitTestResults.push({
name,
success,
error
});
}
unitTest('加法函数 add', () => {
const result = myMath.add(1, 2);
const expect = 3;
if (result === expect) {
console.log('myMath.add 加法测试成功!');
} else {
throw Error(
`myMath.add 加法测试失败,期待结果应该是:${expect},但实际结果为:${result}`
);
}
});
unitTest('减法函数 subtract', () => {
const result = myMath.subtract(3, 2);
const expect = 1;
if (result === expect) {
console.log('myMath.add 减法测试成功!');
} else {
throw Error(
`myMath.add 减法测试失败,期待结果应该是:${expect},但实际结果为:${result}`
);
}
});
let successCount = 0;
let failCount = 0;
allUnitTestResults.forEach((item) => {
if (item.success === true) {
successCount++;
} else {
failCount++;
console.log(item.error);
}
});
console.log(`总共 ${allUnitTestResults.length}个测试用例`);
console.log(`测试成功个数: ${successCount}`);
console.log(`测试失败个数: ${failCount}`);
测试效果运行如下图所示:
你可以看到,“单元测试”是我人工写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模块依赖:
# 基于 npm 安装
npm i -D vitest @vue/test-utils @vitejs/plugin-vue @vitejs/plugin-vue-jsx jsdom
# 或者基于 pnpm 来安装
pnpm i -D vitest @vue/test-utils @vitejs/plugin-vue @vitejs/plugin-vue-jsx jsdom
每个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:
import { defineConfig } from 'vitest/config';
import PluginVue from '@vitejs/plugin-vue';
import PluginJsx from '@vitejs/plugin-vue-jsx';
export default defineConfig({
// 配置插件,用来在测试过程中编译Vue.js的模板语法和JSX语法
plugins: [PluginVue(), PluginJsx()],
// 配置测试环境,支持全局变量和浏览器DOM API
test: {
globals: true,
environment: 'jsdom'
}
});
然后在根目录的 ./package.json
添加测试脚本:
现在我们可以开始来写一个单元测试,例如新建文件 ./packages/components/__tests__/demo.test.ts
,小试一下单元测试::
import { describe, test, expect } from 'vitest';
describe('Demo', () => {
test('Test case', () => {
const a = 1;
const b = 2;
expect(a + b).toBe(3);
});
});
执行测试命令 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
里,具体测试代码如下:
import { describe, test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import BoxTest from './index.test.vue';
describe('Box', () => {
test('snapshot', () => {
const wrapper = mount(BoxTest);
expect(wrapper).toMatchSnapshot();
});
});
测试所用的输入案例代码在文件 ./packages/components/__tests__/box/index.test.vue
里:
<template>
<Box>
<div class="hello">Hello World</div>
</Box>
</template>
<script setup lang="ts">
import { Box } from '../../src';
</script>
执行单元测试后就会自动生成快照文件,会跟单元测试文件名称同名,自动生成在目录 ./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
文件里,具体代码如下所示:
import { describe, test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import BoxTest from './index.test.vue';
describe('Box', () => {
test('className', () => {
const wrapper = mount(BoxTest);
const boxDOM = wrapper.find('.my-vue-box');
// 判断className为my-vue-box的DOM是否存在
expect(boxDOM).toBeTruthy();
const slotDOM = boxDOM.find('.hello');
// 判断className为my-vue-box的DOM内部子节点DOM是否存在
expect(slotDOM).toBeTruthy();
expect(slotDOM.text()).toBe('Hello World');
});
});
通过调整后,不需要频繁生成快照和对比快照了。
2. 行为测试场景
行为测试场景,主要是验证Vue.js组件渲染后,在用户行为操作DOM后触发的DOM结构的变化。例如,用户点击了组件的按钮,触发了组件内部其他渲染变化,这个时候“输入”是组件和行为操作,“输出”是变化后的DOM结构,“断言”是判断变化后的DOM结构快照或者DOM变化指标。
我们就怎么简单怎么来,按照DOM变化的指标来做断言测试。这里,我们测试一下Button按钮的点击行为操作是否正常,需要写一个按钮的计数器来作为单元测试验证,待输入的测试代码(在案例 ./packages/components/__tests__/button/index.test.vue
文件中)如下所示:
<template>
<div class="display-text">当前数值={{ num }}</div>
<Button class="btn-add" @click="onClick">点击加1</Button>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Button } from '../../src';
const props = defineProps<{ num: number }>();
const num = ref<number>(props.num);
const onClick = () => {
num.value++;
};
</script>
具体单元测试代码(在案例 ./packages/components/__tests__/button/index.test.ts
文件中)如下所示:
import { describe, test, expect } from 'vitest';
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import ButtonTest from './index.test.vue';
describe('Button', () => {
test('click event', async () => {
// 模拟浏览器渲染组件
const wrapper = mount(ButtonTest, { props: { num: 123 } });
// 找到数字文案DOM节点
const textDOM = wrapper.find('.display-text');
// 找到按钮DOM节点
const btnDOM = wrapper.find('.btn-add');
// 断言验证点击前的文案
expect(textDOM.text()).toBe('当前数值=123');
// 触发按钮点击
btnDOM.trigger('click');
// 等待DOM变化结束
await nextTick();
// 断言验证结果(点击后的文案)
expect(textDOM.text()).toBe('当前数值=124');
});
});
代码里,我用前几节课里的Button组件和Input组件,写了一个计数器高阶组件,来实现点击计数效果,验证用户操作Button和Input组件是否正常。用@vue/test-utils来渲染组件和触发组件里的操作事件,就是常见的模拟用户行为的单元测试。
3. 请求测试场景
请求测试场景,比较特殊,主要适用于组件内部有涉及请求数据,例如图片请求的情况。那么在单元测试的流程里,“输入”就是组件和数据请求,“输出”就是数据请求结果,“断言”就是判断请求结果是否符合预期。
那么问题来了,Node.js环境里模拟浏览器操作,请求行为是无法模拟的,所以这时候就需要我们人工来模拟请求操作。
举个例子,我这里开发了一个基础组件Image,要写个图片组件的请求图片成功和失败的单元测试:
import { describe, test, expect } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { Image } from '../../src';
// 模拟重写 img标签,自动触发图片请求操作
window.document.createElement = (function (create) {
return function () {
// @ts-ignore
const element: HTMLElement = create.apply(this, arguments);
if (element.tagName === 'IMG') {
// 图片元素渲染后,模拟自动触发图片请求的延时请求操作
setTimeout(() => {
const src = element.getAttribute('src');
if (src?.includes('error.jpg')) {
// 如果图片链接有带error.jpg,就触发请求错误
element.dispatchEvent(new CustomEvent('error', { bubbles: true }));
} else {
// 其它默认触发请求成功
element.dispatchEvent(new CustomEvent('load', { bubbles: true }));
}
}, 100);
}
return element;
};
})(window.document.createElement);
describe('Image', () => {
test('load', async () => {
let resolve: Function;
let reject: Function;
new Promise((res, rej) => ((resolve = res), (reject = rej)));
// 渲染验证图片请求成功
mount(Image, {
props: {
src: './xxx/xxxx.jpg'
},
emits: {
load: (e: Event) => {
expect(e).toBeTruthy();
resolve?.();
},
error: () => {
reject?.();
}
}
});
await flushPromises();
});
test('error', async () => {
let resolve: Function;
let reject: Function;
new Promise((res, rej) => ((resolve = res), (reject = rej)));
// 渲染验证图片请求失败
mount(Image, {
props: {
src: './xxx/error.jpg'
},
emits: {
load: () => {
reject?.();
},
error: (e: Event) => {
expect(e).toBeTruthy();
resolve?.();
}
}
});
await flushPromises();
});
});
上述测试过程中,你会发现,我是重写模拟了Image的虚拟节点创建操作,模拟触发了图片成功请求以及请求失败的操作。
实际开发中,Ajax请求也可以做类似的模拟,主要是模拟重写HttpRequestXML这个全局对象,一般有现成的npm模块,例如xhr-mock。
4. 兜底测试场景
兜底测试场景,就是要做前面三个场景都无法覆盖全面的活,利用Vitest的模拟浏览器环境,用Vue.js传统渲染方式作为“输入”,“输出”是一张在Node.js环境里模拟浏览器里的页面,“断言”就是判断在这个“模拟页面”上是否存在我们想要的结果指标。
例如我们要测试某个DOM是否存在,就可以这么实现代码:
import { describe, test, expect } from 'vitest';
import { createApp, nextTick } from 'vue';
import AppTest from './app.test.vue';
describe('Button', () => {
test('click event', async () => {
// 用jsdom模拟的浏览器API渲染一个根节点DOM div
const div = document.createElement('div');
// 将div追加到页面body上
document.body.appendChild(div);
// 用常规浏览器里Vue.js创建应用的方式创建应用
const app = createApp(AppTest);
// 用常规浏览器里Vue.js挂载应用的方式挂载组件
app.mount(div);
// 用jsdom模拟的浏览器API 查找需要断言的DOM
const textDOM = div.querySelector('.display-text');
// 用jsdom模拟的浏览器API 查找需要断言的DOM
const btnDOM = div.querySelector('.btn-add');
expect(textDOM?.textContent).toBe('当前数值=123');
btnDOM?.dispatchEvent(new CustomEvent('click', { bubbles: true }));
await nextTick();
expect(textDOM?.textContent).toBe('当前数值=124');
});
});
代码直接利用浏览器的API,在Node.js单元测试环境中直接调用,验证渲染后DOM是否存在。
这四个测试场景类型,已经能覆盖绝对大多数的组件场景了。基于单元测试,每次修改代码后,我们都能用自动单元测试,自动验证所有功能是否正常,不再需要人工形式来测试验证,极大地解放开发者的生产力。
但不知道你有没有发现一个问题,作为一个保护代码功能质量的屏障,我们能用什么来衡量单元测试的质量呢?换句话说,我们能用什么指标来衡量单元测试的测试效果呢?这个指标就是单元测试的“覆盖率”。
单元测试覆盖率
单元测试覆盖率,指的是在被测试的代码中,被执行的代码占所有代码的比例。我们可以通过这个指标,找出哪些代码还没被测试覆盖到,避免出现功能逻辑分支被遗漏的问题。
测试覆盖率一般有四个指标:
- 状态覆盖率;
- 代码行数覆盖率;
- 逻辑分支覆盖率;
- 方法覆盖率。
Vitest配置覆盖率的方式很简单,只要做以下三个配置步骤就可以了。
第一步,安装覆盖率的统计npm模块,@vitest/coverage-c8 。
第二步,修改配置vitest.config.js,修改结果如下所示:
import { defineConfig } from 'vitest/config';
import PluginVue from '@vitejs/plugin-vue';
import PluginJsx from '@vitejs/plugin-vue-jsx';
export default defineConfig({
plugins: [PluginVue(), PluginJsx()],
test: {
globals: true,
environment: 'jsdom',
coverage: {
// 覆盖率统计工具
provider: 'c8',
// 覆盖率的分母,packages/ 目录里
// 所有src的源文件作为覆盖率统计的分母
include: ['packages/*/src/**/*'],
// 全量覆盖率计算
all: true
}
}
});
第三步,配置package.json单元测试执行脚本。在根目录的 ./package.json 添加测试覆盖率脚本:
接下来就是执行单元测试操作,执行命令 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语法单元测试要怎么写呢?
欢迎你留言参与讨论,如果有疑问也欢迎评论,下一讲见。
完整的代码在这里
- 丫头 👍(1) 💬(1)
感谢老师帮我扫盲
2022-12-28 - ifelse 👍(0) 💬(0)
学习打卡
2024-09-11 - escray 👍(0) 💬(0)
Error: "coverage.provider: c8" is not supported anymore. Use "coverage.provider: v8" instead 覆盖率的 npm 引擎已经从 c8 升级到 v8 了,是我来的太晚了么 npm i -D @vitest/coverage-c8 会报错 npm i -D @vitest/coverage-v8 就通过了 vitest.config.js 中的配置如下: coverage: { // 覆盖率统计工具 provider: 'v8',
2024-01-09 - 高并发 👍(0) 💬(0)
第三段代码的测试的log文本有点问题, `myMath.add 减法测试成功`,==> `myMath.subtract 减法测试成功`
2023-08-04