前言
在早期接触前端的时候就对单测有所耳闻,但是从当时一直到现在,我都觉得单测是一个费力不讨好的事情。在公司开发相关业务的时候都已经忙的够呛了,还要花时间去写单测,不如自己手点点点、打打 console
就得了。
但是,这个观点一直到这个周末接触到单测课程,我才发现自己的观点好像是有点井底蛙。因为单测不仅仅是为了保证代码的质量,更是为了提高自己的开发效率。在这个过程中,我学到了很多东西,也发现了自己的很多问题。在这里,我会分享一下我学到的东西,希望对大家有所帮助。
什么是单元测试
单元测试是指对软件中的最小可测试单元进行检查和验证。在面向对象的程序设计中,最小单元是指类或者方法。单元测试是开发人员编写的用于检查其自己的代码是否正确的测试代码。单元测试是自动化的,可以在任何时候运行,而且可以频繁地运行。单元测试是一种白盒测试。
为什么要写单元测试
- 提高代码质量
- 提高开发效率
- 保证代码的正确性
- 便于重构
起步
环境准备
假设大家已经会创建 vite
工程,所以这里不在做赘述。如果大家还不会,可以查看 vite 官网。
- Vite
- Vue3.x
- TypeScript
- Pnpm
- Vitest
这里我们使用
vitest
作为单测工具。该工具基于vite
,具有高性能、零配置、开箱即用等特点。
安装
bash
pnpm add vitest -D
配置
这里不做赘述,直接点击查看 配置流程。
测试用例
文件结构
bash
.
├── src
│ ├── sum.ts
├── __test__
│ ├── sum.spec.ts
在
src
目录下新建sum.ts
文件,用于编写我们的业务代码。在__test__
目录下新建sum.spec.ts
文件,用于编写我们的测试用例。
sum.ts
ts
export function sum(a: number, b: number): number {
return a + b
}
sum.spec.ts
ts
import { sum } from '../src/sum'
import { describe, it, expect } from 'vitest'
describe('sum', () => {
it('sum(1, 2) 结果应该为 3', () => {
expect(sum(1, 2)).toBe(3)
})
})
运行测试
bash
pnpm test
查看结果
bash
PASS __test__/sum.spec.ts
sum
✓ sum(1, 2) 结果应该为 3 (2ms)
深入
上述开胃菜,我相信大家看看官网的相关文档都能写出来,并且写这篇文章的目的也并非是水文。接下来,我们将深入一些内容,让大家更好的理解单测。
封装公用方法
在 __test__
目录下新建 utils.ts
文件夹,用于封装公用方法。文件结构应该如下:
bash
.
├── __test__
│ ├── utils
│ ├── renderHook.ts
│ ├── createRefElement.tsx
@vue/test-utils
库是Vue.js
官方提供的一个用于测试Vue
组件的工具库。它提供了一些用于测试Vue
组件的API
,可以让我们更方便的测试Vue
组件。
bash
pnpm add @vue/test-utils -D
封装 createRefElement.tsx
tsx
import { defineComponent, ref } from 'vue'
import { mount } from '@vue/test-utils'
const createRefElement = (slots?: Record<string, Function>) => {
const wrapper = mount(
defineComponent({
setup() {
const domRef = ref<HTMLElement>()
return {
domRef,
}
},
render() {
return <div ref="domRef">{{ ...slots }}</div>
},
}),
)
return wrapper
}
export default createRefElement
封装 renderHook.ts
ts
import { createApp, defineComponent } from 'vue'
import type { App } from 'vue'
export default function renderHook<R = any>(
renderFC: () => R,
): [
R,
App<Element>,
{
act?: (fn: () => void) => void
},
] {
let result: any
let act: ((fn: () => void) => void) | undefined
const app = createApp(
defineComponent({
setup() {
result = renderFC()
act = (fn: () => void) => {
fn()
}
return () => {}
},
}),
)
app.mount(document.createElement('div'))
return [result, app, { act }]
}
模拟事件
浏览器窗口尺寸变化
假设我们有一个 useWindowSize
的 hook
,用于获取浏览器窗口的宽高。
bash
pnpm add @vueuse/core
bash
.
├── src
│ ├── useWindowSize.ts
├── __test__
│ ├── useWindowSize.spec.ts
useWindowSize.ts
这里我们就不自己写了,可以直接使用 @vueuse/core
库提供的 useWindowSize
。
useWindowSize.spec.ts
ts
import { useWindowSize } from '@vueuse/core'
describe('useWindowSize', () => {
it('当浏览器尺寸发生变化后,应该更新 width 与 height 的值', async () => {
const { width, height } = useDevice()
window.innerWidth = 300
window.innerHeight = 400
window.dispatchEvent(new Event('resize'))
await nextTick()
expect(width.value).toBe(300)
expect(height.value).toBe(400)
})
})
点击事件
假设我们有一个 useClick
的 hook
,用于监听点击事件。
useClick.ts
这里的
unrefElement
方法是用于获取ref
的dom
元素;on
与off
方法是用于监听与移除事件;BasicTarget
是描述待绑定元素的类型。由于篇幅原因,这里不做展开,有兴趣可以去百度一下如何写。
ts
import { ref, onMounted, onBeforeUnmount } from 'vue'
export function useClick<T extends HTMLElement>(target: BasicTarget<T>) {
const count = ref(0)
const click = () => {
const element = unrefElement(target)
if (element) {
count.value += 1
}
}
onMounted(() => {
on(document, 'click', click)
})
onBeforeUnmount(() => {
off(document, 'click', click)
})
return {
count,
}
}
useClick.spec.ts
ts
import { useClick } from '../src/useClick'
import { nextTick } from 'vue'
import { ref } from 'vue'
import createRefElement from './utils/createRefElement'
import renderHook from './utils/renderHook'
describe('useClick', () => {
const wrapperRef = createRefElement()
const [result] = renderHook(() => useClick(wrapperRef.element))
it('当点击元素后,count 应该加 1', async () => {
wrapperRef.element.click()
await nextTick()
expect(result.count.value).toBe(1)
})
})
模拟环境
在实际开发中,我们会注入一些 vue
相关的插件,比如 vue-router
, pinia
, i18n
等。这里简单讲解一下如何在测试环境中起用这些插件。
setupMiniApp
在 __test__
的 utils
目录下新建 setupMiniApp.ts
文件。
bash
.
├── __test__
│ ├── utils
│ ├── setupMiniApp.ts
ts
import { setupStore } from '../../src/store'
import { setupRouter } from '../../src/router'
import { setupI18n } from '../../src/locales'
import renderHook from '../utils/renderHook'
/**
*
* @description
* 初始化 mini ray template 应用环境。
* 该方法会初始化 store、router、i18n 等环境。
*
* @example
* const { app } = await setupMiniApp()
*/
const setupMiniApp = async () => {
const [_, app] = renderHook(() => {})
setupStore(app)
setupRouter(app)
await setupI18n(app)
return {
app,
}
}
export default setupMiniApp
其中的相关插件 setup
方法这里就不做赘述,其实大概就是初始化插件,并且挂载在 app
上。可以点击查看 Ray Template 相关方法的实现:
更新 pinia 仓库中的值
在 src
目录下新建一个 store
文件夹,用于存放 pinia
仓库。并且新建一个 demo.ts
文件,用于存放 demo
仓库。
创建对应的 demo.spec.ts
测试文件
bash
.
├── src
│ ├── store
│ ├── demo.ts
├── __test__
│ ├── store
│ ├── demo.spec.ts
demo.ts
ts
import { defineStore } from 'pinia'
export const useDemoStore = defineStore('demo', () => {
const count = ref(0)
const increment = () => {
count.value += 1
}
return {
count,
increment,
}
})
demo.spec.ts
ts
import setupMiniApp from '../utils/setupMiniApp'
import { useDemoStore } from '../../src/store/demo'
describe('useDemoStore', async () => {
await setupMiniApp()
it('当调用 increment 方法后,count 应该加 1', async () => {
const store = useDemoStore()
store.increment()
expect(store.count.value).toBe(1)
})
})
使用 vue-router 方法
在 src
目录下新建一个 router
文件夹,用于存放 vue-router
相关文件。并且新建一个 index.ts
文件,用于存放 router
相关配置。
创建对应的 router.spec.ts
测试文件
bash
.
├── src
│ ├── router
│ ├── index.ts
├── __test__
│ ├── router
│ ├── index.spec.ts
ts
import setupMiniApp from '../utils/setupMiniApp'
import router from '../../src/router'
describe('router', async () => {
await setupMiniApp()
it('当调用 router.push 方法后,路由应该发生变化', async () => {
const { push, replace } = router
assert.isFunction(push)
assert.isFunction(replace)
})
})
使用 i18n 方法
在 src
目录下新建一个 locales
文件夹,用于存放 i18n
相关文件。并且新建一个 index.ts
文件,用于存放 i18n
相关配置。
创建对应的 locales.spec.ts
测试文件
bash
.
├── src
│ ├── locales
│ ├── index.ts
├── __test__
│ ├── locales
│ ├── index.spec.ts
ts
import setupMiniApp from '../utils/setupMiniApp'
import i18n from '../../src/locales'
describe('i18n', async () => {
await setupMiniApp()
it('当调用 t 方法后,应该返回对应的翻译文本', async () => {
const { t } = i18n.global
assert.isFunction(t)
})
})
最后🌻
至此,大概讲解了一下单测的相关内容。希望对大家有所帮助。在实际开发中,单测是非常重要的,不仅仅是为了保证代码的质量,更是为了提高自己的开发效率。希望大家都能写出高质量的代码。
文章内有很多内容都是来自于 Ray Template,如果大家有兴趣可以去看看。如果有什么问题,欢迎大家提出来,我会尽力解答。