Vitest 是一个测试框架,类似老框架 Jest,用于运行测试。Vitest 最大的优点是可以和 Vite 整合起来,减少配置复杂度,如果你不用 Vite 的话 Vitest 不一定是最好的选择。
作为一个测试框架,那么最关键的元素也就还是 describe
、test
(it
)和 expect
:
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
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() 和 vi.spyOn() 共享相同的方法,但是只有 vi.fn() 的返回结果是可调用的。
根据官网的描述,vi.fn()
返回可执行的函数,而 vi.spyOn()
只对目标行为进行修改。在实践中暂时还是觉得 vi.spyOn()
比较实用,一般只会修改目标行为,让页面模拟调用,并不需要在测试文件上调用模拟函数。
使用提供的 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 时可以在这里统一处理。
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 是基于 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 基本告诉了你所有测试方法。
简单来说是 3 个动词:
以上 3 个动词加 All 查找全部,注意,如果不加 All 但是查询到多个元素会抛错。
8 个目标:
如果使用了 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 为其他操作,然后测试运行结果。当然这也是没办法中的办法而已。
const checkbox = getByRole('checkbox', { name: 'label' })
await fireEvent.click(checkbox)
const radio = await findByRole('radio', { name: 'Y' })
await fireEvent.click(radio)
至于为什么在找 role 的时候传入 name 可以找到,我还没研究出确切原因,但是下面这两个链接可以提供一定参考:
vm
直接操作,然后检查数据是否正确最后的最后还是要说一句,即使忽略细节测试,也很容易测漏,漏了再补,久而久之也是一大堆测试,而且某些需求改动也可能造成测试大规模修改,如果不是工期非常充足,请不要写测试。