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

🏂 写在前面

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

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

我觉得的我觉得却把本身给坑了,我发现本身对于前端单元测试一无所知。而后我翻阅了大量的文档,发现基于dva的单元测试文档比较少,所以在有了一番实践以后,我梳理了几篇文章,但愿对于想使用 Jest 进行 React + Dva + Antd 单元测试的你能有所帮助。文章内容力求深刻浅出,浅显易懂~vue

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

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

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

  • 为何要进行测试?

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

    所以单元测试的概念在前端领域应运而生,经过编写单元测试能够确保获得预期的结果,提升代码的可读性,若是依赖的组件有修改,受影响的组件也能在测试中及时发现错误。ios

  • 测试类型又有哪些呢?git

    通常常见的有如下四种:web

    • 单元测试
    • 功能测试
    • 集成测试
    • 冒烟测试
  • 常见的开发模式呢?ajax

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

🎮 技术方案

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

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组件来说解如何作组件单元测试

📜 参考连接

相关文章
相关标签/搜索