组件库工程化后续:组件库测试

倘若你已经阅读过 如何为组件库设计工程化环境?,那么本篇将继续为完善组件库,为组件库添加自动化测试。

自动化测试能够预防无意引入的 bug,并鼓励开发者将应用分解为可测试、可维护的函数、模块、类和组件。这能够帮助你和你的团队更快速、自信地构建复杂的应用。

测试的类型

  • 单元测试:验证小的、独立的代码单元是否按预期工作,侧重于逻辑上的正确性,只关注应用整体功能的一小部分。
  • 组件测试:检查你的组件是否正常挂载和渲染、是否可以与之互动,以及表现是否符合预期。这些测试比单元测试导入了更多的代码,更复杂,需要更多时间来执行。
  • 端到端测试:检查跨越多个页面的功能,并对生产构建的应用进行实际的网络请求,需要建立测试环境。测试当用户实际使用你的应用时发生了什么。

测试方案

jest

基于 NodeJS 环境的测试框架,Jest 为大多数 TypeScript、JSX 项目提供开箱即用的支持、及大多数设置所需的完整测试功能(快照、模拟和覆盖率),在 vite 中使用 jest ,可以使用 vite-jest

vitest

基于 NodeJS 环境的测试框架,提供与 vite 一流的集成,包括单元测试时最常见的功能(模拟,快照以及覆盖率),Vitest 提供了与大多数 Jest API 和生态系统库兼容性。

cypress

Cypress 是基于浏览器环境的测试工具,更加专注于确定元素是否可见,是否可以访问和交互。由于使用的是真实的浏览器 APIs,相较于 vitest 这些模拟浏览器环境局限性更小,代价是运行速率更低。

选型

单元测试

选择 vitest。

vitest 与 vite 优秀的集成关系,让 vitest 和 vite 在运行、构建、测试时可以共享相同的转换流水线,无需处理源文件转换,只需专注于测试。

虽然本次组件库开发选用 rollup 打包,没有利用到上述优点,但:

  • vite 即时的热模块重载( HMR )。
  • 后续可能会使用 vite 代替 rollup。

最终选择 vitest 作为单元测试框架。

组件测试

组件测试通常涉及到单独挂载被测试的组件,触发模拟的用户输入事件,并对渲染的 DOM 输出进行断言。使用官方的测试库 @vue/test-utils测试组件。

端到端测试(E2E)

选择 cypress。

vitest 是一个基于 NodeJS 环境的测试框架,对于依赖浏览器的一些事件如滚动、一些 API 如 IntersectionObserver,模拟浏览器 API(happy-dom,jsdom)具有局限性。基于浏览器环境的测试框架能真实的访问交互,进行更好的端到端测试,考虑:

  • 浏览器覆盖率取舍:端到端测试可以运行在不同的浏览器,较高的覆盖率需要更多的时间和机器成本。
  • 更快的反馈:如热更新。
  • 调试体验:能否通过浏览器控制台调试。
  • 无头模式可见性:在 CI / CD 管道中,端到端测试依托于无头浏览器(如 Puppeteer),是否能够在不同的测试阶段查看应用的快照、视频,从而深入了解错误的原因。

最终选择 cypress 进行端到端测试。

结论

单元测试和组件测试使用 vitest,端到端测试使用 cypress。

环境

安装 vitest,vue 官方测试库 @vue/test-utils,模拟浏览器 API 环境 happy-dom

pnpm add -D vitest @vue/test-utils happy-dom

安装后配置 vitest.config.ts:

import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
plugins: [vue()],
// 测试
test: {
environment: ‘happy-dom’, // 环境配置
},
});

添加 pnpm 命令:


  "scripts": {
    // 新增
    "test": "vitest"
  },

运行 pnpm test命令,vitest 默认依据当前 CI 环境变量决定是否启动 watch 监听,没有配置时默认启用。

vitest 默认匹配文件名带有 test | spec 的 [jt]s(x) 文件作为测试文件:

include: ** /  *.{test,spec}.?(c|m)[jt]s?(x)  

如何测试

describe / test / expect

  • describe:定义一组测试套件,可以包含多个测试用例。
  • test(it):一个测试用例,定义了一组相关的期望。 它接收测试名称和保存测试期望的函数。
  • expect:创建断言。执行后返回多个辅助断言工具函数,如:toBe | toEqual 等。
  • vi:辅助工具集。

单元测试

单元测试主要验证小的、独立的代码单元是否按预期工作,适用于独立的业务逻辑、组件、类、模块或函数,不涉及 UI 渲染、网络请求或其他环境问题。测试输入、输出、边界等,对结果进行断言。

如组件库虚拟列表滚动需要限制滚动频率,一个节流函数如下:

type Timer = null | number;
export function throttle(func : T, time = 0, immediate = true) {
  let timer : Timer = null;
  return function (this : unknown, ... args : Parameters) {
    if (immediate) {
      func.call(this, ... args);
      immediate = false;
    }
    if (timer == null) {
      timer = setTimeout(() => {
        func.call(this, ... args);
        timer = null;
      }, time);
    }
  };
}

可以测试 time immediate 在不同值情况下 func 的执行次数,新建一个 xx.spec.ts 测试文件:

import { describe, expect, test, vi } from 'vitest';

import { throttle } from ‘…/optimize’;

describe(‘test throttle’, () => {
test(‘test immediate true’, async () => {
const cb = vi.fn();
const fn = throttle(cb, 0, true);
fn();
expect(cb).toBeCalledTimes(1);
});
});

以上测试中vi.fn是一个辅助函数,每次调用时它都会存储调用参数、返回值和实例。可以断言 cb 被调用次数达到测试的目的。

组件测试

一个组件,我们应该测试组件做什么,而不是组件怎么实现的。我们应该关心组件的公开接口包括 props、事件、slots,断言组件是否正确渲染输出和触发事件,对于一些依托真实浏览器的 API | 事件不在考虑范围内。

借助 @vue/test-utils,我们可以使用 mount 快速模拟组件挂载。

测试组件 props

import { describe, expect, test, vi } from 'vitest';
import { mount } from '@vue/test-utils';

import { FixedSizeList } from ‘…/’;

describe(‘test fixed-size-list component props’, () => {
const data = new Array(10000).fill(0);
test(‘test fixed-size-list itemClass’, () => {
const wrapper = mount(FixedSizeList, {
props: {
data,
itemClass: ‘custom-item’,
},
});
expect(wrapper.find(‘.virtual-list-item’).classes()).toContain(‘custom-item’);
});
});

wrapper提供提供一些辅助函数帮助我们测试,如:find | element | html | findComponent

如上例,测试自定义类名 props.itemSize 是否正确挂载, 渲染 FixedSizeList 组件后,通过 wrapper.find().classes() 找到目标,然后 expect().toContain(类名)断言是否包含目标。

测试组件 event

再举一个例子,测试组件 onload 事件是否正确触发:

import { describe, expect, test, vi } from 'vitest';
import { mount } from '@vue/test-utils';

import { FixedSizeList } from ‘…/’;
import { delay, triggerScrollTo } from ‘…/…/…/utils/test’;
describe(‘test fixed-size-list event’, () => {
test(‘test fixed-size-list onload’, async () => {
const data = new Array(5).fill(0);
const onLoad = vi.fn(); // onload 会记录每一次执行

const wrapper = mount(FixedSizeList, {
  props: {
    data,
    height: 600,
    itemSize: 150,
    onLoad,
  },
});
const elm = wrapper.find('.virtual-list-container').element as HTMLElement;
await triggerScrollTo(elm, 0, 150); // 触发滚动 scrollTo & dispatchEvent
await delay(); // 等待 scroll 回调函数执行,这里如果只是 wait nextTick() 不行,事件回调属于宏任务,vue 的 nextTick 函数优先使用 Promise.resolve 属于微任务
expect(onLoad).toBeCalledTimes(1);

});
});
// …/…/…/utils/test
export const delay = (time ?: number) => {
return new Promise(resolve => {
setTimeout(resolve, time);
});
};

断言 onload 事件是否如期触发。

端到端测试(E2E)

目前没有接入。

测试快照

快照测试可以测试函数的输出是否会意外更改。使用快照时,Vitest 将获取给定值的快照,将其比较时将参考存储在测试旁边的快照文件。如果两个快照不匹配,则测试将失败:要么更改是意外的,要么参考快照需要更新到测试结果的新版本。

快照测试类似 幂等性测试,独立代码单元如纯函数,或组件渲染结果,接口参数不变时,因当具有幂等性。

例如一个固定高度的虚拟列表,props 不变的情况下,滚动到相同位置,应该得到相同的渲染结构:

import { describe, expect, test, vi } from 'vitest';
import { mount } from '@vue/test-utils';

import { FixedSizeList } from ‘…/’;
import { delay, triggerScrollTo } from ‘…/…/…/utils/test’;
test(‘test fixed-size-list scroll cache’, async () => {
const data = new Array(20).fill(0);

const wrapper = mount(FixedSizeList, {
  props: {
    data,
    height: 600,
    itemSize: 150,
    cache: 4,
  },
});
const elm = wrapper.find('.virtual-list-container').element as HTMLElement;
expect(wrapper.findAll('.virtual-list-item')).toHaveLength(8); // 渲染 item 数量
expect(wrapper.html()).toMatchSnapshot(); // 渲染内容快照
await triggerScrollTo(elm, 0, 650); // 模拟滚动
await delay();
expect(wrapper.findAll('.virtual-list-item')).toHaveLength(12); // 上下缓存后 item 数量
expect(wrapper.html()).toMatchSnapshot(); // 滚动后内容快照
await triggerScrollTo(elm, 0, 850); // 继续滚动
await delay();
expect(wrapper.findAll('.virtual-list-item')).toHaveLength(12); // 没有滚动触底,item 数量
expect(wrapper.html()).toMatchSnapshot(); // 快照

});

默认将快照结果存储在同级目录下的 __snapshots__/**,应该将快照结果一起上传 git 以便验证。

测试覆盖率

Vitest 通过 v8 支持原生代码覆盖率,通过 istanbul 支持检测代码覆盖率。

这里我们使用 v8 作为测试覆盖率提供者,安装 @vitest/coverage-v8

pnpm i -D @vitest/coverage-v8

添加 npm 命令:

vitest test --voverage

配置

import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
plugins: [vue()],
// 测试
test: {
environment: ‘happy-dom’,
coverage: {
include: [‘src/'], // 覆盖率包含文件规则
exclude: [ // 覆盖率剔除文件规则
'
/example/',
'
/tests/',
'
/docs/',
'
/*.md’,
/.d.ts’,
‘build/',
'demo/
’,
‘dist/',
'docs/
’,
‘es/',
'lib/
’,
‘node_modules/**’,
],
},
},
});

测试覆盖率报告

运行 vitest test --voverage

报告有四个测量维度:

  1. 语句覆盖率(statement coverage):每个语句是否都执行情况。
  2. 分支覆盖率(branch coverage):每个流程控制的各个分支是否都执行情况。
  3. 函数覆盖率(function coverage):每个函数是否都调用情况。
  4. 行覆盖率(line coverage):每个可执行代码行是否都执行情况。

这里的行覆盖率是相对可执行代码行,一般情况下,如果我们遵守良好的代码规范,可执行代码行和语句的表现是一致的。

// 2 lines、2 statements
const a = 1;
console.log(a);

如果如下则表现不一致:

// 1 lines、2 statements
const a = 1;console.log(a);

js 中流程控制语句有:

  • if ... else
  • while | do ... while
  • switch case
  • try ... catch ... finally
  • ...

还包括 三目运算符,我们需要确保流程控制的每个边界情况(即分支)都被执行(覆盖)。

最后

感兴趣可以看下这篇同系列:组件库工程化后续:组件文档


这是一个从 https://juejin.cn/post/7369026041711099945 下的原始话题分离的讨论话题