skip to content
usubeni fantasy logo Usubeni Fantasy

Vue 系前端测试策略

/ 16 min read

Vitest

Vitest 是一个测试框架,类似老框架 Jest,用于运行测试。Vitest 最大的优点是可以和 Vite 整合起来,减少配置复杂度(反过来说,如果你不用 Vite 的话 Vitest 不一定是最好的选择)。

如果你在 Vite 的基础上安装 Vitest,只需要安装 ,然后在 vite.config.ts 中配置 test 字段即可,相关配置项可以参考官方文档

作为一个测试框架,那么最关键的元素也就还是 describetestit)和 expect

// 在配置中开启 globals 可以全局引入 vitest,不需要每个文件都写一遍 import
import { describe, expect, test } from "vitest";
const person = {
isActive: true,
age: 32,
};
describe("person", () => {
test("person is defined", () => {
expect(person).toBeDefined();
});
test("is active", () => {
expect(person.isActive).toBeTruthy();
});
test("age limit", () => {
expect(person.age).toBeLessThanOrEqual(32);
});
});

比较麻烦的是 mocking

mock 就是在测试中制造一些测试数据,对于一些不需要测试的函数,可以用 mock 的方式隐藏细节,直接提供需要的数据。在调用复杂的 useX 或 ajax 请求时,特别需要 mock。

下面是一些常见的 MockInstance Method

vi.spyOn

spyOn 可以用于监听一个函数,计算调用次数,也可以使用 mockReturnValue 等方法伪造函数。

这是一个官方例子:

some-path.js
export const getter = "variable";
// some-path.test.ts
import * as exports from "./some-path.js";
vi.spyOn(exports, "getter", "get").mockReturnValue("mocked");

可见 spyOn 可以监视整个模块,干预模块内某个输出值的行为,包括变量和函数。但是实际上却有一些问题,自己定义的模块是可以的,某些模块却不能正确 spyOn,例如 vue-router:

import { useRoute } from 'vue-router'
vi.spyOn(exports, 'useRoute').mockReturnValue({ path: '/path' } as any)

测试运行失败,返回 TypeError: Cannot redefine property: useRoute,原因未明(后面会提供解决方案)。

vi.fn

.fn() 和 vi.spyOn() 共享相同的方法,但是只有 vi.fn() 的返回结果是可调用的

根据官网的描述,vi.fn() 返回可执行的函数,而 vi.spyOn() 只对目标行为进行修改。在实践中暂时还是觉得 vi.spyOn() 比较实用,一般只会修改目标行为,让页面模拟调用,并不需要在测试文件上调用模拟函数。

vi.mock

提供的 path 替换所有导入的模块为另一个模块。对 vi.mock 的调用是提升的,因此您在哪里调用它并不重要。它将始终在所有导入之前执行

因为 vi.mock 会被提升,所以不需要在 beforeEach 里写 vi.mock,写了也只有一个生效

./some-path.js
export function method() {}
// some-path.test.ts
import { method } from "./some-path.js";
vi.mock("./some-path.js", () => ({
method: vi.fn(),
}));

因此上面路由的问题可以如此解决:

import { useRoute } from 'vue-router'
vi.mock('vue-router', async (importOriginal) => {
const vr = await importOriginal()
return { ...(vr as any), useRoute: vi.fn() }
})
// mock 方法时
vi.mocked(useRoute).mockReturnValue({ path: '/system-pipeline-template' } as any)

其实测试文件真的有一点反直觉,很难从代码一下反应出 useRoute 用的到底是什么。虽然你看到用 import { useRoute } from 'vue-router',但因为使用了 vi.mock,最终拿到的 useRoute 其实是 vi.fn()

P.S. vi.mocked 其实是一个 Typescript 辅助函数,可以帮助你通过类型检测,如果是使用 JavaScript 的话可以忽略。

在一些有深层依赖的组件来说,很多基础依赖都会影响测试运行(是的,跟正常运行一样,不只是那个文件的直接依赖,无论间接直接,都会被调用),一一在每个测试文件写 mock 函数是不现实的,针对这个问题,我们可以使用 __mocks__ 文件夹。

__mocks__ 文件夹

mock 方法的函数签名是 (path: string, factory?: () => unknown) => void。若不传入 factory,Vitest 会在 __mocks__ 文件夹找同名文件,将其当成要引入的对象,具体目录结构如下:

- __mocks__
- axios.js
- src
__mocks__
- increment.js
- increment.js
- tests
- increment.test.js

使用例子:

increment.test.js
import { vi } from "vitest";
// axios is a default export from `__mocks__/axios.js`
import axios from "axios";
// increment is a named export from `src/__mocks__/increment.js`
import { increment } from "../increment.js";
vi.mock("axios");
vi.mock("../increment.js");
axios.get(`/apples/${increment(1)}`);

P.S. 如果引入的路径带有 @ 等别名,那么 mock 的时候也需要使用别名,如 vi.mock('@/api/test.ts')

环境变量与全局变量

vi.stubGlobal("__VERSION__", "1.0.0");
vi.stubEnv("VITE_ENV", "staging");

相比函数的测试,用户界面组件测试才是前端的大问题,测试框架 Vitest 本身并不能解决这个问题,我们还需要使用 Vue Test Utils。

钩子

什么时候使用 beforeEach

使用 vi.clearAllMocks 等方法清除 mock,每个测试用例需要使用相同 mock 时可以在这里统一处理。

断言相关

UI 界面

Vitest UI 为你提供 UI 界面,可以快捷地重新运行特定测试,并且可以看到测试耗时和 log,对测试本身的优化十分实用。

Vue Test Utils

Vue Test Utils 简称 VTU,用于模拟挂载组件和触发事件。

模拟组件渲染

const wrapper = mount(MyComponent, {
props: {
msg: "world",
},
slots: {
default: "Default",
first: h("h1", {}, "Named Slot"),
second: Bar,
},
global: {
plugins: [myPlugin],
},
});

上面的代码就是在测试中用 mount 函数模拟挂载 TodoApp 组件,并且可以在第二个参数 options 中传入 datapropsplugins 等配置,在得到的结果 wrapper 中对元素模拟操作,实现测试的效果。

组件操作

import { mount } from "@vue/test-utils";
import TodoApp from "./TodoApp.vue";
test("creates a todo", () => {
const wrapper = mount(TodoApp, {
global: {
plugins: [myPlugin],
},
});
expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(1);
wrapper.get('[data-test="new-todo"]').setValue("New todo");
wrapper.get('[data-test="form"]').trigger("submit");
expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(2);
});

返回的 wrapper这些方法,可以使用 setData 等方法修改组件值,也可以通过 vm 属性访问 ComponentPublicInstance,这些方法和属性提供了测试组件内部运行细节的方法,不过这只是测试的一个流派,后面说到的 Vue Testing Library 就推荐我们尽量贴近实际操作。

操作之后判断结果是否正确的方法是检查某些元素是否存在、数量是否正确,类似:expect(wrapper.find('p').exists()).toBe(false)expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(2)

Vue Testing Library

Vue Testing Library 是基于 VTU 的一层封装。

隐藏细节的测试

Testing Library 不主张测试组件的实现细节,它会刻意隐藏 instance 等信息,让你难以测试实例上的方法,你需要通过页面交互后,检查页面元素变化是否正确,以此判断测试是否通过。所以主要函数 render(Component, options)options 也是基于 Vue Test Utils 本身的 options,基本上就是作为一个中介把参数传过去,然后返回查询对象。

使用 VTL 与否还得是看你写测试的习惯。不过 VTL 的存在当然证明忽略细节的测试有他的合理之处,这篇文章 Testing Implementation Details 向你解析了为何不要测试实现细节,大家可以了解一下。

因为隐藏了细节,所以 VTL 的测试方式就相对更简单了,举一个经典例子:

import { render, fireEvent, screen } from "@testing-library/vue";
import Component from "./Component.vue";
test("increments value on click", async () => {
render(Component);
// screen has all queries that you can use in your tests.
// getByText returns the first matching node for the provided text, and
// throws an error if no elements match or if more than one match is found.
screen.getByText("Times clicked: 0");
const button = screen.getByText("increment");
// Dispatch a native click event to our button element.
await fireEvent.click(button);
await fireEvent.click(button);
screen.getByText("Times clicked: 2");
});

render 本质就是对 VTU mount 的包装,而 queryAllByText 之类的方法其实就是很粗暴的遍历匹配:

https://github.dev/testing-library/dom-testing-library

const queryAllByText: AllByText = (
container,
text,
{
selector = '*',
exact = true,
collapseWhitespace,
trim,
ignore = getConfig().defaultIgnore,
normalizer,
} = {},
) => {
checkContainerType(container)
const matcher = exact ? matches : fuzzyMatches
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
let baseArray: HTMLElement[] = []
if (typeof container.matches === 'function' && container.matches(selector)) {
baseArray = [container]
}
return (
[
...baseArray,
...Array.from(container.querySelectorAll<HTMLElement>(selector)),
]
// TODO: `matches` according lib.dom.d.ts can get only `string` but according our code it can handle also boolean :)
.filter(node => !ignore || !node.matches(ignore as string))
.filter(node => matcher(getNodeText(node), node, text, matchNormalizer))
)
}

所以 VTL 会比直接用 VTU 慢很多。而且因为无法进行细节操作,一些函数例如 emit、子组件的方法会无法调用,函数测试率略微下降。

另外如果你使用了一些 UI 库,下拉列表选择之类的模拟会比较麻烦,如果直接用 VTU 的话可以用 setValue,但 VTL 必须模拟点击打开下拉再模拟点击选择。

VTL 仓库提供的测试例子

cheatsheet

这一页 cheatsheet 基本告诉了你所有测试方法。

元素查询

简单来说是 3 个动词:

  • get:查找失败抛错
  • find:自带 waitFor,查找失败抛错
  • query:查找失败返回 null

以上 3 个动词加 All 查找全部,注意,如果不加 All 但是查询到多个元素会抛错

8 个目标:

  • LabelText
  • PlaceholderText
  • Text
  • DisplayValue
  • AltText
  • Title
  • Role
  • TestId

如果使用了 UI 库,触发组件可能会有一定困难,可以尝试使用 container.querySelector 寻找元素进行精准操作。

事件分发

在 VTL 中可以通过下面的方法模拟用户操作:

import { render, fireEvent, screen } from "@testing-library/vue";
fireEvent.click(node);
fireEvent.input(node, event);
fireEvent(node, event);

本质上是借助浏览器事件相关 API:

event = document.createEvent(EventType);
const { bubbles, cancelable, detail, ...otherInit } = eventInit;
event.initEvent(eventName, bubbles, cancelable, detail);
element.dispatchEvent(event);

事件类型可参考 MDN | Event

实践

新建一个 Vite 项目,然后直接安装 Vitest 和 VTU:

npm install -D vitest @vue/test-utils @testing-library/vue jsdom

并添加两个命令:

{
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage"
}
}

tsconfig.json 加点全局类型:

{
"compilerOptions": {
// ...
"types": ["node", "jsdom", "vitest/globals", "@testing-library/jest-dom"]
// ...
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

在 vite 配置中加入 vitest 配置,确实,vitest 可以直接读 vite 配置,但是如果使用 ts 的话类型校验会报错,结果还是新建文件比较好:

vitest.config.ts
import { fileURLToPath } from "node:url";
import { mergeConfig } from "vite";
import { defineConfig } from "vitest/config";
import viteConfig from "./vite.config";
export default mergeConfig(
viteConfig,
defineConfig({
test: {
globals: true,
environment: "jsdom",
root: fileURLToPath(new URL("./", import.meta.url)),
mockReset: false,
// ...
},
}),
);

现在直接运行 npm run test 便能进行最简单的测试,试着运行吧:

include: **/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}
exclude: **/node_modules/**, **/dist/**, **/cypress/**, **/.{idea,git,cache,output,temp}/**, **/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*
watch exclude: **/node_modules/**, **/dist/**
No test files found, exiting with code 1

虽然没有效果,但是可以从中知道测试文件的范围。在我现在的项目中所有测试全放在 src 下的 __test__ 文件夹,测试文件夹的结构与 src 保持一致,例如:

src
__test__
components
ComponentOne.spec.ts
views
HomeView.spec.ts
components
ComponentOne.vue
views
HomeView.vue

奇葩需求怎么测试

例如拖拽组件,在测试时可以忽略拖拽操作,把拖拽 mock 为其他操作,然后测试运行结果。当然这也是没办法中的办法而已。

选择 checkbox/radio

const checkbox = getByRole("checkbox", { name: "label" });
await fireEvent.click(checkbox);
const radio = await findByRole("radio", { name: "Y" });
await fireEvent.click(radio);

至于为什么在找 role 的时候传入 name 可以找到,我还没研究出确切原因,但是下面这两个链接可以提供一定参考:

总结

  • Vitest 负责测试运行
  • 测试核心关键字 1:mock
  • 测试核心关键字 2:断言
  • Vue Test Utils 负责模拟组件挂载,提供组件测试的可能性
  • VTU 的测试方式是用选择器选择元素修改数据,甚至获取 vm 直接操作,然后检查数据是否正确
  • Vue Testing Library 是 VTU 的二次封装,主张接近用户操作的测试
  • VTL 的测试方式是用可以看到文字选择元素,对元素操作,然后检查新元素是否存在

最后的最后还是要说一句,即使忽略细节测试,也很容易测漏,漏了再补,久而久之也是一大堆测试,而且某些需求改动也可能造成测试大规模修改,如果不是工期非常充足,请不要写测试。

杂项与排雷

其他测试工具

E2E 测试工具

评论组件加载中……