1 什么是vitest?
Vitest 是由 Vite 驱动的下一代测试框架。 相比传统的Jest框架,Vitest有以下四大优点:
- 配置简单:Vitest复用Vite的配置和插件;
- 和Jest兼容:expect, snapshot, coverage这些熟悉的特性和Jest一样,方便从Jest迁移;
- HMR: 只编译运行修改的部分,速度更快;
- 开箱即用的ESModule、Typescript、JSX。
注意:
Vitest 1.0 requires Vite >=v5.0.0 and Node >=v18.0.0
2 安装
npm create vue vitest-demo
安装过程中根据项目需要选择对应的功能或插件。
安装完成后,查看项目根目录有2个配置文件,vitest.config.ts的配置优先级要高于vite.config.ts。
vitest.config.ts
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url))
}
})
)
先找到运行测试用例的命令,然后跑一下试试。
package.json
npm run test:unit
可以看到显示1个测试用例成功了,耗时1.11s。
3 第一个Test
测试Math.sqrt()函数
squareroot.test.ts
import { describe, it, expect } from 'vitest'
describe('square root', () => {
it('finds squareroot', () => {
expect(Math.sqrt(49)).toBe(7)
})
})
test/it: 测试验证或断言代码块,也就是单个测试用例。第一个参数是一个字符串,读起来就像带有“it”或“test”的句子,第二个参数是包含断言的箭头函数。
expect: 这是实际测试发生的地方。Expect 接受有效的 javascript 操作作为参数,并返回一个表示不同形式断言的对象。可以在此处找到完整的断言列表。
describe: 描述了测试套件,它是完全可选的,还可以使用它将相关测试分组。
保存后,可以看到测试用例运行了。
4 Component Testing
组件测试是基础测试,这里示例一个显示通知的简单组件的测试。
4.1 创建通知组件
src/components/NotificationToast.vue
<template>
<div role="alert" :class="[
'notification',
status === 'error' ? 'notification--error' : null,
status === 'success' ? 'notification--success' : null,
status === 'info' ? 'notification--info' : null,
message && message.length > 0 ? 'notification--slide' : null,
]">
<p class="notification__text">
{{ message }}
</p>
<button title="close" ref="closeButton" class="notification__button" @click="$emit('clear-notification')">
✕
</button>
</div>
</template>
<script setup>
defineProps({
status: {
type: String,
default: null
},
message: {
type: String,
default: null
}
})
</script>
<style>
.notification {
transition: all 900ms ease-out;
opacity: 0;
z-index: 300001;
transform: translateY(-100vh);
box-sizing: border-box;
padding: 10px 15px;
width: 100%;
max-width: 730px;
display: flex;
position: fixed;
top: 20px;
right: 15px;
justify-content: flex-start;
align-items: center;
border-radius: 8px;
min-height: 48px;
box-sizing: border-box;
color: #fff;
}
.notification--slide {
transform: translateY(0px);
opacity: 1;
}
.notification__text {
margin: 0;
margin-left: 17px;
margin-right: auto;
}
.notification--error {
background-color: #fdecec;
}
.notification--error .notification__text {
color: #f03d3e;
}
.notification--success {
background-color: #e1f9f2;
}
.notification--success .notification__text {
color: #146354;
}
.notification--info {
background-color: #9AC7F5;
}
.notification__button {
border: 0;
background-color: transparent;
cursor: pointer;
}
</style>
src/App.vue
<script setup>
import { ref } from "vue";
import NotificationToast from "./components/NotificationToast.vue";
const message = ref("Image uploaded successfully")
const clearNotification = () => {
message.value = ""
}
</script>
<template>
<NotificationToast status="success" :message=message @clear-notification="clearNotification"/>
</template>
4.2 安装测试依赖
Vue Test Utils 提供了一组实用函数和方法来以隔离的方式安装 Vue 组件并与之交互。
这个虚拟的组件称为stub,要在测试中使用stub,需要访问 Vue Test Utils(Vue.js 的官方测试实用程序库)中的挂载(mount)方法。通过vue的脚手架创建的项目会自动安装@vue/test-utils,手动安装需要执行以下命令:
npm install --save-dev @vue/test-utils@next
还需要能够在测试时模拟浏览器环境,能够访问一些浏览器 API,并允许在不受实际环境(即实际浏览器)干扰的情况下测试组件。 Vitest 目前支持两个包可以实现这一目标:happy-dom 和 jsdom。打开 package.json,可以看到在安装过程中自动安装了 jsdom。
4.3 测试什么?
组件如果有属性,需要覆盖每一个属性的每一种情况,特别是边界情况,如空。如果有事件,需要覆盖到事件。
- 该组件根据通知状态呈现正确的样式。
- 当消息为空时,通知会向上滑动。
- 单击关闭按钮时,该组件会发出一个事件
src/components/tests/Notification.test.ts
describe("Notification Component", () => { });
4.3.1 第一个功能测试
error, success, info的状态时组件包含对应的样式class
import { mount } from '@vue/test-utils'
import NotificationToast from '../NotificationToast.vue'
import { describe, expect, test } from 'vitest'
describe('Notification component', () => {
test('renders the correct style for error', () => {
const status = 'error'
const wrapper = mount(NotificationToast, {
props: { status }
})
expect(wrapper.classes()).toEqual(
expect.arrayContaining(['notification--error']))
})
test('renders correct style for success', () => {
const status = 'success'
const wrapper = mount(NotificationToast, {
props: { status }
})
expect(wrapper.classes()).toEqual(
expect.arrayContaining(['notification--success']))
})
test('renders correct style for info', () => {
const status = 'info'
const wrapper = mount(NotificationToast, {
props: { status }
})
expect(wrapper.classes()).toEqual(
expect.arrayContaining(['notification--info']))
})
})
还需要测试组件是否向查看器呈现正确的消息。
test('renders correct message to viewer', () => {
const message = 'Something happened, try again'
const wrapper = mount(NotificationToast, {
props: { message }
})
expect(wrapper.find('p').text()).toBe(message)
})
4.3.2 第二个功能测试
当消息为空时,样式的class不包含‘notification--slide’
test('slides up when message is empty', () => {
const message = ''
const wrapper = mount(NotificationToast, {
props: { message }
})
expect(wrapper.classes('notification--slide')).toBe(false)
})
4.3.3 第三个功能测试
点击按钮,发出事件'clear-notification'
test('emits event when close button is clicked', async () => {
const wrapper = mount(NotificationToast, {
data() {
return {
clicked: false
}
}
})
const closeButton = wrapper.find('button')
await closeButton.trigger('click')
expect(wrapper.emitted()).toHaveProperty('clear-notification')
})
这里使用了trigger函数触发点击事件,该函数接受一个事件(点击、聚焦、模糊、按下按键等),执行该事件并返回一个promise。因此,等待此操作,以确保在根据此事件做出断言之前已对 DOM 进行更改。之后,使用emitted函数检查组件已发射的事件列表,该方法返回事件数组。
完整的测试
describe('Notification component', () => {
test('renders the correct style for error', () => {
const status = 'error'
const wrapper = mount(NotificationToast, {
props: { status }
})
expect(wrapper.classes()).toEqual(
expect.arrayContaining(['notification--error']))
})
test('renders correct style for success', () => {
const status = 'success'
const wrapper = mount(NotificationToast, {
props: { status }
})
expect(wrapper.classes()).toEqual(expect.arrayContaining(['notification--success']))
})
test('renders correct style for info', () => {
const status = 'info'
const wrapper = mount(NotificationToast, {
props: { status }
})
expect(wrapper.classes()).toEqual(expect.arrayContaining(['notification--info']))
})
test('slides up when message is empty', () => {
const message = ''
const wrapper = mount(NotificationToast, {
props: { message }
})
expect(wrapper.classes('notification--slide')).toBe(false)
})
test('emits event when close button is clicked', async () => {
const wrapper = mount(NotificationToast, {
data() {
return {
clicked: false
}
}
})
const closeButton = wrapper.find('button')
await closeButton.trigger('click')
expect(wrapper.emitted()).toHaveProperty('clear-notification')
})
test('renders correct message to viewer', () => {
const message = 'Something happened, try again'
const wrapper = mount(NotificationToast, {
props: { message }
})
expect(wrapper.find('p').text()).toBe(message)
})
})
5 API Mock Testing
模拟REST API进行接口测试
5.1 创建卡片组件
使用axios请求接口,JSONPlaceholder模拟接口返回
src/components/PostCard.vue
<template>
<div>
<div v-if="post">
<h1 data-testid="post-title">{{ post.title }}</h1>
<p data-testid="post-body">{{ post.body }}</p>
</div>
<p v-if="loading" data-testid="loader">Loading...</p>
<p v-if="error" data-testid="error-message">{{ error }}</p>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import axios from "axios";
const post = ref(null);
const loading = ref(true);
const error = ref(null);
const fetchPost = async () => {
try {
const { data } = await axios.get(
"https://jsonplaceholder.typicode.com/posts/1"
);
post.value = data;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchPost();
});
</script>
5.2 测试什么?
- 确保程序成功发出 API 请求,并在请求成功时显示正确的数据;
- 通过在 API 请求失败时显示错误消息来测试应用程序是否正确处理错误。
在mount PostCard 之前,需要能够拦截使用 Axios 发出的 GET 请求并返回模拟值。这样做是因为不想向真正的 API 服务器发出请求。请记住,模拟允许单独进行测试。 为此,可以使用 Vitest 中的spy功能。 Vitest 提供了实用程序函数,可通过其 vi 帮助完成此操作。可以从“vitest”导入 { vi } 或在启用 Vitest 全局配置时全局访问它。 这里全局配置它,这样就可以使用 Vitest 中的函数(例如describe、expect和test),而不必每次都导入它们。 Vitest 配置文件中将全局设置为 true:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
},
})
5.2.1 第一个功能测试
先展示loading, 请求mock数据后正确展示title, body内容
使用spyOn函数,它有两个参数:第一个是一个对象,第二个是该对象的键。 spyOn 监视该对象以及 getter 或 setter。因此,当它被调用时,它会执行它所要求的操作。例子中,它使用mockResolvedValueOnce函数返回之前创建的模拟对象mockPost来模拟响应。
flushPromises,这是 Vue test utils 中的一个实用方法,可以刷新所有待处理的已解决的 Promise 处理程序。可以等待flushPromises 的调用来刷新待处理的promise 并提高测试的可读性。
src/components/tests/PostCard.test.ts
import axios from 'axios'
import PostCard from '../PostCard.vue'
import { mount, flushPromises } from '@vue/test-utils'
const mockPost = {
userId: 1,
id: 1,
title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
body: 'quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto'
}
describe('Post Card Component', () => {
test('can fetch and display a post', async () => {
vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: mockPost })
const wrapper = mount(PostCard)
expect(wrapper.html()).toContain('Loading...')
await flushPromises()
// new
expect(wrapper.find('[data-testid="post-title"]').text()).toBe(mockPost.title)
expect(wrapper.find('[data-testid="post-body"]').text()).toBe(mockPost.body)
})
})
5.2.2 第二个功能测试
和mockResolvedValueOnce类似,mockRejectedValueOnce 接收错误对象作为其参数。
test('can display an error message if fetching a post fails', async () => {
vi.spyOn(axios, 'get').mockRejectedValueOnce(new Error('Error occurred'))
const wrapper = mount(PostCard)
expect(wrapper.html()).toContain('Loading...')
await flushPromises()
expect(wrapper.find('[data-testid="error-message"]').text()).toBe('Error occurred')
})
6 Page Testing(E2E Testing)
编写 E2E 测试可以检测组件相互交互时发生的错误和问题,而这些错误和问题在单元或集成测试中可能未被注意到。
被测试应用的交互:用户在输入框中输入title、body,然后点击按钮提交
输入页面:
提交成功页面
非法提交操作
6.1 创建应用
src/App.vue
<template>
<div class="post-container">
<PostCard v-if="createdPost" :title="createdPost.title" :body="createdPost.body" />
<form v-else @submit="handleSubmit" data-testid="post-form" class="create-post-form">
<div class="post-title">
<label for="title">Title</label>
<input data-testid="title-input" type="text" name="title" v-model="formData.title" />
</div>
<div class="post-body">
<label for="body">Content</label>
<textarea data-testid="body-input" name="body" v-model="formData.body"
rows="5" />
</div>
<button type="submit" :disabled="loading" class="create-post-btn">
{{ loading ? "Creating..." : "Create Post" }}
</button>
</form>
<NotificationToast v-if="error" status="error" :message="error" @clear-notification="clearErrorNotification" />
</div>
</template>
<script setup>
import { ref } from "vue";
import axios from "axios";
import PostCard from "./components/PostCard.vue";
import NotificationToast from "./components/NotificationToast.vue";
function getUserId(max = 10) {
return Math.floor(Math.random() * max);
}
const formData = ref({
title: "",
body: "",
});
const createdPost = ref(null);
const loading = ref(false);
const error = ref(null);
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.value.title) {
error.value = "Please input post title";
return;
}
if (!formData.value.body) {
error.value = "Please input post body";
return;
}
loading.value = true;
createdPost.value = null;
try {
const { data } = await axios.post(
"https://jsonplaceholder.typicode.com/posts",
{
userId: getUserId(),
title: formData.value.title,
body: formData.value.body,
}
);
createdPost.value = data;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
const clearErrorNotification = () => {
error.value = null;
};
</script>
<style scoped>
.post-container {
max-width: 560px;
margin: 5rem auto;
}
.create-post-form label {
color: #fff;
}
.create-post-form input,
.create-post-form textarea {
margin-top: 8px;
padding: 12px;
}
.post-title {
display: flex;
flex-direction: column;
}
.post-body {
display: flex;
flex-direction: column;
margin-top: 14px;
}
.create-post-btn {
display: block;
margin: 16px auto;
background-color: #fff;
padding: 8px;
cursor: pointer;
}
</style>
和前面不同,这里,PostCard是一个无状态组件,它有2个属性title、body。因此,需要重新编写一个简单的快照测试来断言组件是否正确渲染。
src/components/PostCard.vue
<template>
<div>
<h1>{{ title }}</h1>
<p>{{ body }}</p>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true,
},
body: {
type: String,
required: true,
},
});
</script>
<style>
h1 {
font-size: 32px;
font-weight: 700;
color: #fff;
}
</style>
src/components/tests/PostCard.test.js
import PostCard from '../PostCard.vue'
import { mount } from '@vue/test-utils'
describe('Post Card Component', () => {
test('created posts render correctly', () => {
const title = 'Test Post';
const body = 'test post body...';
const wrapper = mount(PostCard, {
props: {
title,
body
}
});
expect(wrapper.html()).toMatchSnapshot();
})
})
运行test后,__test__目录下会生成__snapshots__目录包含PostCard.test.snap,每次运行该用例,都会对比新生成的snapshot和之前的是否一致,如果不一致,则失败。
6.2 测试什么?
- 测试用户是否可以成功创建post;
- 应用程序是否根据用户输入和 API 响应显示正确的通知;
6.2.1 第一个功能测试
需要注意输入框输入内容使用test utils的setValue()函数,该函数也是返回一个promise, 另外,flushPromises()也需要使用await
src/App.test.vue
import { mount, flushPromises } from '@vue/test-utils';
import axios from 'axios';
import App from './App.vue';
const mockPost = {
userId: 1,
id: 1,
title: 'Test Post',
body: 'test body'
};
describe('Posts App', () => {
test('user can create a new post', async () => {
vi.spyOn(axios, 'post').mockResolvedValueOnce({ data: mockPost });
const wrapper = mount(App);
// fill in the input fields
await wrapper.find('[data-testid="title-input"]').setValue(mockPost.title);
await wrapper.find('[data-testid="body-input"]').setValue(mockPost.body);
// submit the form
await wrapper.find('[data-testid="post-form"]').trigger('submit');
expect(wrapper.find('[type="submit"]').html()).toContain('Creating...');
await flushPromises();
// assert that the created post is displayed on screen
expect(wrapper.html()).toContain(mockPost.title);
expect(wrapper.html()).toContain(mockPost.body);
})
})
6.2.2 第二个功能测试
在不输入所需数据的情况下提交表单,这应该会触发错误并显示通知组件以及适当的消息,以及可能由于某些网络或 API 问题而失败,通知组件是否会显示正确的消息。
// second group of tests
describe('user gets notified', () => {
test('when attempting to create a post with incomplete fields', async () => {
const wrapper = mount(App);
// try to submit the form with empty fields
await wrapper.find('[data-testid="post-form"]').trigger('submit');
// assert that the notification is displayed
expect(wrapper.html()).toContain('Please input post title');
// click the close button
await wrapper.find('[data-testid="close-notification" ]').trigger('click');
// assert that error message is no longer on screen
expect(wrapper.html()).not.toContain('Please input post title');
// fill in the title input field
await wrapper.find('[data-testid="title-input"]').setValue(mockPost.title);
// try to submit the form with empty body field
await wrapper.find('[data-testid="post-form"]').trigger('submit');
// assert that a new error prompting user for body is displayed
expect(wrapper.html()).toContain('Please input post body');
});
test('when creating a new post fails', async () => {
vi.spyOn(axios, 'post').mockRejectedValueOnce(new Error('Error Occurred'))
const wrapper = mount(App);
// fill in the input fields
await wrapper.find('[data-testid="title-input"]').setValue(mockPost.title);
await wrapper.find('[data-testid="body-input"]').setValue(mockPost.body);
// try to submit the form
await wrapper.find('[data-testid="post-form"]').trigger('submit');
// assert
await flushPromises();
expect(wrapper.html()).toContain('Error Occurred');
});
});
7 Coverage
Vitest 支持 v8 和 Istanbul 等提供商的代码覆盖率。
v8 内置于 Javascript 的原生 V8 引擎中,因此使用起来非常快速和高效。 v8 还提供准确的代码覆盖率信息,无需任何特殊配置或设置。 另一方面,Istanbul 是一个功能更丰富的工具,它不依赖于任何 Javascript 引擎,但支持广泛的 Javascript 测试框架和工具。由于 Istanbul 不是基于 Javascript 构建的,因此它有时可能会在语言功能上落后,并且可能比 v8 更慢且更消耗资源。 需要在配置文件中指定一个提供程序,否则默认情况下将使用 v8。
示例中使用的c8(由于vitest的版本较低, 如果vitest的版本>=0.32.0, 这里就改成'v8')
vitest.config.js
export default mergeConfig(
viteConfig,
defineConfig({
test: {
coverage: {
provider: 'c8'
},
globals: true,
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/*'],
root: fileURLToPath(new URL('./', import.meta.url))
}
})
)
运行命令:
pnpm run test --coverage
8 参考:
文中代码来自 github.com/Code-Pop/qu…