一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第6天,点击查看活动详情。
背景
今天跟组长聊了下关于单元测试的看法,打算对单元测试的理解写一个总结,主要讲下市面上(我经常用)的测试工具、如何编写测试、如何调试
为什么需要测试
测试极大地保障我们小步快走开发,试想下没有测试的情况,你去做优化或者是重构,你每次修改内部代码,为了保障行为是否正确,需要手动去做一连串测试(与项目大小成正比),而且人工测试稳定性太差了,难免会疏漏,面对💩⛰更是无从下手
如果你事先对程序编写了测试,那么你的境况完全不一样,每次改动,运行测试命令,只需关注行为是否与测试预想一致即可。
而且好处并不止这点,著名的测试驱动开发,也就是TDD
,这种测试手法,推荐我们在实现功能前,先编写测试,在这过程中,我们得到了方法名、入参和输出,直观体现出功能整体架构,接下来主要把关注点放到内部实现中即可,间接解构了方法实现和接口实现
测试同时又是文档的第二张脸,很多隐藏的功能使用姿势,都埋藏在测试里面
测试的收益随时间与项目大小成正比,而且你要发npm包的话,项目里没有做好足够的测试覆盖率,估计没有几个人敢去买你的单。
从上面几点可见测试的重要性
测试工具
我之前经常用jest
,最近正在转用vitest
。
为什么呢?
我转换vitest
主要有4个理由:
jest
对于ts
和esm
支持需要额外配置,并且部分功能还是实现阶段,而vitest
对我目前项目开箱即用,更加友好省心- 同样是因为
jest
需要更多的额外配置,而vitest
可以和vite
共用打包配置,行为一致 vitest
基于vite
,更快- 语法和
jest
一致,没有中文文档的时候,我一度对着jest
来写vitest
😂,切换成本低
如何编写测试
测试简单起来很简单,奇怪起来很奇怪,例如:你需要在node环境中使用浏览器宿主的api,即使有jsdmo
这玩意,也无法让你直接从中各种奇奇怪怪的测试用例脱身而出。这里说说我遇到的几种情况以及做法
ua
跟浏览器和兼容相关的函数并不少,函数里面可能有各种各样的环境判断,这时候需要模拟ua
进行测试,模拟ua
这些还是挺简单的,就像赋值普通变量一样,想测其他分支,就再赋值对应的ua
即可
const ua = `Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1`
Object.defineProperty(window, 'navigator', {
value: {
userAgent: ua,
platform: ua,
},
writable: true,
})
定时器
节流或者是倒计时这些,涉及到真实系统时间的方法,例如验证码60秒,不要傻傻地去等那60秒🤣,测试库带时间限制,会超时警告。看下面例子的正确姿势👇
// 一个节流方法的单元测试
it('idle', () => {
jest.useFakeTimers() // 模拟定时器
const fun = jest.fn() // 创建一个模拟函数,用来记录调用次数
const idle = 2000 // 定义时间
const throttleFun = throttle(fun, idle)
throttleFun() // 进入定时器等待2秒才触发
throttleFun() // 刷新定时器,重新进入定时器等待2秒才触发
expect(fun).toHaveBeenCalledTimes(0) // 定时器还没超时,所以函数还没被调用
jest.advanceTimersByTime(idle) // 告诉定时器已经过了2秒
expect(fun).toHaveBeenCalledTimes(1) // 超时,触发回调函数,被调用了1次
})
上面单元测试的具体流程:模拟定时器->设置成超时->记录下函数调用次数
loadScript
loadScript
这个方法主要是用来加载js脚本,测试起来并不难,输入一个相关js脚本链接,然后将加载到window
上的值进行对比就可以,但是这里有个问题,一般脚本都会占那么点kb,测试小还行,然后多起来一边耗时一点速度会很拉胯
能不能在这地方优化呢?
我的做法是,我搞了一个体积很小的测试包发到npm上,然后我用loadScript
加载就可以啦。不过说不定,npm上早就有这种包了,不过懒得找
其实还有一种做法,就是起一个服务,本地挂一段js脚本,让loadScript
加载。这个方法看起来很慢,却是最安全的,因为这段js脚本是可控的,你放到第三方平台上,说不定某天就没了。这不是危言耸听,因为发生在我身上,某天被人找上门来,说要买我的npm包名🤩,被妹子威逼利诱,身为正人君子的我那会动摇,但是听说妹纸对这个包名情有独钟,那没办法了,俗话说君子不夺人所好,只好割爱让出包名,换来了铜板几枚。
node
之前写了一个lerna
👉🏻好家伙,这是写了一个“lerna”,涉及到了一些git场景和npm相关的命令,无论是前者还是后者如果只用一般的方式去写单元测试的话,会对项目环境造成污染,在研究了相关库的测试,get到了新的手法去解决。
git
场景是要在一个模板项目上创建git仓库
测试,我是利用node生成一个临时目录,再把进程的当前工作目录切换到临时目录,这样我所有的git操作都基于该临时目录进行测试,关机就会自动清除掉临时目录🌶~
version
当你本地安装了@abmao/pkgs
时,用pkgs version
会出现以下交互👇🏻
编写测试就是为了后面自动化,如果还需要交互可太麻烦了,观摩了相关库的测试和源码,发现可以通过直接输入新的版本号跳过交互操作,但是因为该库并不满足我当时的需求,他每次运行除了获取新版本之外,还会干一大堆事,我就fork项目修改完再pr上去,不过估计没人管这个项目了,干脆自己publish上去自己用。
publish
所以我做单元测试是不是要真实调用npm run publish
这个命令,把包放上去?
当然不可能,太憨了😅。我的做法是将命令以字符串的方式返回,然后进行断言。
过程并不顺利,生成这个命令的函数是一个类的方法,功能内聚在细节里面。思考过后,决定对源码做测试环境判断,如果是测试环境的话,就把命令字符串收集起来,然后测试这边断言
虽然对源码做了手脚,但没达到破坏性操作,总体还是可以接受
如何调试
我是用vscode
进行调试,在根目录放.vscode/launch.json
配置文件,通过组合键fn
+f5
进入Debug
,当然,你还得先在代码左方添加你要调试的断点
launch.json配置
下面是相关测试库配置,复制即可
// vitest
{
// 想了解更多的信息, 请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "pwa-node",
"request": "launch",
"name": "Debug Current Test File",
"autoAttachChildProcesses": true,
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
"args": ["run", "${relativeFile}"],
"smartStep": true,
"console": "integratedTerminal"
}
]
}
// jest
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Jest All",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["--runInBand"],
// 只会打当前vscode预览文件得断点
// "args": ["${fileBasename}", "--runInBand", "--detectOpenHandles"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"windows": {
"program": "${workspaceFolder}/node_modules/jest/bin/jest"
}
},
{
"type": "node",
"request": "launch",
"name": "Jest Current File",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["${relativeFile}"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"windows": {
"program": "${workspaceFolder}/node_modules/jest/bin/jest"
}
}
]
}
// jest+pnpm
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Jest",
"type": "node",
"request": "launch",
"stopOnEntry": false,
// 只会打当前vscode预览文件的断点
"args": ["${fileBasename}", "--runInBand", "--detectOpenHandles"],
"cwd": "${workspaceFolder}",
"preLaunchTask": null,
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/jest",
"env": {
"NODE_ENV": "development"
},
"console": "integratedTerminal",
"sourceMaps": true,
"windows": {
"program": "${workspaceFolder}/node_modules/jest/bin/jest",
}
}
]
}
过程
写测试老实说还是很枯燥乏味的,但是这是一开始,等你写到后面的时候,你会对那些绿绿的覆盖率提高变得执着(偏执)起来,这样子很好,但这样子不好,因为很容易就会演变成为了测试而测试,提高那1%覆盖率,而做了很多破坏性的操作。所以要学会平衡,是否值得这样做。
反正我,除了无法或者不用的地方,覆盖率基本拉满,略略略~
总结
话说回来,跟组长聊的过程,涉及到了微信jssdk跟唤醒app的一些功能如何做单元测试,在我的理解中,这两种其实都超过了单元测试的范畴,就比如微信jssdk分享,即使开一个无头浏览器也做不了右上角分享(如果有大佬做过的话指点下),如果非要做的话,可以参考写publish
测试时的思想,不实际去触发第三方行为,只做前端这边的事,把结果转化成简单的变量并对比是否与我们预想中的值一样,一样视为通过。至少保证了前端入参出参正确,出错的话,甩锅也理直气壮。
还有一点就是,能纯函数测试就用纯函数测试,面向对象之类耦合程度和心智负担更重,会出现各种奇奇怪怪的测试手法(参考publish
完~