查看原文
其他

【第2214期】前端测试心法 + React 组件测试实践

阿伟 前端早读课 2021-03-03

前言

今日前端早读课文章由知乎@阿伟投稿分享。

正文从这开始~~

说来惭愧,从事前端开发多年,也开源过不少项目,然而却从未做过测试工作。内心抵触的原因大概是:

  • 我的代码不需要测试!(手动狗头

  • 测试用例写起来太麻烦了

  • 没想清楚前端的测试用例到底该测啥

  • 不清楚组件测试该怎么做(技术栈太复杂)

随着这些年打工搬砖的积累,以上问题已逐渐有了模糊的答案。再加上随后需要做一个正经的开源项目了,如果还是没有完备的测试实在是没有排面。于是就仔细思考了以上 4 个心理障碍并认真从零开始折腾了一段时间对 React 组件的单元测试。我果然没有让我失望,现在我觉得我学会了!而验证自己真的学会的最佳办法就是去把别人也教会,所以先从解决以上心理问题开始吧!

我的代码不需要测试

年轻的时候可以不讲武德,但现在已经是个成熟的打工人了,得学会自己写测试了。

去 GitHub 上看看,哪个流行的开源项目没有测试用例啊?几乎可以说有测试用例的项目不一定是好项目,但好的项目一定有测试用例。

当代码的复杂度达到了一定的级别,当维护者的数量不止你一个,你应该会逐渐察觉到你在合并 PR 的时候,会变得越发小心翼翼,即使代码看起来没什么问题,但你心里还是会犯嘀咕:这个 Feature 会不会带来其他 Bug ?这个 Fix 会不会引入其他 "Feature" ?

而这种时候,完备的测试用例就是你的定心丸了,无论啥变更,在合并之前先跑一通用例!它只要通过了就至少可以保证:不会再踩过去已经踩过的坑,现有的特性也不会遭到破坏。

所以,测试用例是一种对自己和对用户的安全感。

不过这里我想你会说:道理我都懂,可是我又不做开源,我也不造轮子,我每天面对的是层出不穷的业务需求,一天一改版,你告诉我我哪有时间去写测试?我要如何写测试?

胸嘚,我懂你!所以,你那种情况不写就不写了吧,我认真的。

不过即使对于逻辑不那么固定的前端项目,有一种情况个人建议还是得写测试,比如:「用户注册页」、「支付页」、「广告模块」等等这些听名字就很「吓人」的模块,我劝你还是写吧!写用例还是要比写 downtime 轻松一些的。

测试用例写起来太麻烦了

古话说得好:一想全是困难,一干都是方法。我最近特别喜欢这句话,很多事情都是这样,你开始做了,做习惯了,习惯写测试用例了,就一点也不麻烦了,就跟你现在即使不开启 Lint 工具,也可以人肉格式化代码了一样。

麻烦的其实是你永远都不开始,然后项目还在越做越大,逻辑越来越复杂,等到你意识到不写不行的时候再想写,那才是真滴麻烦!

没想清楚前端的测试用例到底该测啥

这条才是我要重点输出的部分!虽然我也是才刚开始写测试,但测试心法我想我早已掌握,现在决定悉数传授于你:

  • 测试用例要面向特性而不是面向实现

  • 你只需要测自己的一亩三分地,默认你依赖的都是安全的

  • 像写文档那样去写用例

好,我们再来一条一条说:

测试用例要面向特性而不是面向实现

应该有不少人存在这种误区:认为要测试和能测试的对象一般就是某某 utils、helper 之类的工具模块,但我觉得这是一种自欺欺人的做法。在下认为测试用例应当保障的永远是对外文档中你承诺给用户的所有特性!

举个例子:这里有一个点击就送屠龙刀的按钮,那你觉得对于这个按钮来说,它的测试用例该保障的核心是什么?很简单,就是:test 该按钮在被触发了点击事件后到底有没有送屠龙刀?

至于这个按钮是用 canvas 画的,还是 dom 拼的、事件绑定用的 addEventListener 还是 onClick、组件是不是通过你们项目约定的某个公共方法创建的等等这些和该按钮的核心能力有很大关系吗?

我们假设你真的只测了那个生产组件的公共方法,而那个按钮你认为是业务组件就没去管。然后某天某位同事心血来潮,通过那个公共方法给所有按钮增加了一个圣诞节彩蛋:点击后会出现雪花 Pop(本故事纯属虚构,如有雷同,纯属巧合),而万万没想到的是那个 Pop 会遮住你的屠龙刀领取超链!于是虽然那个公共方法的测试用例过了,但你的屠龙刀是送不出去了。

反过来说,又有一个同事真的只是想要优化一下生产组件的公共方法,比如他把事件绑定统一由 onClick 改成了 addEventListener,按说这是一个很好的优化,但他没想到的是你的屠龙刀按钮居然还测试了 onClick 是否有值,于是你也不敢改了。

上面这个例子多多少少有点不恰当,因为看起来那个公共方法的影响力也挺大的,所以它大概率也需要一份独立的测试用例来保全它对外输出的能力。但如果你连同公共方法一起打包对外只发布了一个屠龙刀按钮,对于按钮的可用性来说,那个公共方法的测试用例就是多余的!

你想啊,你之后可能还会迭代无数个版本,那个公共方法甚至哪天还会被你删掉,你爱用啥黑科技白科技去造按钮没人管你,你只要保证你发版的时候,那颗按钮依然可以送出屠龙刀就绝对 ok!

最后总结一波:写测试用例之前先确定宏观层面你想为调用方提供的服务是什么?而你提供的服务就恰恰是你测试的边界,不要试图越界去保障一些本不属于你的东西。

你只需要测自己的一亩三分地,默认你依赖的都是安全的

软件工程也是工程,工程有个重要特点就是整体是由很多个模块拼装构建而成。但对于你的工程而言,其中包含的诸多依赖其实你很难做到知根知底,用它就只能信他。但会有不少人其实是半信半疑,体现在测试用例里就是表面上在测自己程序的执行结果,但其实只在给依赖做测试。

举个简单的例子,你基于 loadsh 的 _.add 封装了一个两数先相乘再相加的小工具:

  1. function multiplyThenAdd(a, b) {

  2. return _.add(a*b, b)

  3. }

那你的测试用例会怎么写?这样吗:

  1. // 1*2 + 2 = 4

  2. expect(multiplyThenAdd(1, 2)).toBe(4);

乍一看好像没啥毛病,但细一琢磨,你这个不是分明在测:

当 _.add 的入参为 2 和 2 时,结果为 4 吗?

所以你的测试用例本质上还是在给 lodash 做测试,且不说人家这么大名鼎鼎的函数库已经有了更加完备的测试用例了,你做了好事但也没干正事啊!你自己那个小工具的核心能力有被覆盖到么:

  • 确保参数会相乘了吗?

  • 确保乘完会相加了吗?

所以一个相对完备的用例应该基于以上两条去写:

  1. const add = jest.spyOn(_, 'add') // 将 loadsh 的 add 方法 mock 掉


  2. multiplyThenAdd(1, 2)


  3. // 表面上只是在获取 add 的调用参数,实际上还确保了 multiplyThenAdd 被调用后一定会调用 add。

  4. // 因为没被调用的话又从何获取调用参数呢?

  5. const [arg1, arg2] = add.mock.calls[0]


  6. expect(arg1).toBe(1 * 2) // 确保参数相乘

  7. expect(arg2).toBe(2) // 确保参数相加

是的,确保相乘后的参数最终会被相加其实只需要确保传递给 add 的参数是对的就 ok 了!接下来的事情就请相信你的依赖吧。

当然了,上面的测试只是为了说清楚事情,实际也不太建议直接这样用 1、2 这种简单的固定数字去测,更推荐的做法是用随机数字去测。

然后我猜,对于这个例子你可能还会觉得那我最后就再测一下 add 的结果也不费啥事啊!你就是想偷那一行代码的懒吧?

没错!能偷的懒不偷那不傻么?要是每个测试用例都能这么偷一下子,那整个工程四舍五入就是血赚一个亿!玩笑归玩笑,下面我要再说另一个同类型的不得不偷懒的场景:

你写了一个基于 AntdModal 的弹窗组件,它预设了弹窗的一些属性:比如 title、width 等。

这种组件类型的测试用例成本可就高多了啊,即使现在有了 Enzyme 这种不需要基于真实浏览器的前端组件测试神器,但它也分了 shallow render 和 full render 。人家之所以不干脆直接只支持 full render 还在文档里把 shallow render 提得那么前,就是想告诉你没事能 shallow 就别 full!你随手的一个 full 可能就让整体的测试时间加倍了,成本依然高昂。

所以还是拿你那个弹窗组件来说,你确定真的需要在弹窗弹出来后,再通过 dom api 看看 title 对不对、width 对不对吗?因为这难道不是 Antd 该保障的能力么?那正确又省事的办法其实就是直接:

  1. const wrapper = shallow(<YourModal />)


  2. expect(wrapper.props()).matchObject({

  3. title: 'expected title',

  4. width: 666 // expected width

  5. })

是的!和第一个例子的偷懒方式一个路子:对于依赖的函数你的用例只需要确保入参是正确的;对于依赖的组件你只需要确保传入的 props 是正确的。

举一反四,类似的还有 AntdModal.method() 这种看起来又像是组件又像是函数的依赖,不要因为它穿上了 UI 的马甲就不认得它了:

我们假设你写了一个按钮,点击会调用 Modal.confirm 出现 「确认删除吗?」 的提示,点击「确认按钮」后会去执行 remove 方法。

你可以先停下来想一想,你的测试用例是否真的要走:点一下那个按钮,然后看看有没有出现正确提示弹窗,最后再点一下那个弹窗按钮看看是否真的执行了删除方法这么一段冗长的 UI 测试流程吗?

好了,聪明的你随便思考了一下马上就知道了不需要,所以可以直接这样:

  1. import { Modal } from 'antd'


  2. const remove = jest.fn()

  3. const wrapper = shallow(<YourButton remove={remove} />) // 是的,还是不需要 full render


  4. const onClick = wrapper.prop('onClick') // 直接通过 prop 拿到 click 方法


  5. const confirm = jest.spyOn(Modal, 'confirm') // 将 AntdModal 的 confirm 方法 mock 掉


  6. onClick() // 直接调用 onClick 就相当于在点击按钮了


  7. const { content, onOk } = confirm.mock.calls[0] // 直接取 confirm 被调用的入参


  8. expect(content).toBe('确认删除吗?') // 测测提示信息对不对


  9. await onOk() // 直接调用 onOk 就相当于在点击弹窗的「确定按钮」了


  10. expect(remove).tobeCalled() // 测测点击「确定按钮」后 remove 方法有没有被调用

好了!这部分内容也要最后再唠叨一句:测试尽量别依赖 UI 行为、大多数时候确保相应的 UI 触发函数被调用过即可,UI 的变更请信任你依赖的组件会保障。

再插个题外话,如果你的项目也变得足够流行了,你可以想办法推动你依赖的重要项目跟你做联合测试,这样他们再升级的时候你就不怕有大的影响。

像写文档那样去写用例

按照上文,我们的用例拆分首先应当立足于你对外承诺的特性,然后可以再基于你的人生观、世界观、消费观等各种观去组织它们。

这个过程仔细想想是不是和你写文档的套路特别类似?你对外的文档是怎么罗列特性的,你的测试用例基本就是怎么 test 的;你的文档是怎么组织特性的(分章/分节),你的测试用例基本就是怎么 describe 的(当然,除了这些,一些项目运行阶段被提出的重要 issue 也应当被测试起来)。

所以很多时候编写一份完善的测试用例并不是一个 test 一个 test 依次写满的,而是可以和写文章一样,先用 describe 和 test 去列好大纲,最后再去填充内容。当然了,如果已经文档先行了,那就更好办了,抄之!

比如,如果我来参考 AntdForm 的文档去编写一份测试用例,我大概会这么写:

  1. describe('Form', () => {

  2. describe('Props', () => {

  3. test('colon 会控制 label 后面的冒号是否显示', () => {})

  4. test('component 设置 Form 渲染元素,为 false 则不创建 DOM 节点', () => {})

  5. // ...

  6. })


  7. describe('Instance', () => {

  8. test('getFieldError 可获取对应字段名的错误信息', () => {})

  9. test('getFieldInstance 获取对应字段实例', () => {})

  10. // ...

  11. })

  12. })


  13. describe('Form.Item', () => {

  14. describe('Props', () => {

  15. // ...

  16. })

  17. })


  18. // ...

不清楚组件测试该怎么做(技术栈太复杂)

都看到这里了,给孩子赏个赞吧,赏个赞吧~~~

目前我还只做过 React 项目的测试,所以就以它为例分享一些技术实践。

首先要解决的是测试环境的搭建,这里我推荐使用的测试框架是 Jest。然后在现代的前端工程里,你可以写 TypeScript、你可以写 jsx、你也可以 import 各种静态资源,而这些东西都需要经过编译后才可以最终被浏览器执行。

当然了,测试框架也可以搭配 puppeteer 跑在浏览器中,这种叫做 e2e 测试,可以翻译成“端到端”测试。它模仿用户,从某个入口开始,逐步执行操作,直到完成某项工作。不过这种我还没试过,因为觉得它一定比较麻烦且写起来费事测起来费时。

所以我更倾向于搭配 Enzyme 直接在 node 环境下跑的单元测试。我喜欢它的简单优雅,可以做许多上文的「不 render」测试,写起来爽测起来快。不过凡事没有完美,在 node 环境下跑测试也会面临类似浏览器无法直接跑工程源码的困境,你同样得搭一份类似 webpack 开发环境那样的测试环境。

编译环境
ts(x) 支持

直接用 ts-jest 吧!点进去看看它的文档,尤其是 presets 部分。

另外,对于 ts 项目本身一般都会有 tsconfig 配置文件,但还是推荐你再创建一个 tsconfig 专用于执行测试期间的 ts 支持,可通过 global['ts-jest'].tsconfig 来配置:

  1. // jest.config.js

  2. module.exports = {

  3. // [...]

  4. globals: {

  5. 'ts-jest': {

  6. tsconfig: 'tsconfig.test.json',

  7. },

  8. },

  9. }

import 各类样式文件

推荐直接使用:identity-obj-proxy。

  1. // jest.config.js

  2. module.exports = {

  3. // [...]

  4. moduleNameMapper: {

  5. '\\.(css|less)$': 'identity-obj-proxy'

  6. },

  7. }

Cannot use import statement outside a module 问题

看错误提示你应该能明白,这是因为你在让 jest 直接执行包含 es6 模块语法的代码,比如各种 import。所以除了 ts(x),你还得为 js(x) 考虑一下。这里我们可以直接用 babel 搞定它:

  • 创建 babel.config

  • 修改 jest.config:

  1. // jest.config.js

  2. module.exports = {

  3. // [...]

  4. transform: {

  5. // 使包括 setup.js 在内的所有 js 文件都走 babel,且指定 babel 配置(默认在根目录)

  6. '^.+\\.(js|jsx)$': ['babel-jest', { configFile: 'babel.config.js 所在路径' }],

  7. },

  8. }

或者如果你用了 ts-jest,并且 babel.config.js 在根目录,可以直接:

  1. // jest.config.js

  2. module.exports = {

  3. // [...]

  4. preset: "ts-jest/presets/js-with-babel"

  5. }

如何把所有测试相关的配置都集中在一个目录?

配置着配置着,你会发现根目录的文件越来越多了,有些还可能和项目本身的文件冲突,一点也不清真。所以你准备在根目录创建一个叫做 test 的文件夹了,把所有测试相关的配置都集中到里面去。

先移过去吧,然后在执行 jest 的时候手动指定一下 jest.config 的路径:

  1. jest -c ./test/jest.config.js

先执行一下,看看错误提示都哪些文件找不到了你就去把配置中对应的错误路径都修复一下。不过有个比较麻烦的情况,比如你的测试用例本身是用 ts 编写的,然后使用了类似下面的短路径 import:

  1. import utils from '@/test/utils'

那你就摊上事了,当时怎么改都改不好,最后在这条 issue 评论 里找到的灵感,上关键配置:

  1. // /test/tsconfig.json

  2. {

  3. "compilerOptions": {

  4. "baseUrl": "../",

  5. "paths": {

  6. "@/*": ["*"]

  7. }

  8. }

  9. }

  1. const { pathsToModuleNameMapper } = require('ts-jest/utils');

  2. const { compilerOptions } = require('./tsconfig.json');


  3. // /test/jest.config.js

  4. module.exports = {

  5. // [...]

  6. modulePaths: ['../'], // 是的!它就是关键配置!

  7. moduleNameMapper: {

  8. ...pathsToModuleNameMapper(compilerOptions.paths), // 它也是关键配置,但不是解决问题的关键

  9. },

  10. }

如何不排除某些 node_modules 模块?

默认情况下,jest 会直接执行 node_modules 模块,但极少情况跑不起来的时候那就说明某些 node 模块对外的导出包没有经过编译/编译的不够彻底。所以需要告诉 jest 不要忽略它们,它们也需要走编译。该需求可通过 transformIgnorePatterns 来实现。

比如如果你的项目依赖了 antd,那你大概率需要这个!尤其是你在控制台已经看到了该 issue 描述的类似错误时,可直接 copy 如下配置:

  1. // jest.config.js

  2. module.exports = {

  3. // [...]

  4. transformIgnorePatterns: ['/node_modules/(?!antd|@ant-design|rc-.+?|@babel/runtime).+(js|jsx)$']

  5. }

执行环境
jsdom

上面的编译环境只是让代码能够跑起来,但要记得你要跑的是前端代码,原本它们是应该执行在浏览器环境下的。你的某些代码可能会依赖:Dom、Bom 上的各类方法或属性,这些在 node 环境下显然是没有的。怎么办?感谢前人们种的树吧!

有个叫做 jsdom 的伟大项目,它模拟了浏览器环境,所以你要测试的代码中包含的诸如:docuemnt.getElementById、window.alert 等代码才可以被正常执行。

幸运的是,jest 也意识到了这一点,它直接内置了 jsdom,且默认的 testEnvironment 就是 jsdom。所以你基本不需要再做任何关于 jsdom 配置之类的事情了。不过凡事总有例外,比如你测试的时候发现某些 window 上的方法不存在或表现的不符合预期,你可以重新定制它们,像这样:

  1. // jest.config.js

  2. module.exports = {

  3. // [...]

  4. setupFiles: ['./setup.jsdom.js'], // 会在测试代码被执行前执行

  5. }

  1. // setup.jsdom.js

  2. // 给 window 增加一个 matchMedia 方法


  3. Object.defineProperty(window, 'matchMedia', {

  4. value: jest.fn((query) => ({

  5. matches: query.includes('max-width'),

  6. addListener: jest.fn(),

  7. removeListener: jest.fn(),

  8. })),

  9. });

Enzyme React Adapter

ok!终于现在距离你使用 enzyme 去 mount 一个 React 组件还差最后一步了!那就是配置你测试项目依赖的 React 版本的 Adapter!这部分就直接看文档吧:

Introduction · Enzyme:https://enzymejs.github.io/enzyme/#

An update to UsernameForm inside a test was not wrapped in act(...)

你是否开始被该错误警告折磨地乌心烦躁了呢?该错误简单来说是因为在测试 React 组件的过程中发生了一些异步数据操作,而那些操作带来的 UI 副作用并没有被 act 函数包裹。 更详细的解释我找到了一篇觉得写的特别好的文章,可以先看看:

https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning

不过在用 act 包裹异步副作用后一般还需要配合一次 update:

  1. import { mount } from 'enzyme';

  2. import { act } from 'react-dom/test-utils';


  3. describe('xx', () => {

  4. test('xx', async () => {

  5. const wrapper = mount(<Page />)

  6. await act(() => {

  7. // 比如在这里点击了某个按钮,随后会触发一个弹窗

  8. // 再比如调用了表单提交,随后会触发表单校验提示等等类似做了 A 但随后会引起 B 的异步操作

  9. })

  10. wrapper.update()

  11. // 在这之后一般你才能够获取到很多异步操作后的 Dom 结果

  12. })

  13. })

除了 act + update,更有些时候你还需要配合 delay 来等待一个 tickTime > 0 的异步操作,所以你可以考虑封装一个叫做 actAndUpdate 的工具函数了:

  1. function delay(ms) {

  2. return new Promise(resolve => setTimeout(resolve, ms))

  3. }


  4. function makeActAndUpdate(wrapper) {

  5. return (work, updateDelay) => act(async () => {

  6. await work()

  7. if (updateDelay) await delay(updateDelay)

  8. wrapper.update()

  9. })

  10. }


  11. describe('xx', () => {

  12. test('xx', async () => {

  13. const wrapper = mount(<Page />)

  14. const actAndUpdate = makeActAndUpdate(wrapper)

  15. await actAndUpdate(() => {

  16. // ...

  17. })

  18. })

  19. })

关于本文 作者:@阿伟 原文:https://zhuanlan.zhihu.com/p/352143497

为你推荐


【第2186期】使用浏览器开发工具测试网站可访问性的七种方法


【第2213期】原子设计:如何设计组件体系


欢迎自荐投稿,前端早读课等你来

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存