# Vue
# 单元测试Vitest
# 安装测试框架
# -D == --save-dev == -d
# Vitest requires Vite >= v3.0.0 and Node >= v14.0.0
npm install -D vitest
2
3
# 断言
Vitest支持chaijs断言写法,chai是一个用于节点和浏览器的BDD/TDD断言库,可以与任何javascript测试框架完美搭配。
# 断言的写法
chaijs支持三种写法:
should
chai.should(); foo.should.be.a('string'); foo.should.equal('bar'); foo.should.have.lengthOf(3); tea.should.have.property('flavors').with.lengthOf(3);
1
2
3
4
5expect
var expect = chai.expect; expect(foo).to.be.a('string'); expect(foo).to.equal('bar'); expect(foo).to.have.lengthOf(3); expect(tea).to.have.property('flavors').with.lengthOf(3);
1
2
3
4
5
6assert
var assert = chai.assert; assert.typeOf(foo, 'string'); assert.equal(foo, 'bar'); assert.lengthOf(foo, 3) assert.property(tea, 'flavors'); assert.lengthOf(tea.flavors, 3);
1
2
3
4
5
6
7
# 在项目中书写测试case
在Vite根目录中创建文件example.test.ts。 然后引入两个依赖,编写对应测试用例:
// expect 所需要使用的断言库
// test 创建测试用例
import { expect, test } from 'vitest'
test('test common matcher', () => {
const name = 'vanalso'
// 预计 name变量 等于 'vanalso'
expect(name).toBe('vanalso')
expect(2 + 2).not.toBe(5)
})
2
3
4
5
6
7
8
9
10
然后使用 npx vitest example 命令运行测试,即可看到结果。
# 相关语法
toBe // 严格相等(值与引用)
not.toBe // 不严格相等
toEqual // 值相等(例如断言对象)
toBeTruehy // 为真
toBeFalsy // 为假
toBeGreaterThan // 大于
toBeLessThan // 小于
2
3
4
5
6
7
# 测试回调函数
假设此时我们需要检测一个回调函数是否被执行:
describe('functions', () => {
test('create a mock function', () => {
// 使用vi.fn()创建函数的监听程序
const callback = vi.fn()
// 将监听函数传递到目标testfn中作为回调函数
testFn(12, callback)
// toHaveBeenCalled断言函数,用于测试函数是否被调用
expect(callback).toHaveBeenCalled()
// toHaveBeenCalledWith断言函数,用于测试函数是否至少被传递特定参数调用过一次
expect(callback).toHaveBeenCalledWith(12)
})
})
2
3
4
5
6
7
8
9
10
11
12
13
describe可以将测试分组
# 监听对象内的方法
使用vi.spyOn监听对象内的方法
test('spy on method', () => {
const obj = {
getName: () => 1
}
// 创建对于对象obj的getName方法的监听
const spy = vi.spyOn(obj, 'getName')
obj.getName()
// 断言spy监听的getName方法至少调用一次
expect(spy).toHaveBeenCalled()
obj.getName()
// 断言spy监听的getName方法将会调用两次
expect(spy).toHaveBeenCalledTimes(2)
})
2
3
4
5
6
7
8
9
10
11
12
13
# 第三方模块模拟Mock数据
通常调用api发送ajax请求时,需要通过后端获取数据,但在坐做前端测试测时候,并不需要调用真实接口,所以需要模拟axios模块,使其不用调用api也能测试接口调用是否正确
// 将需要代理的模块引入
import axios from 'axios'
// 对于ts来讲需要将axios的类型断言为Mocked,并传入泛型axios,才可使用Mock内部方法
const mockedAxios = axios as Mocked<typeof axios>
// 使用vi.mock替换axios中导入的模块
vi.mock('axios')
test('mock third party module', async () => {
// 将替换后的mockedAxios.get方法的实现替换为mockImplementation传入的方法,即调用axios.get的时候直接返回一promise.resolve
mockedAxios.get.mockImplementation(() => Promise.resolve({ data: '123' }))
// 此处为上方mockImplementation的简写形式,直接mock一个resolve
mockedAxios.get.mockResolvedValue({ data: '1234' })
const result = await request()
// 因为上方已经替换掉request中axios.get的执行结果为{ data: '123' },此处直接预计结果为123,即可测试成功
expect(result).toBe('1234')
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 基于Vue的单元测试工具
vue-test-utils 安装:
npm i -D @vue/test-utils
# 创建组件测试
测试组件时,要将组件挂在到页面中,这时需要创建一个包含被挂载和渲染的Vue组件,使用vue-test-utils中的mount方法:
import { mount } from '@vue/test-utils'
import Button from './Button.vue'
const wrapper = mount(Button, {
props: {
type: 'primary'
},
slots: {
default: 'button'
}
})
console.log(wrapper.html())
// .classes()方法可以获取到节点的class集合
// toContain()方法可以断言实际值是否在数组中
expect(wrapper.classes()).toContain('ds-button--primary')
// .getComponent方法可以获取元素,传递css选择器
// .text()可以获取节点文本内容
expect(wrapper.getComponent('.ds-button').text()).toBe('button')
// 通过attributes可以获取到组件上的属性
// 使用toBeDefined断言属性被定义
expect(wrapper.attributes('disabled')).toBeDefined()
// 或者通过element获取到dom元素,从而访问disabled属性
expect(wrapper.find('button').element.disabled).toBeDefined()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
在mound方法中可以传入props、slots等选项用于提供组件创建时所需参数
# 配置vitest运行环境
但此时如果使用npx vitest Button运行测试的话,会报错ReferenceError: document is not defined,这是因为vitest的运行环境是基于node的,需要配置vitest使其运行在dom环境中,解决没有document的问题:
// 在根目录创建vitest.config.ts文件
// 使用///三斜杠命令添加test属性
/// <reference types='vitest' />
// 通过mergeConfig合并vite.config.ts中的配置
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
// 针对vitest配置
test: {
// 开启全局api
globals: true,
// 设定运行环境为jsdom
environment: 'jsdom'
},
})
)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
此处需要安装jsdom
npm i -D jsdom
# Stubs组件存根
在测试组件的时候,经常会遇到组件内部引用第三方组件的情况,此时我们只想知道是否存在第三方组件,无需挂载复杂的组件内部,只要节点存在且传递属性正确即可,所以对于测试来讲,我们不希望挂载完整的第三方组件,使用global.stubs
测试button组件的icon子组件是否被正确挂载
test('button icon', () => {
const wrapper = mount(Button, {
props: {
icon: 'arrow-up'
},
slots: {
default: 'button'
},
global: {
stubs: ['FontAwesomeIcon']
}
})
const fontElement = wrapper.findComponent(FontAwesomeIcon)
// 断言组件存在
expect(fontElement.exists()).toBeTruthy()
// 断言icon属性的值为'arrow-up'
expect(fontElement.attributes('icon')).toBe('arrow-up')
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Vnode以及Render Function
# Virtual DOM
Virtual DOM: 一种虚拟的,保存在内存中的数据结构,用来代表UI的表现,和真实DOM节点保持同步。Virtual DOM是由一系列的Vnode组成。
const vnode = {
type: 'div',
props: {
id: 'hello'
},
children: [
// more vnodes
]
}
2
3
4
5
6
7
8
9
# Render Pipeline 渲染管线
Compile: Vue组件的Template会被编译成render function,一个可以返回Virtual DOM 树的函数
Mount: 执行render function,遍历虚拟DOM树,生成真正的DOM节点
Patch: 当组件中任何响应式对象(依赖)发生变化的时候,执行更新操作。生成新的虚拟节点树,Vue内部会遍历新的虚拟节点树,和旧的树做对比,然后执行必要的更新。
# 创建Vnode
h 和 createVnode 都可以创建vnode,h是hyperscript的缩写,意思是“Javascript that produces HTML(hypertext markup language)”,很多Virtual DOM的实现都是使用这个函数名称。还有一个函数称之为createVnode,更形象,两个函数的用法几乎是一样的。
import {h, createVnode} from 'vue'
const vnode = h(
'div', // type
{ id: 'foo', class: 'bar'}, // props
[
// choldre
]
)
2
3
4
5
6
7
8
9
// VNode.ts
import { h, defineComponent } from 'vue'
export default defineComponent({
name: 'VNode',
props: {
msg: {
type: String,
}
},
setup(props) {
return () => h('h1', { id: 'vnode' }, props.msg)
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
# 使用JSX/TSX语法创建vnode
// Collapse.test.tsx
const onChange = vi.fn()
const wrapper = mount(() => (
<Collapse modelValue={['a']} onChange={onChange}>
<Item name="a" title="Title A">
content a
</Item>
<Item name="b" title='Title B'>
content b
</Item>
<Item name="c" disabled title='Title c '>
content c
</Item>
</Collapse>
))
const headers = wrapper.findAll('.ds-collapse-item__header')
const contents = wrapper.findAll('.ds-collapse-item__content__wrapper')
const firstHeader = headers[0]
const firstContent = contents[0]
await firstHeader.trigger('click')
expect(firstContent.isVisible()).toBeFalsy()
expect(onChange).toHaveBeenCalledWith([])
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Popperjs
官网: https://floating-ui.com/?utm_source=popper.js.org (opens new window)
# 安装及基础使用
npm i -S @popperjs/core
import { onMounted, ref } from 'vue'
import { createPopper, type Instance } from '@popperjs/core'
// tooltip
const overlayNode = ref<HTMLElement>()
// 触发元素
const triggerNode = ref<HTMLElement>()
let popperInstance: Instance | null = null
onMounted(()=>{
if(overlayNode.value && triggerNode.value) {
console.log('createPopper create')
popperInstance = createPopper(triggerNode.value, overlayNode.value, { placement: 'bottom' })
}
setTimeout(()=>{
// 重设属性
popperInstance?.setOptions({placement: 'bottom'})
}, 2000)
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 动态绑定事件
正常在绑定事件的时候,我们使用@click来绑定,知道@click = v-on:click,所以可以使用v-on传递object作为参数,实现动态绑定事件
const events = reactive({'click': callback, 'mouseenter': callback});
<div v-on="events"></div>
2
# Hooks
# hooks 是什么
vue3 中的 hooks 就是函数的一种写法,就是将文件的一些单独功能的 js 代码进行抽离出来进行封装使用。
它的主要作用是 Vue3 借鉴了 React 的一种机制,用于在函数组件中共享状态逻辑和副作用,从而实现代码的可复用性。
注意:其实 hooks 和 vue2 中的 mixin 有点类似,但是相对 mixins 而言, hooks 更清楚复用功能代码的来源, 更清晰易懂。
# hooks 的优点
- hooks 作为独立逻辑的组件封装,其内部的属性、函数等和外部组件具有响应式依附的作用。
- 自定义 hook 的作用类似于 vue2 中的 mixin 技术,使用方便,易于上手。
- 使用 Vue3 的组合 API 封装的可复用,高内聚低耦合。
# 自定义 hook 需要满足的规范
- 具备可复用功能,才需要抽离为 hooks 独立文件
- 函数名/文件名以 use 开头,形如: useXX
- 引用时将响应式变量或者方法显式解构暴露出来;
# 示例
这是一个全局点击事件钩子封装 其作用在于当在document上点击时,判断是否为目标元素以外的其他元素,如果是的话,触发callback。
import { onMounted, onUnmounted, type Ref } from "vue"
// 定义一个钩子函数,功能为点击一个给定元素以外的元素时,触发回调函数
// 接收两个参数,目标元素(ref类型) 回调函数
const useClickOutside = (elementRef: Ref<undefined | HTMLElement>, callback: (e: MouseEvent) => void) => {
// 定义点击事件,接收参数event
const handler = (e: MouseEvent) => {
console.log('handler attach')
// 如果目标元素存在,且点击目标元素存在
if (elementRef.value && e.target) {
// Node.contains() Node 接口的 contains() 方法返回一个布尔值,表示一个节点是否是给定节点的后代,即该节点本身、其直接子节点(childNodes)、子节点的直接子节点等。
// 这里来判断被点击的元素是不是目标元素或其子元素
if (!elementRef.value.contains(e.target as HTMLElement)) {
// 如果不是则触发callback
callback(e)
}
}
}
onMounted(() => {
// 页面挂载时绑定handler事件
document.addEventListener('click', handler)
})
onUnmounted(() => {
// 页面卸载时移除handler事件
document.removeEventListener('click', handler)
})
}
export default useClickOutside
// components
import useClickOutside from '@/hooks/useClickOutside';
const popperContainerNode = ref<HTMLElement>()
useClickOutside(popperContainerNode, () => {
if(props.trigger === 'click' && isOpen.value) {
close()
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 高级类型 Partial
Partial 可以使一个类型上的所有key变为可选key,例如 引用第三方库popper时,我们希望可以在组件外部定义popper的参数,而popper的参数类型Options是需要必选的,此时就可以使用Partial使其可选
// Popper types.d.ts
...
export declare type Options = {
placement: Placement;
modifiers: Array<Partial<Modifier<any, any>>>;
strategy: PositioningStrategy;
onFirstUpdate?: (arg0: Partial<State>) => void;
};
// 由上可以看到都是必选项
...
// components type.ts
2
3
4
5
6
7
8
9
10
11
12