解锁单元测试的羞羞姿势

815 阅读8分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第6天,点击查看活动详情

背景

今天跟组长聊了下关于单元测试的看法,打算对单元测试的理解写一个总结,主要讲下市面上(我经常用)的测试工具、如何编写测试、如何调试

为什么需要测试

测试极大地保障我们小步快走开发,试想下没有测试的情况,你去做优化或者是重构,你每次修改内部代码,为了保障行为是否正确,需要手动去做一连串测试(与项目大小成正比),而且人工测试稳定性太差了,难免会疏漏,面对💩⛰更是无从下手

如果你事先对程序编写了测试,那么你的境况完全不一样,每次改动,运行测试命令,只需关注行为是否与测试预想一致即可。

而且好处并不止这点,著名的测试驱动开发,也就是TDD,这种测试手法,推荐我们在实现功能前,先编写测试,在这过程中,我们得到了方法名、入参和输出,直观体现出功能整体架构,接下来主要把关注点放到内部实现中即可,间接解构了方法实现和接口实现

测试同时又是文档的第二张脸,很多隐藏的功能使用姿势,都埋藏在测试里面

测试的收益随时间与项目大小成正比,而且你要发npm包的话,项目里没有做好足够的测试覆盖率,估计没有几个人敢去买你的单。

从上面几点可见测试的重要性

测试工具

我之前经常用jest,最近正在转用vitest

为什么呢?

我转换vitest主要有4个理由:

  1. jest对于tsesm支持需要额外配置,并且部分功能还是实现阶段,而vitest对我目前项目开箱即用,更加友好省心
  2. 同样是因为jest需要更多的额外配置,而vitest可以和vite共用打包配置,行为一致
  3. vitest基于vite,更快
  4. 语法和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会出现以下交互👇🏻 image.png 编写测试就是为了后面自动化,如果还需要交互可太麻烦了,观摩了相关库的测试和源码,发现可以通过直接输入新的版本号跳过交互操作,但是因为该库并不满足我当时的需求,他每次运行除了获取新版本之外,还会干一大堆事,我就fork项目修改完再pr上去,不过估计没人管这个项目了,干脆自己publish上去自己用。

publish

所以我做单元测试是不是要真实调用npm run publish这个命令,把包放上去?

当然不可能,太憨了😅。我的做法是将命令以字符串的方式返回,然后进行断言。

过程并不顺利,生成这个命令的函数是一个类的方法,功能内聚在细节里面。思考过后,决定对源码做测试环境判断,如果是测试环境的话,就把命令字符串收集起来,然后测试这边断言

image.png

image.png 虽然对源码做了手脚,但没达到破坏性操作,总体还是可以接受

如何调试

我是用vscode进行调试,在根目录放.vscode/launch.json配置文件,通过组合键fn+f5进入Debug,当然,你还得先在代码左方添加你要调试的断点

image.png

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%覆盖率,而做了很多破坏性的操作。所以要学会平衡,是否值得这样做。

反正我,除了无法或者不用的地方,覆盖率基本拉满,略略略~ image.png

总结

话说回来,跟组长聊的过程,涉及到了微信jssdk跟唤醒app的一些功能如何做单元测试,在我的理解中,这两种其实都超过了单元测试的范畴,就比如微信jssdk分享,即使开一个无头浏览器也做不了右上角分享(如果有大佬做过的话指点下),如果非要做的话,可以参考写publish测试时的思想,不实际去触发第三方行为,只做前端这边的事,把结果转化成简单的变量并对比是否与我们预想中的值一样,一样视为通过。至少保证了前端入参出参正确,出错的话,甩锅也理直气壮。

还有一点就是,能纯函数测试就用纯函数测试,面向对象之类耦合程度和心智负担更重,会出现各种奇奇怪怪的测试手法(参考publish

完~