5

那些年错过的React组件单元测试(上)

 3 years ago
source link: https://segmentfault.com/a/1190000039710016
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

🏂 写在前面

关于前端单元测试,其实两年前我就已经关注了,但那时候只是简单的知道断言,想着也不是太难的东西,项目中也没有用到,然后就想当然的认为自己就会了。

两年后的今天,部门要对以往的项目补加单元测试。真到了开始着手的时候,却懵了 😂

我以为的我以为却把自己给坑了,我发现自己对于前端单元测试一无所知。然后我翻阅了大量的文档,发现基于dva的单元测试文档比较少,因此在有了一番实践之后,我梳理了几篇文章,希望对于想使用 Jest 进行 React + Dva + Antd 单元测试的你能有所帮助。文章内容力求深入浅出,浅显易懂~

介于内容全部收在一篇会太长,计划分为两篇,本篇是第一篇,主要介绍如何快速上手jest以及在实战中常用的功能及api

🏈 前端自动化测试产生的背景

在开始介绍jest之前,我想有必要简单阐述一下关于前端单元测试的一些基础信息。

  • 为什么要进行测试?

    在 2021 年的今天,构建一个复杂的web应用对于我们来说,并非什么难事。因为有足够多优秀的的前端框架(比如 ReactVue);以及一些易用且强大的UI库(比如 Ant DesignElement UI)为我们保驾护航,极大地缩短了应用构建的周期。但是快速迭代的过程中却产生了大量的问题:代码质量(可读性差、可维护性低、可扩展性低)低,频繁的产品需求变动(代码变动影响范围不可控)等。

    因此单元测试的概念在前端领域应运而生,通过编写单元测试可以确保得到预期的结果,提高代码的可读性,如果依赖的组件有修改,受影响的组件也能在测试中及时发现错误。

  • 测试类型又有哪些呢?

    一般常见的有以下四种:

  • 常见的开发模式呢?

    • TDD: 测试驱动开发
    • BDD: 行为驱动测试

🎮 技术方案

针对项目本身使用的是React + Dva + Antd的技术栈,单元测试我们用的是Jest + Enzyme结合的方式。

Jest

关于Jest,我们参考一下其Jest 官网,它是Facebook开源的一个前端测试框架,主要用于ReactReact Native的单元测试,已被集成在create-react-app中。Jest特点:

  • 优秀的 api
  • 快速且安全
  • 代码覆盖率
  • 优秀的报错信息

Enzyme

EnzymeAirbnb开源的React测试工具库,提供了一套简洁强大的API,并内置Cheerio,同时实现了jQuery风格的方式进行DOM处理,开发体验十分友好。在开源社区有超高人气,同时也获得了React官方的推荐。

📌 Jest

本篇文章我们着重来介绍一下Jest,也是我们整个React单元测试的根基。

安装JestEnzyme。如果React的版本是15或者16,需要安装对应的enzyme-adapter-react-15enzyme-adapter-react-16并配置。

/**
 * setup
 *
 */

import Enzyme from "enzyme"
import Adapter from "enzyme-adapter-react-16"
Enzyme.configure({ adapter: new Adapter() })

jest.config.js

可以运行npx jest --init在根目录生成配置文件jest.config.js

/*
 * For a detailed explanation regarding each configuration property, visit:
 * https://jestjs.io/docs/en/configuration.html
 */

module.exports = {
  // All imported modules in your tests should be mocked automatically
  // automock: false,

  // Automatically clear mock calls and instances between every test
  clearMocks: true,

  // Indicates whether the coverage information should be collected while executing the test
  // collectCoverage: true,

  // An array of glob patterns indicating a set of files for which coverage information should be collected
  collectCoverageFrom: ["src/**/*.{js,jsx,ts,tsx}", "!src/**/*.d.ts"],

  // The directory where Jest should output its coverage files
  coverageDirectory: "coverage",

  // An array of regexp pattern strings used to skip coverage collection
  // coveragePathIgnorePatterns: [
  //   "/node_modules/"
  // ],


  // An array of directory names to be searched recursively up from the requiring module's location
  moduleDirectories: ["node_modules", "src"],

  // An array of file extensions your modules use
  moduleFileExtensions: ["js", "json", "jsx", "ts", "tsx"],


  // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
  // modulePathIgnorePatterns: [],

  // Automatically reset mock state between every test
  // resetMocks: false,

  // Reset the module registry before running each individual test
  // resetModules: false,

  // Automatically restore mock state between every test
  // restoreMocks: false,

  // The root directory that Jest should scan for tests and modules within
  // rootDir: undefined,

  // A list of paths to directories that Jest should use to search for files in
  // roots: [
  //   "<rootDir>"
  // ],

  // The paths to modules that run some code to configure or set up the testing environment before each test
  // setupFiles: [],

  // A list of paths to modules that run some code to configure or set up the testing framework before each test
  setupFilesAfterEnv: [
    "./node_modules/jest-enzyme/lib/index.js",
    "<rootDir>/src/utils/testSetup.js",
  ],

  // The test environment that will be used for testing
  testEnvironment: "jest-environment-jsdom",

  // Options that will be passed to the testEnvironment
  // testEnvironmentOptions: {},

  // The glob patterns Jest uses to detect test files
  testMatch: ["**/?(*.)+(spec|test).[tj]s?(x)"],

  // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
  // testPathIgnorePatterns: [
  //   "/node_modules/"
  // ],


  // A map from regular expressions to paths to transformers
  // transform: undefined,

  // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
  transformIgnorePatterns: ["/node_modules/", "\\.pnp\\.[^\\/]+$"],
}

这里只是列举了常用的配置项:

  • automock: 告诉 Jest 所有的模块都自动从 mock 导入.
  • clearMocks: 在每个测试前自动清理 mock 的调用和实例 instance
  • collectCoverage: 是否收集测试时的覆盖率信息
  • collectCoverageFrom: 生成测试覆盖报告时检测的覆盖文件
  • coverageDirectory: Jest 输出覆盖信息文件的目录
  • coveragePathIgnorePatterns: 排除出 coverage 的文件列表
  • coverageReporters: 列出包含 reporter 名字的列表,而 Jest 会用他们来生成覆盖报告
  • coverageThreshold: 测试可以允许通过的阈值
  • moduleDirectories: 模块搜索路径
  • moduleFileExtensions:代表支持加载的文件名
  • testPathIgnorePatterns:用正则来匹配不用测试的文件
  • setupFilesAfterEnv:配置文件,在运行测试案例代码之前,Jest 会先运行这里的配置文件来初始化指定的测试环境
  • testMatch: 定义被测试的文件
  • transformIgnorePatterns: 设置哪些文件不需要转译
  • transform: 设置哪些文件中的代码是需要被相应的转译器转换成 Jest 能识别的代码,Jest 默认是能识别 JS 代码的,其他语言,例如 Typescript、CSS 等都需要被转译。
  • toBe(value):使用 Object.is 来进行比较,如果进行浮点数的比较,要使用 toBeCloseTo
  • not:取反
  • toEqual(value):用于对象的深比较
  • toContain(item):用来判断 item 是否在一个数组中,也可以用于字符串的判断
  • toBeNull(value):只匹配 null
  • toBeUndefined(value):只匹配 undefined
  • toBeDefined(value):与 toBeUndefined 相反
  • toBeTruthy(value):匹配任何语句为真的值
  • toBeFalsy(value):匹配任何语句为假的值
  • toBeGreaterThan(number): 大于
  • toBeGreaterThanOrEqual(number):大于等于
  • toBeLessThan(number):小于
  • toBeLessThanOrEqual(number):小于等于
  • toBeInstanceOf(class):判断是不是 class 的实例
  • resolves:用来取出 promise 为 fulfilled 时包裹的值,支持链式调用
  • rejects:用来取出 promise 为 rejected 时包裹的值,支持链式调用
  • toHaveBeenCalled():用来判断 mock function 是否被调用过
  • toHaveBeenCalledTimes(number):用来判断 mock function 被调用的次数
  • assertions(number):验证在一个测试用例中有 number 个断言被调用

命令行工具的使用

在项目package.json文件添加如下script:

"scripts": {
    "start": "node bin/server.js",
    "dev": "node bin/server.js",
    "build": "node bin/build.js",
    "publish": "node bin/publish.js",
++  "test": "jest --watchAll",
},

此时运行npm run test:

我们发现有以下几种模式:

  • f: 只会测试之前没有通过的测试用例
  • o: 只会测试关联的并且改变的文件(需要使用 git)(jest --watch 可以直接进入该模式)
  • p: 测试文件名包含输入的名称的测试用例
  • t: 测试用例的名称包含输入的名称的测试用例
  • a: 运行全部测试用例

在测试过程中,你可以切换适合的模式。

类似于 react 或者 vue 的生命周期,一共有四种:

  • beforeAll():所有测试用例执行之前执行的方法
  • afterAll():所有测试用例跑完以后执行的方法
  • beforeEach():在每个测试用例执行之前需要执行的方法
  • afterEach():在每个测试用例执行完后执行的方法

这里,我以项目中的一个基础 demo 来演示一下具体使用:

Counter.js

export default class Counter {
  constructor() {
    this.number = 0
  }
  addOne() {
    this.number += 1
  }
  minusOne() {
    this.number -= 1
  }
}

Counter.test.js

import Counter from './Counter'
const counter = new Counter()

test('测试 Counter 中的 addOne 方法', () => {
  counter.addOne()
  expect(counter.number).toBe(1)
})

test('测试 Counter 中的 minusOne 方法', () => {
  counter.minusOne()
  expect(counter.number).toBe(0)
})

运行npm run test:

通过第一个测试用例加 1,number的值为 1,当第二个用例减 1 的时候,结果应该是 0。但是这样两个用例间相互干扰不好,可以通过 Jest 的钩子函数来解决。修改测试用例:

import Counter from "../../../src/utils/Counter";
let counter = null

beforeAll(() => {
  console.log('BeforeAll')
})

beforeEach(() => {
  console.log('BeforeEach')
  counter = new Counter()
})

afterEach(() => {
  console.log('AfterEach')
})

afterAll(() => {
  console.log('AfterAll')
})

test('测试 Counter 中的 addOne 方法', () => {
  counter.addOne()
  expect(counter.number).toBe(1)
})
test('测试 Counter 中的 minusOne 方法', () => {
  counter.minusOne()
  expect(counter.number).toBe(-1)
})

运行npm run test:

可以清晰的看到对应钩子的执行顺序:

beforeAll > (beforeEach > afterEach)(单个用例都会依次执行) > afterAll

除了以上这些基础知识外,其实还有异步代码的测试、Mock、Snapshot 快照测试等,这些我们会在下面 React 的单元测试示例中依次讲解。

异步代码的测试

众所周知,JS中充满了异步代码。

正常情况下测试代码是同步执行的,但当我们要测的代码是异步的时候,就会有问题了:test case实际已经结束了,然而我们的异步代码还没有执行,从而导致异步代码没有被测到。

那怎么办呢?

对于当前测试代码来说,异步代码什么时候执行它并不知道,因此解决方法很简单。当有异步代码的时候,测试代码跑完同步代码后不立即结束,而是等结束的通知,当异步代码执行完后再告诉jest:“好了,异步代码执行完了,你可以结束任务了”。

jest提供了三种方案来测试异步代码,下面我们分别来看一下。

done 关键字

当我们的test函数中出现了异步回调函数时,可以给test函数传入一个done参数,它是一个函数类型的参数。如果test函数传入了donejest就会等到done被调用才会结束当前的test case,如果done没有被调用,则该test自动不通过测试。

import { fetchData } from './fetchData'
test('fetchData 返回结果为 { success: true }', done => {
  fetchData(data => {
    expect(data).toEqual({
      success: true
    })
    done()
  })
})

上面的代码中,我们给test函数传入了done参数,在fetchData的回调函数中调用了done。这样,fetchData的回调中异步执行的测试代码就能够被执行。

但这里我们思考一种场景:如果使用done来测试回调函数(包含定时器场景,如setTimeout),由于定时器我们设置了 一定的延时(如 3s)后执行,等待 3s 后会发现测试通过了。那假如 setTimeout 设置为几百秒,难道我们也要在 Jest 中等几百秒后再测试吗?

显然这对于测试的效率是大打折扣的!!

jest中提供了诸如jest.useFakeTimers()jest.runAllTimers()toHaveBeenCalledTimesjest.advanceTimersByTimeapi来处理这种场景。

这里我也不举例详细说明了,有这方面需求的同学可以参考Timer Mocks

返回 Promise

⚠️ 当对Promise进行测试时,一定要在断言之前加一个return,不然没有等到Promise的返回,测试函数就会结束。可以使用.promises/.rejects对返回的值进行获取,或者使用then/catch方法进行判断。

如果代码中使用了Promise,则可以通过返回Promise来处理异步代码,jest会等该promise的状态转为resolve时才会结束,如果promisereject了,则该测试用例不通过。

// 假设 user.getUserById(参数id) 返回一个promise
it('测试promise成功的情况', () => {
  expect.assertions(1);
  return user.getUserById(4).then((data) => {
    expect(data).toEqual('Cosen');
  });
});
it('测试promise错误的情况', () => {
  expect.assertions(1);
  return user.getUserById(2).catch((e) => {
    expect(e).toEqual({
      error: 'id为2的用户不存在',
    });
  });
});

注意,上面的第二个测试用例可用于测试promise返回reject的情况。这里用.catch来捕获promise返回的reject,当promise返回reject时,才会执行expect语句。而这里的expect.assertions(1)用于确保该测试用例中有一个expect被执行了。

对于Promise的情况,jest还提供了一对匹配符resolves/rejects,其实只是上面写法的语法糖。上面的代码用匹配符可以改写为:

// 使用'.resolves'来测试promise成功时返回的值
it('使用'.resolves'来测试promise成功的情况', () => {
  return expect(user.getUserById(4)).resolves.toEqual('Cosen');
});
// 使用'.rejects'来测试promise失败时返回的值
it('使用'.rejects'来测试promise失败的情况', () => {
  expect.assertions(1);
  return expect(user.getUserById(2)).rejects.toEqual({
    error: 'id为2的用户不存在',
  });
});

async/await

我们知道async/await其实是Promise的语法糖,可以更优雅地写异步代码,jest中也支持这种语法。

我们把上面的代码改写一下:

// 使用async/await来测试resolve
it('async/await来测试resolve', async () => {
  expect.assertions(1);
  const data = await user.getUserById(4);
  return expect(data).toEqual('Cosen');
});
// 使用async/await来测试reject
it('async/await来测试reject', async () => {
  expect.assertions(1);
  try {
    await user.getUserById(2);
  } catch (e) {
    expect(e).toEqual({
      error: 'id为2的用户不存在',
    });
  }
});

⚠️ 使用async不用进行return返回,并且要使用try/catch来对异常进行捕获。

Mock

介绍jest中的mock之前,我们先来思考一个问题:为什么要使用mock函数?

在项目中,一个模块的方法内常常会去调用另外一个模块的方法。在单元测试中,我们可能并不需要关心内部调用的方法的执行过程和结果,只想知道它是否被正确调用即可,甚至会指定该函数的返回值。这个时候,mock的意义就很大了。

jest中与mock相关的api主要有三个,分别是jest.fn()jest.mock()jest.spyOn()。使用它们创建mock函数能够帮助我们更好的测试项目中一些逻辑较复杂的代码。我们在测试中也主要是用到了mock函数提供的以下三种特性:

  • 捕获函数调用情况
  • 设置函数返回值
  • 改变函数的内部实现

下面,我将分别介绍这三种方法以及他们在实际测试中的应用。

jest.fn()

jest.fn()是创建mock函数最简单的方式,如果没有定义函数内部的实现,jest.fn()会返回undefined作为返回值。

// functions.test.js

test('测试jest.fn()调用', () => {
  let mockFn = jest.fn();
  let res = mockFn('厦门','青岛','三亚');

  // 断言mockFn的执行后返回undefined
  expect(res).toBeUndefined();
  // 断言mockFn被调用
  expect(mockFn).toBeCalled();
  // 断言mockFn被调用了一次
  expect(mockFn).toBeCalledTimes(1);
  // 断言mockFn传入的参数为1, 2, 3
  expect(mockFn).toHaveBeenCalledWith('厦门','青岛','三亚');
})

jest.fn()所创建的mock函数还可以设置返回值,定义内部实现返回Promise对象

// functions.test.js

test('测试jest.fn()返回固定值', () => {
  let mockFn = jest.fn().mockReturnValue('default');
  // 断言mockFn执行后返回值为default
  expect(mockFn()).toBe('default');
})

test('测试jest.fn()内部实现', () => {
  let mockFn = jest.fn((num1, num2) => {
    return num1 + num2;
  })
  // 断言mockFn执行后返回20
  expect(mockFn(10, 10)).toBe(20);
})

test('测试jest.fn()返回Promise', async () => {
  let mockFn = jest.fn().mockResolvedValue('default');
  let res = await mockFn();
  // 断言mockFn通过await关键字执行后返回值为default
  expect(res).toBe('default');
  // 断言mockFn调用后返回的是Promise对象
  expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]");
})

jest.mock()

一般在真实的项目里,测试异步函数的时候,不会真正的发送 ajax 请求去请求这个接口,为什么?

比如有 1w 个接口要测试,每个接口要 3s 才能返回,测试全部接口需要 30000s,那么这个自动化测试的时间就太慢了

我们作为前端只需要去确认这个异步请求发送成功就好了,至于后端接口返回什么内容我们就不测了,这是后端自动化测试要做的事情。

这里以一个axios请求demo为例来说明:

// user.js
import axios from 'axios'

export const getUserList = () => {
  return axios.get('/users').then(res => res.data)
}

对应测试文件user.test.js:

import { getUserList } from '@/services/user.js'
import axios from 'axios'
// 👇👇
jest.mock('axios')
// 👆👆
test.only('测试 getUserList', async () => {
  axios.get.mockResolvedValue({ data: ['Cosen','森林','柯森'] })
  await getUserList().then(data => {
    expect(data).toBe(['Cosen','森林','柯森'])
  })
})

我们在测试用例的最上面加入了jest.mock('axios'),我们让jest去对axios做模拟,这样就不会去请求真正的数据了。然后调用axios.get的时候,不会真实的请求这个接口,而是会以我们写的{ data: ['Cosen','森林','柯森'] }去模拟请求成功后的结果。

当然模拟异步请求是需要时间的,如果请求多的话时间就很长,这时候可以在本地mock数据,在根目录下新建 __mocks__文件夹。这种方式就不用去模拟axios,而是直接走的本地的模拟方法,也是比较常用的一种方式,这里就不展开说明了。

jest.spyOn()

jest.spyOn()方法同样创建一个mock函数,但是该mock函数不仅能够捕获函数的调用情况,还可以正常的执行被spy的函数。实际上,jest.spyOn()jest.fn()的语法糖,它创建了一个和被spy的函数具有相同内部代码的mock函数

Snapshot 快照测试

所谓snapshot,即快照也。通常涉及 UI 的自动化测试,思路是把某一时刻的标准状态拍个快照。

describe("xxx页面", () => {
  // beforeEach(() => {
  //   jest.resetAllMocks()
  // })
  // 使用 snapshot 进行 UI 测试
  it("页面应能正常渲染", () => {
    const wrapper = wrappedShallow()
    expect(wrapper).toMatchSnapshot()
  })
})

当使用toMatchSnapshot的时候,Jest 将会渲染组件并创建其快照文件。这个快照文件包含渲染后组件的整个结构,并且应该与测试文件本身一起提交到代码库。当我们再次运行快照测试时,Jest 会将新的快照与旧的快照进行比较,如果两者不一致,测试就会失败,从而帮助我们确保用户界面不会发生意外改变。

到这里,关于前端单元测试的一些基础背景和Jest的基础api就介绍完了,在下一篇文章中,我会结合项目中的一个React组件来讲解如何做组件单元测试

📜 参考链接


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK