项目搭建
项目地址: gitee.com/duibaix/vue…
- 使用vite搭建vue3+ts项目
npm create vue@latest
- 加入vitest测试框架, @testing-library/vue测试工具包, jsdom模拟DOM, 项目开发时依赖
npm install -D vitest @testing-library/vue jsdom
-
配置项, 由于vitest与vite的关系可直接在vite.config.ts中配置test信息
- 在顶部声明vitest配置ts三斜杠指令
- 加入test配置项
/// <reference types= "vitest/config" />
import { defineConfig } from "vite";
export default defineConfig({
test: {
// ... Specify options here.
},
...
});
组件测试
- 创建一个简单组件, 展示传入的props元素, my-component.vue 需要传入一个必选参数element元素标签, 两个可选参数isShow是否渲染节点和classname类名, 该组件中还具有一个默认插槽
注: 如果不传isShow参数, js 会将undefined隐式转换为false
<template>
<div class="home">
<component :is="element" v-if="isShow" :class="classname">
<slot></slot>
</component>
</div>
</template>
<script setup lang="ts">
defineProps<{
element: string,
isShow?: boolean,
classname?: string,
}>()
</script>
<style scoped>
.my-class {
border: 1px solid red;
}
</style>
-
创建test文件, 测试组件是否渲染正确
- 第一个测试用例挂载MyComponent时, 传递所有props参数, 默认插槽传入一段文字, 渲染成功后断言插槽是否正确被替换, 是否有传入的类样式
- 第二个测试用例渲染组件时, 传入参数isShow为false, 根据组件逻辑将不会挂载该节点到DOM中, 则后续断言该节点为空, 且在DOM中找不到传入的插槽文字信息
import { render, screen} from '@testing-library/vue'
import { describe, it, expect, assert } from 'vitest'
import MyComponent from '../../components/my-component.vue'
describe("MyComponent Test Unit", () => {
const testMsg = "Hello, MyCommponent"
// test case 组件正确渲染
it("render correctly", async () => {
render(MyComponent, {
props: {
element: "div",
isShow: true,
classname: "my-class"
},
slots: { default: testMsg}
})
const res = await screen.findByText(testMsg)
// 断言挂载有我传入的slot参数
expect(res.innerHTML).toBe(testMsg)
// 断言类样式存在
assert(res.classList.contains("my-class"))
})
//test case 组件display: none
it("component do not show correctly", async () => {
render(MyComponent, {
props: {
element: "div",
isShow: false,
},
slots: { default: testMsg}
})
// 断言组件不在 DOM 中
const res = screen.queryByText(testMsg);
expect(res).toBeNull();
})
})
函数简介:
@testing-library/vue中: 相关文档API | Testing Library
- render渲染一个vue组件实例, 并返回一个包含各种实用方法的对象,
- screen用于在渲染后的 DOM 中查找元素, 类似于浏览器中的DOM API
vitest中: 相关文档Vitest
- describe函数为一个测试套件, 第一个参数是对其描述信息, 第二个参数接收包含一个或多个测试用例的函数, 官方所描述是为了让组织测试和基准,使报告更加清晰
- it(test)函数为一个具体的测试用例, 第一个参数为描述信息, 第二个参数接收具体包含测试逻辑的函数。如果里面有异步操作需要声明为异步函数, 为了防止遗漏我在每个函数前都加了async关键字
- expect, assert都为断言函数, api使用风格不同
模拟网络请求测试
- 增加项目依赖 flush-promises 等待之前所有的异步请求, 在@vue/test-utils包中也有此工具
npm install -D flush-promises
- 声明ts类型(网络请求的返回值, 主要为下文的mock数据person类型)
/// <reference types= "vite/client" />
type Person = {
id: number;
name: string;
}
type Pageable = {
page: number;
size: number;
}
type DataForm<T> = {
content: T[];
pageable: Pageable;
totalElements: number;
}
type Result<T> = {
code: number;
msg: string;
result: DataForm<T> | any;
}
- 创建mock数据 person.ts
export const persons: Result<Person> = {
code: 200,
msg: "ok",
result: {
content: [
{ id: 1, name: "张三1" },
{ id: 2, name: "张三2" },
{ id: 3, name: "张三3" },
{ id: 4, name: "张三4" },
{ id: 5, name: "张三5" },
{ id: 6, name: "张三6" },
{ id: 7, name: "张三7" },
{ id: 8, name: "张三8" },
{ id: 9, name: "张三9" },
{ id: 10, name: "张三10" },
],
pageable: {
page: 1,
size: 10,
},
totalElements: 99,
},
};
- 封装模拟网络请求获取person.ts中的数据, 返回一个promise对象, 这里为了模拟网络请求失败, 特地加了一个参数
import { persons } from "@/mock/persons";
const person = {
getPersonList: (failFlag: boolean = false) => new Promise<Result<Person>>((res, rej) => {
if (failFlag) {
rej("服务器异常, 请联系管理员");
}
res(persons)
})
}
export default person
- 创建一个展示数据的组件person-list.vue接收一个Person数组进行无序列表展示person的姓名, 和一个内容页面my-content.vue, 点击按钮请求数据将数据传入person-list组件中进行展示, 同样这里为了测试异常, 给组件加了一个props参数fetchFail为了标志请求失败或成功, 默认值为false请求成功
<template>
<div class="box">
<ul v-if="persons.length > 0">
<li v-for="person in persons" :key="person.id">
{{ person.name }}
</li>
</ul>
<h2 v-else>
空
</h2>
</div>
</template>
<script setup lang="ts">
defineProps<{
persons: Person[]
}>()
</script>
<template>
<div>
<button @click="loadingPersonList">loading persons</button>
<PersonList :persons="personList"></PersonList>
</div>
</template>
<script setup lang="ts">
import { type Ref, ref } from "vue";
import PersonList from "@/components/person-list.vue";
import fetch from '@/fetch'
const props = withDefaults(defineProps<{
fetchFail: boolean
}>(), {
fetchFail: false
})
const personList: Ref<Person[]> = ref([]);
const loadingPersonList = () => {
fetch.person.getPersonList(props.fetchFail).then((res) => {
if (res.code === 200) {
personList.value = res.result.content;
}
}).catch((err) => {
console.error(err);
});
};
</script>
-
创建测试文件, 这里的shallowMount, mount与之前提到的render函数作用一致, 都是渲染组件实例. flushPromises函数则是为了等待之前所有的异步请求结束
- 第一个测试用例模拟数据请求成功, 在组件挂载成功之后找到按钮并模拟点击操作. 等待网络请求成功之后获取到Ref变量personList, 查看里面是否有数据并且其中包含一个id为1, name为张三1的对象
- 第二个测试用例模拟网络请求失败的情况, 传递参数fetchFail为true, 再次模拟按钮点击事件, 断言会渲染一个h2标签, 且里面内容为' 空 ', 注意这里文字旁边都有一个空格
import { describe, it, expect } from "vitest";
import { shallowMount, mount } from "@vue/test-utils";
import flushPromises from "flush-promises";
import MyContent from "@/page/home/my-content/index.vue";
describe("MyContent组件网络请求测试", () => {
// case1
it("加载数据成功", async () => {
// 挂载组件
const myContentWrapper = shallowMount(MyContent)
// 模拟按钮点击事件, 开始请求数据
const button = myContentWrapper.find("button")
button.trigger("click");
await flushPromises();
// 等待网络请求, 判断数据是否有值
const personList = (myContentWrapper.vm as any).personList;
expect(personList).toBeDefined();
expect(personList.length).toBeGreaterThan(0);
expect(personList).toContainEqual({
id: 1,
name: '张三1'
})
console.log(personList);
})
// case2
it("模拟网络错误", async () => {
// 传入参数, 模拟请求失败
const myContentWrapper = mount(MyContent, {
props: {
fetchFail: true,
},
});
// 模拟点击事件
const button = myContentWrapper.find("button");
button.trigger("click");
await flushPromises();
// 断言出现h2元素, 并且显示 ' 空 '
const h2El = myContentWrapper.find("h2");
expect(h2El.exists()).toBe(true)
expect(h2El.element.innerHTML).toBe(" 空 ")
});
})
权限测试
页面级权限
- 增加路由依赖
npm install -D vue-router
- 配置router, 一共五个页面和四个路由有效路由, 默认展示home首页, 如果没有访问没有定义的路由时返回404页面, 在每个路由中增加了meta元信息, 里面包含这个路由可以访问的用户名, 为所有用户都可访问, 在导航守卫中判断该用户是否有权限, 无权限跳转到no-permission页面, 用户角色存放在localStorage中, 这里为了后续代码覆盖率特地封装setNavGuard*函数并导出用在后续测试用例中
import { createRouter, createWebHashHistory, type Router, type RouteRecordRaw } from "vue-router";
declare module "vue-router" {
interface RouteMeta {
permisson: Array<string>;
}
}
const routes: RouteRecordRaw[] = [
{
path: "/",
redirect: "/home",
},
{
path: "/home",
component: () => import("@/page/home/index.vue"),
meta: {
permisson: ["*"],
},
},
{
path: "/about",
component: () => import("@/page/about/index.vue"),
meta: {
permisson: ["*"],
},
},
{
path: "/manage",
component: () => import("@/page/manage/index.vue"),
meta: {
permisson: ["admin", "userA"],
},
},
{
path: "/no-permisson",
component: () => import("@/page/no-permisson/index.vue"),
meta: {
permisson: ["*"],
},
},
{
path: "/:pathMatch(.*)*", // 捕获所有未匹配的路径
component: () => import("@/page/not-found/index.vue"),
meta: {
permisson: ["*"],
},
},
];
const router = createRouter({
history: createWebHashHistory(),
routes
})
const setNavGuard = (router: Router) => {
router.beforeEach((to, from, next) => {
const { permisson } = to.meta;
if (permisson.includes("*") || permisson.includes(localStorage.getItem("userRole"))) {
next()
} else {
next("/no-permisson")
}
})
}
setNavGuard(router)
export { routes, setNavGuard };
export default router;
-
创建测试用例, , 如下为App.vue, 为router-view创建自定义数据属性, 方便后续测试查找到该元素
<div class="app-content"> <router-link class="link" to="/">主页</router-link> <router-link data-test="about" class="link" to="/about">关于</router-link> <router-link data-test="manage" class="link" to="/manage">管理页面</router-link> <div> <router-view></router-view> </div> </div> ```
- 第一条测试用例, 测试默认路由渲染Home组件, 其他组件不存在
- 第二条测试用例, 测试访问没有定义的路由路径, 跳转到404
- 第三条测试用例, App组件渲染成功后点击router-link跳转到About页面
- 第四条测试用例, App组件渲染成功后点击router-link跳转到Manage页面被拦截重定向到NoPermission
- 第五条测试用例, 增加全局属性localSorage增加用户角色为admin, App组件渲染成功后点击router-link成功跳转到Manage页面
注意: 在测试vue-router时, 需要每一个test拥有自己的router实例, 详细可查看下方官方文档, 这里使用vitest的beforeEach注册一个回调函数, 在当前上下文中的每个测试运行前调用
expectComponent函数为断言组件是否存在, 需要传入一个VueWrapper对象和一个ComponentTestExist数组, 循环自定义数据判断组件是否渲染正确(其实写完才发现还不如一行一行cv的可读性高)
import { describe, expect, it, beforeEach } from "vitest";
import { mount, type VueWrapper } from "@vue/test-utils";
import { type Component } from "vue";
import flushPromises from "flush-promises";
import { type Router, createRouter, createWebHashHistory } from "vue-router";
import { routes, setNavGuard } from "@/router";
import App from "@/App.vue";
import Home from "@/page/home/index.vue";
import About from "@/page/about/index.vue";
import Manage from "@/page/manage/index.vue";
import NoPermission from "@/page/no-permisson/index.vue";
import NotFound from "@/page/not-found/index.vue";
declare type ComponentTestExist = {
component: Component;
isExist: boolean;
};
const expectComponent = (
appWrapper: VueWrapper,
list: Array<ComponentTestExist>
) => {
list.forEach((item) =>
expect(appWrapper.findComponent(item.component).exists()).toBe(item.isExist)
);
};
// each test should use it's own instance of the router
let router: Router;
beforeEach(async () => {
router = createRouter({
history: createWebHashHistory(),
routes,
});
setNavGuard(router);
});
describe("页面权限测试", () => {
it("测试路由不存在, 返回not-found页面", async () => {
router.push("//aaa////");
await router.isReady();
const appWrapper = mount(App, {
global: {
plugins: [router],
},
});
// 断言渲染NotFound页面
expect(appWrapper.html()).toContain("404");
expect(appWrapper.findComponent(NotFound).exists()).toBe(true);
});
it("测试路由渲染", async () => {
router.replace("/");
await router.isReady();
const appWrapper = mount(App, {
global: {
plugins: [router],
},
});
// 断言只出现Home组件
expectComponent(appWrapper, [
{ component: Home, isExist: true },
{ component: About, isExist: false },
{ component: Manage, isExist: false },
{ component: NoPermission, isExist: false },
]);
// 正确渲染Home组件组件中title
expect(appWrapper.html()).toContain("Hello, HomePage");
});
it("测试路由点击跳转成功", async () => {
router.push("/");
await router.isReady();
const appWrapper = mount(App, {
global: {
plugins: [router],
},
});
// 模拟点击about路由, 断言渲染About组件成功
await appWrapper.find("[data-test='about']").trigger("click");
await flushPromises();
expect(appWrapper.findComponent(About).exists()).toBe(true);
expect(appWrapper.html()).toContain("about");
});
it("测试没有权限的页面, 加载没有权限", async () => {
router.push("/");
await router.isReady();
const appWrapper = mount(App, {
global: {
plugins: [router],
},
});
// 模拟点击manage路由, 断言无权限, 渲染NoPermission组件成功
await appWrapper.find("[data-test='manage']").trigger("click");
await flushPromises();
expect(appWrapper.findComponent(NoPermission).exists()).toBe(true);
expect(appWrapper.findComponent(Manage).exists()).toBe(false);
expect(appWrapper.html()).toContain("no permisson");
});
it("测试没有权限的页面, 加载成功", async () => {
// 设置用户角色信息
localStorage.setItem("userRole", "admin");
router.push("/");
await router.isReady();
const appWrapper = mount(App, {
global: {
plugins: [router],
},
});
// 模拟点击manage路由, 断言无权限, 渲染Manage组件成功
await appWrapper.find("[data-test='manage']").trigger("click");
await flushPromises();
expect(appWrapper.findComponent(Manage).exists()).toBe(true);
expect(appWrapper.html()).toContain("manage");
});
});
组件级权限
灵感来源:
- 首先增加权限mock数据, permissons.ts
注: 这里*通配符的含义
- 在用户权限中, 拥有*表示可以查看所有组件进行或操作任意组件
- 在组件所需权限中, 拥有*表示任意用户都可以进行查看或操作(除当该用户的权限为 null, undefined等外, 例如haveNone用户, 当然这些逻辑可以在后续权限组件中更改)
// 所有用户
export const userRoles = ["foo", "bar", "admin", "haveNone"];
// 每个用户的权限信息, 必须为数组
export const userRolePermission = {
foo: ["sys:user:add"],
bar: ["sys:user:add", "sys:user:update"],
admin: ["*"], // 表示拥有所有权限
haveNone: null, // 无权限, 或(-)0, false, "", undefined, NaN
};
// 每个按钮所需的权限, string | string[]
export const btnPermission: Array<BtnPermisson> = [
{ name: "增加用户", permissson: "sys:user:add" },
{ name: "删除用户", permissson: "sys:user:delete" },
{ name: "修改用户", permissson: "sys:user:update" },
{
name: "联系用户",
permissson: ["sys:user:add", "sys:user:update"],
}, // 表示需要add && update两个权限
{ name: "查看用户", permissson: "*" }, // 表示无权限判断
];
- 创建useAuth.ts使用hooks函数模拟用户当前的权限(实际开发业务时应存储在状态管理或localstorage中)
import { ref } from "vue";
// 当前用户, 初始值只有增加用户的权限
export const nowPermissions = ref([
// "*",
"sys:user:add",
// "sys:user:delete",
// "sys:user:update",
]);
- 创建权限组件将需要权限展示的组件或元素包裹起来, 传入permissionNeed参数, 从useAuth中拿到当前用户权限, 通过isShowSlot计算属性用来实现判断权限逻辑(对应前端也就是该组件是否显示)
<template>
<slot v-if="isShowSlot" :nowPermissions></slot>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { nowPermissions } from "@/hooks/useAuth";
// 按钮需要的权限
const props = defineProps<{
permissionNeed: string[] | string;
}>();
const isIncludeAll = (list: string[] | string) => list.includes("*");
const isShowSlot = computed((): boolean => {
const { permissionNeed } = props;
// 如果用户没有权限,不显示
if (!nowPermissions.value) {
return false;
}
// 权限为*任意,直接显示
if (isIncludeAll(nowPermissions.value) || isIncludeAll(permissionNeed)) {
return true;
}
if (Array.isArray(permissionNeed)) {
// 用户需要包含每一个按钮所需的权限
return permissionNeed.every((item) => nowPermissions.value.includes(item));
} else {
return nowPermissions.value.includes(permissionNeed);
}
});
</script>
<style scoped></style>
- manage页面, 通过切换不同用户展示不同的按钮. 拿到所有mock数据进行展示, 将按钮传递给子组件插槽中(这里不关心权限判断的逻辑, 只需要将参数传递给子组件), 这里默认插槽其实还可以接受一个nowPermissions参数获取当前用户的权限, 可以进一步增强对插槽中的内容做操作(例如按钮的禁用, 颜色等).
注:
- 你很快就注意到了我在manage页面也用到了useAuth函数, 其实这里只是为了去切换用户特地引入的(真正业务中, 用户的切换是在登录与登出的逻辑中去操作), 实际上这里不需要关心当前用户的权限值, 只需要将按钮展示所需要的权限传入btn-authority组件.
- 这里@change="(e) => selectChange(e)" 有一点react风格, 在react事件处理函数中只能传入函数, 而不能直接调用函数比如@change="selectChange($event)" 这样的写法(react会在渲染页面时调用而不是在点击事件中调用, 当然在react中具体使用jsx语法), 而在vue中这两种写法都可以, 因为vue在内部有做处理(实际上都会以函数的形式传递,详细可查看下面链接), 个人认为传入函数比较好一些, 毕竟框架最后也是要拿到函数使用addEventListener实现监听, 而且在查看代码时也能很清楚地知道这是一个回调函数
你知道vue中@click="onClick"和@click="onClick()"有什么区别么?更详细的了解vue中的 - 掘金
<template>
<div class="manage">
<h2>manage</h2>
<div class="content">
<label :for="userRoleString">用户:</label>
<select :id="userRoleString" @change="(e) => selectChange(e)">
<option
v-for="(userRole, index) in userRoleOptions"
:key="index"
:value="userRole"
>
{{ userRole }}
</option>
</select>
<div class="btn-box">
<template v-for="(item, index) in btns">
<btn-authority :permissionNeed="item.permissson">
<template #default>
<button :key="index" :disabled="false">
{{ item.name }}
</button>
</template>
</btn-authority>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { type Ref, ref } from "vue";
import BtnAuthority from "@/components/btn-authority.vue";
import { nowPermissions } from "@/hooks/useAuth";
import {
btnPermission,
userRolePermission,
userRoles,
} from "@/mock/permissions";
// 用户select和label绑定的id值
const userRoleString = "userRole";
// 用户角色选项数组
const userRoleOptions = ref(userRoles);
// 每个按钮所需权限数组
const btns: Ref<Array<BtnPermisson>> = ref(btnPermission);
// select框change事件, 改变当前用户的权限
const selectChange = (e: Event) => {
const target = e.target as HTMLSelectElement;
nowPermissions.value = userRolePermission[target.value];
};
</script>
<style scoped>
.manage {
width: 50 % ;
margin: 0 auto;
text-align: center;
}
.content select {
width: 50 % ;
}
.content .btn-box {
margin-top: 20px;
display: flex;
flex-flow: row, nowrap;
justify-content: space-evenly;
}
</style>
-
manage组件测试,
- 第一条测试用例, 挂载manage组件, 断言默认foo角色的内容展示正确(拥有增加和查看权限)
- 第二条测试用例, 模拟选择bar用户, 断言其展示的内容正确(没有删除权限)
- 第三条测试用例, 模拟选择admin用户, 断言其展示的内容正确(拥有全部权限)
- 第四条测试用例, 模拟选择haveNone用户, 只有查看权限
import { describe, it, expect } from "vitest" ; import { mount } from "@vue/test-utils" ; import Manage from "@/page/manage/index.vue" ; describe ( "组件权限测试" , () => { it ( "测试foo权限" , async () => { const manageWrapper = mount ( Manage ); // 测试默认foo角色 expect (manageWrapper. html ()). contain ( "foo" ); // 断言foo拥有的按钮权限正确展示 => 查看和修改按钮 const btns = manageWrapper. findAll ( "button" ); expect (btns. find ( ( button ) => button. text () === "增加用户" )). toBeDefined (); expect (btns. find ( ( button ) => button. text () === "查看用户" )). toBeDefined (); expect (btns. find ( ( button ) => button. text () === "删除用户" )). toBeUndefined (); expect (btns. find ( ( button ) => button. text () === "联系用户" )). toBeUndefined (); expect (btns. find ( ( button ) => button. text () === "修改用户" )). toBeUndefined (); }); it ( "测试bar权限" , async () => { const manageWrapper = mount ( Manage ); // 模拟select选项框, 选择bar角色 await manageWrapper. find ( "select" ). setValue ( "bar" ); expect (manageWrapper. html ()). contain ( "bar" ); // 断言foo拥有的按钮权限正确展示 => 查看,修改,增加和删除按钮 const btns = manageWrapper. findAll ( "button" ); expect (btns. find ( ( button ) => button. text () === "增加用户" )). toBeDefined (); expect (btns. find ( ( button ) => button. text () === "查看用户" )). toBeDefined (); expect (btns. find ( ( button ) => button. text () === "删除用户" )). toBeUndefined (); expect (btns. find ( ( button ) => button. text () === "联系用户" )). toBeDefined (); expect (btns. find ( ( button ) => button. text () === "修改用户" )). toBeDefined (); }); it ( "测试admin权限" , async () => { const manageWrapper = mount ( Manage ); // 模拟select选项框, 选择admin角色 await manageWrapper. find ( "select" ). setValue ( "admin" ); expect (manageWrapper. html ()). contain ( "admin" ); // 断言foo拥有的按钮权限正确展示 => 所有权限 const btns = manageWrapper. findAll ( "button" ); expect (btns. find ( ( button ) => button. text () === "增加用户" )). toBeDefined (); expect (btns. find ( ( button ) => button. text () === "查看用户" )). toBeDefined (); expect (btns. find ( ( button ) => button. text () === "删除用户" )). toBeDefined (); expect (btns. find ( ( button ) => button. text () === "联系用户" )). toBeDefined (); expect (btns. find ( ( button ) => button. text () === "修改用户" )). toBeDefined (); }); it ( "测试用户权限为空" , async () => { const manageWrapper = mount ( Manage ); // 模拟select选项框, 选择haveNone角色 await manageWrapper. find ( "select" ). setValue ( "haveNone" ); expect (manageWrapper. html ()). contain ( "haveNone" ); // 断言haveNone 只有查看权限 const btns = manageWrapper. findAll ( "button" ); expect (btns. find ( ( button ) => button. text () === "增加用户" )). toBeUndefined (); expect (btns. find ( ( button ) => button. text () === "查看用户" )). toBeDefined (); expect (btns. find ( ( button ) => button. text () === "删除用户" )). toBeUndefined (); expect (btns. find ( ( button ) => button. text () === "联系用户" )). toBeUndefined (); expect (btns. find ( ( button ) => button. text () === "修改用户" )). toBeUndefined (); }); });
代码覆盖率
- 增加v8依赖
npm install -D @vitest/coverage-v8
2. 使用vitest run --coverage命令跑覆盖率, 会默认在根目录下生成coverage文件夹, 里面包含具体的代码覆盖率信息
npx vitest run --coverage
3. 测试率结果展示