blog icon indicating copy to clipboard operation
blog copied to clipboard

【bigo】React 单元测试实践

Open lazy-b opened this issue 3 years ago • 0 comments

React单元测试实践

单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。单元测试是由程序员自己来完成,最终受益的也是程序员自己。执行单元测试,就是为了尽量证明这段代码的行为和期望的一致。

其实我们每天都在做单元测试,包括那些认为自己从来没有写过单元测试的同事。你写了一个函数,log一下或者在界面上点一下,这,也是单元测试,把这种单元测试称为临时单元测试。临时单元测试的软件,一个是很难能够覆盖所有场景,二个是无法自动化运行,大幅度提高后期测试和维护成本。可以说,进行充分的单元测试,是提高软件质量,降低开发成本的必由之路。

下面讲一下我个人的 React 单元测试实践,旨在抛砖引玉。以下谈论到的优缺点以及实践方法等,均带有个人主观色彩,如有不同意见,望不吝赐教~

单元测试的优点

能够用更低的成本去验证代码的稳定性,基本保证目标代码在之后一直按照初始的预期运行,同时可以接入持续集成,进行低成本的重复使用,在第一时间发现问题,减少维护成本。

  // 验证 keepInTarget 辅助方法是否能够永远保证返回的值为 adns 和 targets 的交集
  // 如果在界面上测试这个功能,进行一次操作的时间就已经够写出下面的测试用例了
  it.each([
    // eslint-disable-next-line no-sparse-arrays
    [, [], undefined],
    [[], [], []],
    [['abc'], [], []],
    [[], ['abc'], []],
    [['abc'], ['abc'], ['abc']],
    [['abc', 'bcd'], ['abc', 'cde'], ['abc']],
  ])('keepInTarget %#', (adns, targets, expectValue) => {
    expect(keepInTarget(adns as any, targets)).toEqual(expectValue);
  });

能够帮助开发者从另一个角度去思考如何组织代码,让代码结构更合理——你们有没有见过1000多行的类,方法里面全局状态内部属性随便使用,三个屏都看不完一个方法的那种。 主要体现在:能够促进思考方法、函数的边界,而不是不管三七二十一都放在一个方法里面;能够下意识的去写纯函数;

能够让你的同事觉得你有点东西🐶、

什么时候开始写单元测试

建议大家现在就开始尝试写单元测试。单元测试应该是对代码无侵入的,有没有单元测试都不影响你的代码功能实现。也就是说你完全可以有多少时间写多少测试,有写一个测试的时间就添加一个测试,没有时间就不写。

理由就是上面的第二点,只要你计划写单元测试,即使最后由于各种原因并没有写单元测试,你的代码也会和以前不同。相信我,我读书多不会骗你的🐶、

明确测试范围

在开始写单元测试之前,首先需要明确当前的测试是需要测试什么?

以组件为例,一般一个组件都会包含:ui呈现、工具方法、内部逻辑处理、第三方依赖、自己写的子组件。那么这么多东西哪些需要测试,哪些不需要测试,你在写测试之前就需要想好。

建议按照这样一个优先级顺序添加测试:关键逻辑代码、复杂逻辑代码、工具方法、其他代码。根据自己的时间,逐步提高测试覆盖率。

在实际操作上可以尝试在自测的环节,将盲目的界面自测操作,转变为单元测试代码,这样甚至可以用更少的时间得到更好的自测效果。

框架选型

测试组件库有很多,这里选用了目前最流行的: jest + enzyme (部分示例使用了 @testing-library/*),同时为了测试 hook 还使用了@testing-library/react-hooks

jest 作为一款测试框架,拥有测试框架该有的一套体系,丰富的断言库,大多数api与老牌的测试框架如jasmine、mocha,譬如常用的expect、test(it)、toBe等,都非常好用。内部也是使用了jasmine作为基础,在其上封装。但是因为Snapshot这个特色功能,非常适合react项目的测试。

enzyme 提供了几种方式将react组件渲染成真实的dom,提供了类似jquery的api来获取dom;提供了simulate函数来模拟事件触发;提供接口让我们获取到组件的state和props并且能对其进行操作。enzyme其实是react-test-renderer的封装,react-test-renderer的api非常不友好,但是enzyme开发的api跟jquery一致

@testing-library/react-hooks是一个专门用来测试React hook的库。我们知道虽然hook是一个函数,可是我们却不能用测试普通函数的方法来测试它们,因为它们的实际运行会涉及到很多React运行时(runtime)的东西,因此很多人为了测试自己的hook会编写一些TestComponent来运行它们,这种方法十分不方便而且很难覆盖到所有的情景。

关于使用的框架建议:

  1. 如果只是想测试一下功能函数,引入 jest 及其相关依赖即可
  2. 如果还想对 react 组件进行测试,添加引入 enzyme 及其相关依赖
  3. 如果还想对 hook 进行测试,添加引入 @testing-library/react-hooks 及其相关依赖
  4. 建议先都添加上,毕竟不知道什么时候就用的上了,到时候可不一定有时间去调整配置参数

参考 create-react-app 进行配置

常用操作

调试测试代码

jest 调试的介绍:https://jestjs.io/docs/troubleshooting

测试代码也是代码,经常出现测试代码有误导致报错,一次次进行 log 输出效率低下,排查起来问题也是十分考验想象力,所以学习一下测试代码的调试也是很有必要的。 调试测试代码的原理就是利用 node 执行时 传入 --inspect-brk 参数进行调试,因为测试代码的运行是基于 node 环境的,所以就有如下调试方式:在运行测试命令时直接添加 --inspect-brk 参数结合 chrome 浏览器、利用编辑器的调试界面。

直接使用 --inspect-brk 参数运行测试脚本

在项目根目录运行命令(mac) node --inspect-brk node_modules/.bin/jest --runInBand ,然后打开 chrome 浏览器,地址栏输入 chrome://inspect 点击 Open Dedicated DevTools for Node ,这时会自动打开一个调试面板,里面会在文件的最开始自动断点。

Note: the --runInBand cli option makes sure Jest runs the test in the same process rather than spawning processes for individual tests. Normally Jest parallelizes test runs across processes but it is hard to debug many processes at the same time.

# --inspect-brk 打开node调试模式
# --runInBand
# node --inspect-brk node_modules/.bin/jest --runInBand [any other arguments here]
# or on Windows
# node --inspect-brk ./node_modules/jest/bin/jest.js --runInBand [any other arguments here]

使用编辑器的调试界面(推荐)

这里以 vscode 的操作步骤举例(下面的操作按钮均为默认窗口界面位置,如果自己以前自定义过请按自己自定义之后的实际菜单操作)

  1. 点击 vscode 侧边菜单栏中的【运行和调试】(又有个小虫子图标的菜单)
  2. 在出现的侧边小窗口最上方点击添加配置
  3. 此时自动打开一个文件,文件名为 launch.json ,里面可能已经有了配置项,也可能没有
  4. 在 configurations 中添加上调试 jest 相关的配置,配置参考 jest 官方文档Debugging in VS Code
  5. 配置完成后在调试窗口最上方选择刚刚的配置(一般是 Debug Jest Tests),然后点击配置前面的运行按钮(一个三角符号)
// 一个当前可用的调试配置
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug Jest Tests",
      "type": "node",
      "request": "launch",
      "runtimeArgs": [
        "--inspect-brk",
        "${workspaceRoot}/node_modules/.bin/jest",
        "--runInBand"
      ],
      "console": "integratedTerminal",
      "internalConsoleOptions": "neverOpen",
      "port": 9229
    }
  ]
}

记录快照

如果你的项目中还没有任何测试用例,那么使用快照测试将能最快的实现一个基本保障。

某圣诞节彩蛋事件,如果项目有写快照测试可能就不会有那么多背锅的打工人了。

快照测试的优缺点:

  • 优点
    • 投入小:一个快照测试往往只需要 2 行测试代码就可以搞定,而且也不用考虑太多逻辑
    • 回报高:快照测试对代码的覆盖度极高,设计合适的快照,能够覆盖被测试组件的所有 ui 变化;能够监听到指定ui的任何细微的变动
  • 缺点
    • 不完全可靠:快照测试只是保证了渲染出来的结构没有改变,并不能很好的记录到逻辑的改变
      • 过分依赖快照测试可能会导致忽略掉一些逻辑错误
      • 建议对关键逻辑添加针对性的普通测试
    • 失败频繁:任何导致 ui 发生改变的代码,都会导致快照测试失败,然后需要手动去更新快照
      • 容易产生狼来了效应,从而主观上忽略失败,导致失去测试的意义
      • 基于此种原因,甚至有团队禁止写快照测试
      • 建议快照测试也需要划定被测试范围,一次包含太多组件的快照测试,更容易出现失败的结果

我认为快照测试是利大于弊的,建议对基础场景进行快照记录,然后针对关键逻辑写普通测试

  • 快照测试的性价比很高,花费的时间几乎可以忽略不计,但是能够给项目添加一个最基础的保障
  • 即使是写了很多逻辑测试,也还是需要快照测试来做补充,毕竟不可能针对所有 ui 写测试用例,那样成本太高了
  • 初期并没有很多测试用例,一次失败的用例不会太多,不至于直接忽略
  • 开始写测试不熟练,其他的逻辑测试可能需要耗费大量的时间,而快照测试则可以很快写出来

强烈建议使用 shallow 浅渲染记录快照,因为全量渲染的快照体积太大了
举一个实际例子:有一个有 41 个 antd 表单项的表单,记录了 12 个全量快照之后,快照记录文件大小达到了 13MB,也就是说一个快照大小超过了 1MB 而改成使用 enzyme 的 shallow 渲染之后的快照大小仅为 522 KB ,平均一个快照 40 KB,大大缩小了体积

记录快照方法:

  1. 使用 @testing-library/react 中的 render 方法,此方法会深度遍历依赖,渲染得到完整的 dom 结构 —— 如果没有针对指定组件进行 mock 的话。
    1. 由于是深度遍历依赖,所以可能会出现很多奇怪的报错,一般对报错的地方进行mock或者根据提示解决问题就行。因为有些组件有全局依赖属性,测试的时候没有就会报错。
    2. 一般来说如果报错部分其实和测试无关,可以直接 mock 掉,如果比较重要就建议补全对应依赖。
    3. 此方法依赖一个”类浏览器“环境,一般是使用 jsdom 模拟。
    4. 由于已经完全渲染到了“类浏览器”环境中,所以测试用例之间能够互相影响,建议每个测试用例执行完毕之后都清理掉
      1. 高版本已经默认会自动清理
// 引入 react
import React from 'react';
// 引入测试方法
import { render } from '@testing-library/react';
// 被测试组件
import App from 'pages/Media/App';

// 由于被测试组件中使用了 Link 组件 所以必须要用 Router 组件包裹
// 如果不想引入 Router 组件,则可以 mock 一下 Link 组件为 div 或者 a 标签啥的
import { MemoryRouter } from 'react-router-dom';

describe('snapshot', () => {
  it('library render', () => {
    // 渲染
    const { container } = render(
      <MemoryRouter>
        <App />
      </MemoryRouter>
    );
    // 快照记录
    expect(container).toMatchSnapshot();
  });
});
  1. 使用 enzyme 中的 shallow \ mount \ render 三种方法中的一种,这三种方法渲染模式均不相同
    1. 三者的说明「技术雷达」之使用 Enzyme 测试 React(Native)组件
    2. shallow:
      1. 浅渲染,只会渲染出被测试组件的结构,子组件以及依赖组件不会被渲染。
      2. 子组件不会影响到测试组件
      3. 可以使用 simulate 方法模拟交互
      4. 此方法最快
    3. mount
      1. 全量渲染,也需要”类浏览器“环境才能运行。
      2. 可以使用 simulate 方法模拟交互
      3. 每个测试用例执行完建议清理掉,因为已经实际渲染到环境中了
    4. render
      1. 全量渲染出静态的HTML字符串,但是不涉及到实际的逻辑操作
// shallow 
import { shallow } from 'enzyme'

describe('Enzyme Shallow', () => {
  it('App should have three <Todo /> components', () => {
    const app = shallow(<App />)
    expect(app.find('Todo')).to.have.length(3)
  })
}


// mount
import { mount } from 'enzyme'

describe('Enzyme Mount', () => {
  it('should delete Todo when click button', () => {
    const app = mount(<App />)
    const todoLength = app.find('li').length
    app.find('button.delete').at(0).simulate('click')
    expect(app.find('li').length).to.equal(todoLength - 1)
  })
})


// render
import { render } from 'enzyme'

describe('Enzyme Render', () => {
  it('Todo item should not have todo-done class', () => {
    const app = render(<App />)
    expect(app.find('.todo-done').length).to.equal(0)
    expect(app.contains(<div className="todo" />)).to.equal(true)
  })
})

触发事件

有时候需要测试页面的响应操作正不正常,这个时候就需要触发事件了,触发事件的方法为 fireEvent 或者 simulate

有时候触发第三方组件的事件比较麻烦,不清楚具体该怎么触发,此时可以找到第三方组件的源码,找到他们的测试用例,看看他们自己是怎么触发的。

// 需要触发 antd 的 Search 组件的 enter 事件,不清楚应该怎么触发
// 找到 antd 的测试用例,发现是直接找到那个input,然后触发 keydown 事件,对应 keyCode 为 13
  // it('should trigger onSearch when press enter', () => {
  //   const onSearch = jest.fn();
  //   const wrapper = mount(<Search defaultValue="search text" onSearch={onSearch} />);
  //   wrapper.find('input').simulate('keydown', { key: 'Enter', keyCode: 13 });
  //   expect(onSearch).toHaveBeenCalledTimes(1);
  //   expect(onSearch).toHaveBeenCalledWith(
  //     'search text',
  //     expect.objectContaining({
  //       type: 'keydown',
  //       preventDefault: expect.any(Function),
  //     }),
  //   );
  // });

  // 自己改写成 @testing-library/react 框架版本的测试用例
  // 搜索输入框点击 enter 键
  const searcher = container.querySelector('.ant-input-search.table-head-search .ant-input')!;
  fireEvent.keyDown(searcher, { key: 'Enter', code: 'Enter', keyCode: 13 });

常用的 mock 方式

mock 一个依赖库中的某一个方法

场景:有个依赖库,需要 mock 这个库提供的一个方法,但是又担心只mock这个方法导致其他地方报错,这个时候就可以选择保留其他方法的同时mock这一个方法 jest.requireActual(moduleName)

// 只 mock 了 ahooks 提供的 useAntdTable 调用后返回的 run 方法
jest.mock('ahooks', () => ({
  ...jest.requireActual('ahooks'),
  useAntdTable: (...args) => {
    const res = jest.requireActual('ahooks').useAntdTable(...args);
    return { ...res, run: jest.fn() };
  },
}));

mock 注意事项

  1. jest.mock 会被提升到文件的最顶部,也就是会在 import 导入依赖之前运行
    1. 所以 import A from xxx 然后在 jest.mock 回调中使用 A 是不可以的 out-of-scope variables
    2. 上面问题的一个解决办法为上面问题中有人回答的: mockImplementation
    3. 还有一个办法为前面场景问题中列出的,使用 requireActual
  2. 同理,如果在 jest.mock 需要引用外部变量等是不被允许的
    1. 会出现报错:Note: This is a precaution to guard against uninitialized mock variables. If it is ensured that the mock is required lazily, variable names prefixed with mock (case insensitive) are permitted.
    2. 根据报错提示,需要以 mock (不区分大小写) 作为前缀。
    3. 而且由于这个变量需要延迟使用,所以在 jest.mock 回调中直接使用会报 “变量未定义“ 的错,需要在一个方法中使用
// 必须使用 mock 开头,告诉 jest 这是一个 mock 变量,需要延迟使用
let mocksubmit;

jest.mock('ahooks', () => {
  const origin = { ...jest.requireActual('ahooks') };

  // 如果写在此处,则会报 mocksubmit 未定义
  // 因为 mocksubmit 使用了 mock 前缀,被标注为延迟使用
  // 所以此时的函数运行环境中还未运行上面的 let mocksubmit 语句
  // const spy = { submit: jest.fn() };
  // // 注意赋值的时机, mock 开头的变量
  // mocksubmit = jest.spyOn(spy, 'submit');

  return {
    ...origin,
    useAntdTable: (...args) => {
      const spy = { submit: jest.fn() };
      // 此处成功赋值,因为此处运行的时机为 useAntdTable 调用的时刻
      // 也就是 App 渲染之后,此时 let mocksubmit 已经运行了
      mocksubmit = jest.spyOn(spy, 'submit');
      const o = origin.useAntdTable(...args);
      o.search = {
        ...origin.search,
        submit: jest.fn(mocksubmit),
      };
      return o;
    },
  };
});

all jest.mock will be hoisted to the top of actual code block at compile time, which in this case is the top of the file.

antd 3.X 版本的 form 属性 mock

3.x 版本的 antd ,经过 Form.create 包装的组件将会自带 this.props.form 属性,但是这样就给我们的测试带来了困难。 如果我们使用 enzyme 的 mount 方法进行渲染,而且只是进行逻辑的测试,那么问题不大。 因为我们可以直接拿到包装后的组件进行全量渲染,基本可以满足测试要求。 但是如果使用这样的渲染结果来记录快照是不太行的,输出的快照体积太大了,此时就需要使用 shallow 来进行渲染了。 然而使用 shallow 进行渲染并获取快照,就出现问题了,如果用包装后的组件来进行渲染,快照拿不到表单项的渲染结果,这样的快照是没有什么用的。 此时就会想到,能不能直接 shallow 渲染原组件,然鹅这样会收获一个报错 TypeError: Cannot destructure property getFieldDecorator of 'undefined' or 'null'. , 因为你的组件拿不到 form 属性,太难了。。。

// 直接 shallow 包装后的组件,只能拿到一个 form,form-item 没有被渲染出来,这样的快照基本没什么用
<SlotOperation
  form={
    Object {
      "getFieldDecorator": [Function],
      "getFieldError": [Function],
      "getFieldInstance": [Function],
      "getFieldProps": [Function],
      "getFieldValue": [Function],
      "getFieldsError": [Function],
      "getFieldsValue": [Function],
      "isFieldTouched": [Function],
      "isFieldValidating": [Function],
      "isFieldsTouched": [Function],
      "isFieldsValidating": [Function],
      "isSubmitting": [Function],
      "resetFields": [Function],
      "setFields": [Function],
      "setFieldsInitialValue": [Function],
      "setFieldsValue": [Function],
      "submit": [Function],
      "validateFields": [Function],
      "validateFieldsAndScroll": [Function],
    }
  }
/>

// 如果尝试直接 shallow 渲染原组件,就会收获一个报错
// TypeError: Cannot destructure property `getFieldDecorator` of 'undefined' or 'null'.

//       152 |   public render() {
//       153 |     const { permission, form, sspType } = this.props;
//     > 154 |     const { getFieldDecorator, getFieldsValue } = form;
//           |                                                   ^
//       155 |     const formData = getFieldsValue();
//       156 |     const {


此时坑已经挖了,我当然要负责任的填上,这里提供一种 hack 方法(不确定有没有更好的办法,如果有人知道的话请联系我)。
我们首先捋一下,我们现在的核心需求是:记录快照的时候需要把表单项记录下来,而且快照大小不能太大。 也就是说要求:首先必须使用 shallow 进行渲染;然后使用 shallow 就要求只能直接渲染原表单组件。 那么需要解决的问题就是:如果 mock 表单组件需要的 form 属性?
这里提供的思路就是:使用 Form.create 包装一个mock出来的组件,然后渲染这个包装后的组件,在渲染的过程中将拿到的 form 赋值给一个外界的变量, 然后这个变量就可以给我们需要测试的组件用了。

  // ....

  // 渲染 fake 组件拿到 form
  // 使用方法,每次都返回不同的 form,防止不同的测试用例之间干扰
  export function getForm() {
    let form: WrappedFormUtils<any>;
    const Fake = Form.create()((props: any) => {
      form = props.form;
      return <div />;
    });
    const container = document.createElement('div');
    ReactDOM.render(<Fake />, container);
    ReactDOM.unmountComponentAtNode(container);

    // @ts-ignore
    return form;
  }

  // ....

  // 将上面拿到的 form 传给被测试组件,此时就不会再报错了,渲染结果也是正常的
 const shallowRes = shallow(<Test form={getForm()} />);

 expect(toJson(shallowRes)).toMatchSnapshot({}, '我记录了表单项的快照');

一些单元测试示例代码

测试工具函数、常量

// pattern.ts 通用的正则表达式
// 前后不允许空格
export const NO_BLANK_AROUND = /^(?!\s)(?!.*\s$)/;
export const genWordLimits = (min = 5, max = 37) => new RegExp(`^.{${min},${max}}$`);

// pattern.spec.ts 测试正则表达式是否正确
import { NO_BLANK_AROUND, genFixed, genWordLimits, genIntLimits } from '../pattern';
describe('测试正则', () => {
  it('NO_BLANK_AROUND', () => {
    expect(NO_BLANK_AROUND.test('')).toBe(true);
    // eslint-disable-next-line max-len
    // https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test#firefox%E7%89%B9%E6%AE%8A%E6%B3%A8%E6%84%8F
    // 在 Firefox 8之前, test() 被不正确地实现了;
    // 当无参数地调用时,它会匹配之前的输入值 (RegExp.input 属性),而不是字符串"undefined"。
    // 这已经被修正了;现在 /undefined/.test() 正确地返回true,而不是错误。
    // 也就是说,正常情况下:test() 等价于 test('undefined')
    expect(NO_BLANK_AROUND.test('undefined')).toBe(true);
    expect(NO_BLANK_AROUND.test('abc')).toBe(true);
    expect(NO_BLANK_AROUND.test(' abcde')).toBe(false);
    expect(NO_BLANK_AROUND.test('ab ')).toBe(false);
    expect(NO_BLANK_AROUND.test(' abcdef ')).toBe(false);
  });
  it('genWordLimits', () => {
    const limits = genWordLimits(3, 5);
    expect(limits.test('')).toBe(false);
    expect(limits.test('abc')).toBe(true);
    expect(limits.test('abcde')).toBe(true);
    expect(limits.test('ab')).toBe(false);
    expect(limits.test('abcdef')).toBe(false);
    // @ts-ignore
    expect(limits.test()).toBe(false);
  });
});

快照测试

// index.tsx ListCtr 表单组件
...

// index.spec.tsx 测试 ListCtr 表单组件
import React from 'react';
import { shallow } from 'enzyme';
// @ts-ignore
import { getForm } from 'src/__tests__/utils/form';
import { ListCtrl } from '../index';
import toJson from 'enzyme-to-json';

describe('snapshot', () => {
  it('shallow render', () => {
    const shallowRes = shallow(<ListCtrl form={getForm()} />);
    expect(toJson(shallowRes)).toMatchSnapshot({}, 'base');
  });
});

测试界面ui

import React from 'react';
import { render, shallow, mount } from 'enzyme';
import { SlotOperation } from '../SlotOperation';
import { Checkbox, Form } from 'antd';
import { genTabel } from '__tests__/utils/common';
import { baseComponentProps, app } from '__tests__/pages/Media/App/slotOperation.mock';
import { BrowserRouter } from 'react-router-dom';

// ... 一些 mock 操作

// 检查页面渲染的项对不对
describe('items check', () => {
  // sspType 1 create
  const base = { ...baseComponentProps };
  const RenderForm = Form.create()(SlotOperation);
  const mountRes = mount(
    <BrowserRouter>
      <RenderForm {...(base as any)} />
    </BrowserRouter>
  );

  const target = mountRes.find(SlotOperation);
  target.setState({ adTypes: {}, app, action: 'create' });

  describe('sspType 1', () => {
    // 基础设置
    it.each(
      genTabel(
        [
          '广告位名称',
          // 'systemSource',
          ['location_testkey', false],
          '广告位类型',
          ['模板广告', false],
          ['模板广告id', false],
          ['横幅尺寸', false],
          ['Topview样式关联广告位', false],
          ['banner自动刷新', false],
          ['间隔时长', false],
          ['是否允许激励视频中途退出', false],
          ['视频自动重播', false],
          ['视频方向', false],
          ['最佳分辨率', false],
          ['视频广告声音', false],
          ['插屏倒计时时长', false],
        ],
        true
      )
    )('%s sholud exists %s', (label, exists) => {
      expect(mountRes.find(`FormItem[label="${label}"]`).exists()).toBe(exists);
    });
    it('SlotFrequency should exists true', () => {
      expect(mountRes.find(`SlotFrequency`).exists()).toBe(true);
    });
    it('流量来自美国旧系统 should exists true', () => {
      expect(mountRes.containsMatchingElement(<Checkbox>流量来自美国旧系统</Checkbox>)).toBe(true);
    });
  });
});

测试组件逻辑

// TplConfig.tsx  TplConfig 组件
...

// TplConfig.spec.tsx 测试 TplConfig 组件的交互逻辑是否符合预期
import React from 'react';
import { render, shallow, mount } from 'enzyme';
import TplConfig from '../TplConfig';
import Form from 'antd/lib/form';
import { Checkbox } from 'antd';

jest.mock('pages/Media/TplManage/util/request', () => Promise.resolve({ list: [] }));

beforeAll(() => {
  console.warn = jest.fn();
  console.error = jest.fn();
});

describe('TplConfig', () => {
  const base = {
    formItemLayout: {},
    appId: ['1'],
    data: {},
    adTypes: {},
    noAdd: false,
  } as any;
  const FormWrapper = Form.create()(TplConfig);
  const mountRes = mount(<FormWrapper {...base} />);
  const form = mountRes.find(TplConfig).props().form;

  beforeEach(() => {
    mountRes.setProps({ adTypes: {} });
    mountRes.update();
  });

  // 原生
  it('templateAdLock native', () => {
    mountRes.setProps({ adTypes: { 1: ['1'] } });
    mountRes.update();

    const tpl = mountRes.find('FormItem[label="模板广告"]');
    expect(tpl.exists()).toBe(true);

    expect(tpl.containsMatchingElement(<Checkbox>启用原生模板</Checkbox>)).toBe(true);
    expect(tpl.containsMatchingElement(<Checkbox disabled={true}>启用算法竞价(针对bigodsp有效)</Checkbox>)).toBe(
      true
    );

    expect(form.getFieldValue('templateAdLock')).toBe(0);
    tpl.find('CheckboxGroup .ant-checkbox-input').at(0).simulate('change');
    expect(form.getFieldValue('templateAdLock')).toBe(1);
    expect(mountRes.find('FormItem[label="配置原生模板"]').exists()).toBe(true);
  });
});

测试 hook

// useAdnList.hook.ts useAdnList 文件
...

// useAdnList.hook.spec.ts 测试 useAdnList 是否正常
import { renderHook } from '@testing-library/react-hooks';
import { AdTypeEnum } from 'constants/app';
import { useAdnList, ADNConfs } from '../useAdnList.hook';

describe('useAdnList.hook', () => {
  it('should be defined', () => {
    expect(useAdnList).toBeDefined();
  });

  it.each([
    [[], {}, []],
    [[], { 1: ['1'] }, []],
    [[AdTypeEnum.NATIVE], { 1: ['1'] }, [...ADNConfs]],
    [[AdTypeEnum.NATIVE], { 1: ['1'], 2: [] }, [...ADNConfs]],
    [[AdTypeEnum.NATIVE], { 1: ['1'], 3: [] }, [...ADNConfs]],
    [[AdTypeEnum.REWARD_VIDEO], { 4: [] }, [{ title: 'bigoad', value: 'bigoad' }]],
    [[AdTypeEnum.REWARD_VIDEO], { 1: ['1'], 4: [] }, [{ title: 'bigoad', value: 'bigoad' }]],
    [
      [AdTypeEnum.INTERSTITIAL],
      { 3: [] },
      [
        { title: 'bigoad', value: 'bigoad' },
        { title: 'bigobrand', value: 'bigobrand' },
        { title: 'bigobrand_cpm', value: 'bigobrand_cpm' },
      ],
    ],
    [
      [AdTypeEnum.INTERSTITIAL],
      { 3: [], 4: [] },
      [
        { title: 'bigoad', value: 'bigoad' },
        { title: 'bigobrand', value: 'bigobrand' },
        { title: 'bigobrand_cpm', value: 'bigobrand_cpm' },
      ],
    ],
    [[AdTypeEnum.INTERSTITIAL], { 1: ['1'], 3: [] }, [{ title: 'bigoad', value: 'bigoad' }]],
  ])('mounted value %#', (adTypeList, adTypes, expectValue) => {
    const hook = renderHook(() => useAdnList(adTypeList as any, adTypes, jest.fn));
    expect(hook.result.current).toEqual(expectValue);
  });

  it('update adTypes', () => {
    let adTypeList = [AdTypeEnum.INTERSTITIAL];
    let adTypes = { 4: [] } as any;
    const spy = jest.fn();
    const hook = renderHook(() => useAdnList(adTypeList as any, adTypes, spy));
    const expectValue1 = ['bigoad', 'bigobrand', 'bigobrand_cpm'].map((v) => ({ title: v, value: v }));
    expect(hook.result.current).toEqual(expectValue1);

    adTypes = {};
    hook.rerender();
    expect(hook.result.current).toEqual(expectValue1);

    adTypes = { 1: ['1'], 4: [] };
    hook.rerender();
    expect(hook.result.current).toEqual([{ title: 'bigoad', value: 'bigoad' }]);

    adTypeList = [AdTypeEnum.NATIVE];
    hook.rerender();
    expect(hook.result.current).toEqual([...ADNConfs]);

    adTypes = { 4: [] };
    adTypeList = [AdTypeEnum.INTERSTITIAL];
    hook.rerender();
    expect(hook.result.current).toEqual(expectValue1);
  });
});

一些遇到的问题解决记录

react-i18next:: You will need to pass in an i18next instance by using initReactI18next

https://www.tangshuang.net/3824.html https://react.i18next.com/misc/testing https://levelup.gitconnected.com/internationalization-i18n-in-react-using-hooks-62e1262c2c51

原因:被测试组件使用了 i18n 但是当前测试环境并没有初始化 i18n 的配置 解决:1、直接引入项目的 i18n 初始化配置(又会有 suspense 问题);2、mock 相关的多语言属性(推荐);

jest.mock('react-i18next', (): any => ({
  useTranslation: (): any => ({
    t: (key: string): string => key,
  }),
}));

Login suspended while rendering, but no fallback UI was specified.

https://stackoverflow.com/questions/54432861/a-react-component-suspended-while-rendering-but-no-fallback-ui-was-specified

原因:出现这个报错,一个可能的原因是测试组件使用了 const { t } = useTranslation();,而这个hook要求组件包裹在 Suspense 组件中
解决:

  1. 修改 i18n 的配置
```js
i18n
  .use(XHR)
  .use(LanguageDetector)
  .init({
    react: { 
      useSuspense: false //   <---- this will do the magic
    }
});
```
  1. 给被测试组件包裹 Suspense
```js
<Suspense fallback={<div>Loading... </div>}>
      <App />
</Suspense>
```

suspense 只渲染出了 fallback

https://stackoverflow.com/questions/53189059/how-to-test-snapshots-with-jest-and-new-react-lazy-16-6-api?answertab=active#tab-top

原因:suspense 包裹的组件存在一个loading状态,一开始只会渲染出 fallback

Invariant failed: You should not use <Link> outside a <Router>

原因:被测试组件内部使用了 Link 组件,所以需要使用 Router 组件进行包裹 解决:使用 BrowserRouter 作为包裹组件

import { BrowserRouter } from 'react-router-dom';

<BrowserRouter>
  <TestApp />
</BrowserRouter>

TypeError: window.matchMedia is not a function

https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function

window.matchMedia = (query) => ({
  matches: false,
  media: query,
  onchange: null,
  addListener: jest.fn(), // Deprecated
  removeListener: jest.fn(), // Deprecated
  addEventListener: jest.fn(),
  removeEventListener: jest.fn(),
  dispatchEvent: jest.fn(),
});

Error: Not implemented: HTMLCanvasElement.prototype.getContext (without installing the canvas npm package)

https://my.oschina.net/u/2263272/blog/4468786 https://ask.csdn.net/questions/6150266?answer=41482145&spm=1005.2026.3001.5703

原因:缺乏 canvas 功能的代码 解决:安装 jest-canvas-mock ,然后可以在当前测试文件引入或者在 setupTests.js 里面引入

TypeError: Cannot set property 'font' of undefined

上一步引入 jest-canvas-mock 后,还是会报这个错,可能是版本问题,网上大部分人都没有这个问题
jest-canvas-mock 删除后重新安装 canvas 解决,一定要把前一个依赖删除干净。

TypeError: Function.prototype.name sham getter called on non-function

这个报错只在生成测试覆盖率时出现,也就是使用了 --coverage 参数
原因:enzyme 遇到匿名类会出现问题
解决:给匿名导出的类添加类名

组件中有随机数,导致快照测试总是不通过

原因:随机数在每次生成快照都会不相同 解决: mock 那个随机数生成方法。例如:如果是使用 Math.random() 生成的随机数,就mock Math.random 方法;

比较常见的还有 new Date() 的返回,这个可以用 mockdate 库来 mock

import MockDate from 'mockdate';

MockDate.set(-639129600000);

如果那个随机数在外部依赖项中,可以找到依赖项的源码查看是什么随机方法,或者使用浅渲染,尝试规避掉那个随机数的渲染。

如果 mock 没有生效,需要检查一下 mock 的时机是不是在代码运行之后,如果是这种情况,需要想办法将 mock 时机提前。

没有浅渲染方法

https://github.com/enzymejs/enzyme

原因:@testing-library/react 没有提供浅渲染方法,如果需要浅渲染,需要使用 enzyme 。目前建议直接使用 enzyme。

Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.

https://stackoverflow.com/questions/58070996/how-to-fix-the-warning-uselayouteffect-does-nothing-on-the-server

原因:使用了 enzyme 的 render 方法进行渲染HTML结构,此时 useLayoutEffect 会有提示,实际上渲染是成功了的,但是还是建议将这个报错处理掉。 解决:mock 整个 react 或者 只 mock useLayoutEffect hook 钩子。

// mock 整个 react
// 推荐
jest.mock('react', () => ({
  ...jest.requireActual('react'),
  useLayoutEffect: jest.requireActual('react').useEffect,
}));

// 单纯mock useLayoutEffect 方法
// 不推荐,需要放在 setupTest.js 文件里面才能生效,不太灵活

import React from "react" 
React.useLayoutEffect = React.useEffect 

Warning: An update to List inside a test was not wrapped in act(...)

  1. 一般来说使用了 @testing-library/react 框架是不需要手动去包裹 act 方法的using-act-and-wrapperupdate
  2. 所以如果你是使用 enzyme 框架,然后出现了这一条报错,那你可以按照文档去尝试使用 act 包裹你的 state 更新操作
  3. 然后如果你是使用 @testing-library/react 框架,还是出现了这个报错,那你也可以尝试再加一层 act 试试,如果问题解决,那么恭喜你可以跳过下面的步骤了
  4. 如果你和我一样,不管怎么加 act ,加 async await 都无法解决这个报错,那么你可以尝试这个解决方案use waitForElement
  5. 当然,由于版本问题 waitForElement 已经废弃使用了。但是思路还是一样的:发生 state 更新操作之后,查找该 state 更新造成的结果, 查找到了则说明更新已经结束了。
  6. 对应现在 [email protected] 的最新 api 就是使用 findBy* 选择器查找元素,这个解决办法也已经有人在上面的那个问答里面回复了。
  7. 这里我再记录一下我分析的出现这个报错的原因,毕竟一般来说不会出现这个报错
    1. 首先在测试里面触发了一个事件
    2. 这个事件并没有直接更新状态,而是触发了另一个事件
    3. 然后第二个事件触发了状态更新,这就导致了默认的 act 没有起到作用
    4. 而且无论我怎么手动添加 act 都还是不起作用
    5. 因为我无法将 act 直接包裹在 state 变化的那个事件上

高阶组件返回的结果如何测试原始组件

  1. 假设有下面一个组件需要被测试,无论如何都拿不到原始的 SlotOperation 组件,因为这个组件使用了低版本的 antd 的 Form,必须要使用 Form.create 处理。
    这时就可以使用 enzyme 的 find 方法,根据组件名直接查找到需要测试的组件。
// 被测试组件必须要被高阶组件处理
export default connect(mapStateToProps)(
  Form.create<IProps>({
    name: 'slot_operation',
  })((props: IProps) => {
    if (!props.appId || !props.adSpaceId) {
      history.replace('/media/app');
      return null;
    }
    return <SlotOperation {...props} />;
  })
);


// 测试伪代码
import { mount } from 'enzyme';
import toJson from 'enzyme-to-json';
import SlotOperation from 'pages/Media/App/SlotOperation';
const mountRes = mount(
  <BrowserRouter>
    <SlotOperation {...(base as any)} />
  </BrowserRouter>
);


const slot = mountRes.find('SlotOperation');

  1. 如果可以绕过那个高阶组件,则可以mock那个高阶组件,原样返回原组件。
  2. 直接导出原组件进行测试,因为单元测试应该一次只测试一个对象,而不是和高阶组件进行联合测试。如果认为该高阶组件确实需要被测试,可以添加单独对该高阶组件的测试。

lazy-b avatar Jun 23 '21 04:06 lazy-b