Vitest
Vitest 是一个测试框架,类似老框架 Jest,用于运行测试。Vitest 最大的优点是可以和 Vite 整合起来,减少配置复杂度(反过来说,如果你不用 Vite 的话 Vitest 不一定是最好的选择)。
如果你在 Vite 的基础上安装 Vitest,只需要安装 ,然后在 vite.config.ts
中配置 test
字段即可,相关配置项可以参考官方文档。
作为一个测试框架,那么最关键的元素也就还是 describe
、test
(it
)和 expect
:
// 在配置中开启 globals 可以全局引入 vitest,不需要每个文件都写一遍 importimport { 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
等方法伪造函数。
这是一个官方例子:
export const getter = "variable";
// some-path.test.tsimport * 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
vi.fn() 和 vi.spyOn() 共享相同的方法,但是只有 vi.fn() 的返回结果是可调用的。
根据官网的描述,vi.fn()
返回可执行的函数,而 vi.spyOn()
只对目标行为进行修改。在实践中暂时还是觉得 vi.spyOn()
比较实用,一般只会修改目标行为,让页面模拟调用,并不需要在测试文件上调用模拟函数。
vi.mock
使用提供的 path 替换所有导入的模块为另一个模块。对 vi.mock 的调用是提升的,因此您在哪里调用它并不重要。它将始终在所有导入之前执行。
因为 vi.mock
会被提升,所以不需要在 beforeEach
里写 vi.mock,写了也只有一个生效
export function method() {}
// some-path.test.tsimport { 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
使用例子:
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
中传入 data
、props
、plugins
等配置,在得到的结果 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 encourages you to avoid testing implementation details like the internals of a component you’re testing (though it’s still possible). The Guiding Principles of this library emphasize a focus on tests that closely resemble how your web pages are interacted by the users.
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 必须模拟点击打开下拉再模拟点击选择。
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 的话类型校验会报错,结果还是新建文件比较好:
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 的测试方式是用可以看到文字选择元素,对元素操作,然后检查新元素是否存在
最后的最后还是要说一句,即使忽略细节测试,也很容易测漏,漏了再补,久而久之也是一大堆测试,而且某些需求改动也可能造成测试大规模修改,如果不是工期非常充足,请不要写测试。
杂项与排雷
- 遇到奇怪的报错很可能是有模块没 mock,导致运行错误,进而引起神奇的错误
- VTU 的挂载参数可以解决全局插件、组件等问题
ByRole
查询性能低,遇到测试太慢的情况可以用ByTestId
(叠加 waitFor 更慢)- 测试运行时间相关的数据含义
- 前端测试慢?其实不只是你,大家都这么慢
- 是否需要 reset?
其他测试工具
E2E 测试工具
- Cypress,测试编写方法有额外的学习成本,(怨念:而且他连 tab 都无法按)
- Playwright
- Cypress vs Playwright