关于前端单元测试,其实两年前我就已经关注了,但那时候只是简单的知道断言
,想着也不是太难的东西,项目中也没有用到,然后就想当然的认为自己就会了。
两年后的今天,部门要对以往的项目补加单元测试。真到了开始着手的时候,却懵了 😂
我以为的我以为却把自己给坑了,我发现自己对于前端单元测试一无所知。然后我翻阅了大量的文档,发现基于dva
的单元测试文档比较少,因此在有了一番实践之后,我梳理了几篇文章,希望对于想使用 Jest 进行 React + Dva + Antd 单元测试
的你能有所帮助。文章内容力求深入浅出,浅显易懂~
介于内容全部收在一篇会太长,计划分为两篇,本篇是第一篇,主要介绍如何快速上手jest
以及在实战中常用的功能及api
在开始介绍jest
之前,我想有必要简单阐述一下关于前端单元测试的一些基础信息。
在 2021 年的今天,构建一个复杂的web
应用对于我们来说,并非什么难事。因为有足够多优秀的的前端框架(比如 React
,Vue
);以及一些易用且强大的UI
库(比如 Ant Design
,Element UI
)为我们保驾护航,极大地缩短了应用构建的周期。但是快速迭代的过程中却产生了大量的问题:代码质量(可读性差、可维护性低、可扩展性低)低,频繁的产品需求变动(代码变动影响范围不可控)等。
因此单元测试的概念在前端领域应运而生,通过编写单元测试可以确保得到预期的结果,提高代码的可读性,如果依赖的组件有修改,受影响的组件也能在测试中及时发现错误。
测试类型又有哪些呢?
一般常见的有以下四种:
常见的开发模式呢?
TDD
: 测试驱动开发BDD
: 行为驱动测试针对项目本身使用的是React + Dva + Antd
的技术栈,单元测试我们用的是Jest + Enzyme
结合的方式。
Jest
关于Jest
,我们参考一下其Jest 官网,它是Facebook
开源的一个前端测试框架,主要用于React
和React Native
的单元测试,已被集成在create-react-app
中。Jest
特点:
Enzyme
Enzyme
是Airbnb
开源的React
测试工具库,提供了一套简洁强大的API
,并内置Cheerio
,同时实现了jQuery
风格的方式进行DOM
处理,开发体验十分友好。在开源社区有超高人气,同时也获得了React
官方的推荐。
Jest
本篇文章我们着重来介绍一下Jest
,也是我们整个React单元测试
的根基。
安装Jest
、Enzyme
。如果React
的版本是15
或者16
,需要安装对应的enzyme-adapter-react-15
和enzyme-adapter-react-16
并配置。
/** * setup * */ import Enzyme from "enzyme" import Adapter from "enzyme-adapter-react-16" Enzyme.configure({ adapter: new Adapter() })
可以运行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 的调用和实例 instancecollectCoverage
: 是否收集测试时的覆盖率信息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 来进行比较,如果进行浮点数的比较,要使用 toBeCloseTonot
:取反toEqual(value)
:用于对象的深比较toContain(item)
:用来判断 item 是否在一个数组中,也可以用于字符串的判断toBeNull(value)
:只匹配 nulltoBeUndefined(value)
:只匹配 undefinedtoBeDefined(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
提供了三种方案来测试异步代码,下面我们分别来看一下。
当我们的test
函数中出现了异步回调函数时,可以给test
函数传入一个done
参数,它是一个函数类型的参数。如果test
函数传入了done
,jest
就会等到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()
和toHaveBeenCalledTimes
、jest.advanceTimersByTime
等api
来处理这种场景。
这里我也不举例详细说明了,有这方面需求的同学可以参考Timer Mocks
⚠️ 当对Promise
进行测试时,一定要在断言之前加一个return
,不然没有等到Promise
的返回,测试函数就会结束。可以使用.promises/.rejects
对返回的值进行获取,或者使用then/catch
方法进行判断。
如果代码中使用了Promise
,则可以通过返回Promise
来处理异步代码,jest
会等该promise
的状态转为resolve
时才会结束,如果promise
被reject
了,则该测试用例不通过。
// 假设 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
其实是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
,即快照也。通常涉及 UI 的自动化测试,思路是把某一时刻的标准状态拍个快照。
describe("xxx页面", () => { // beforeEach(() => { // jest.resetAllMocks() // }) // 使用 snapshot 进行 UI 测试 it("页面应能正常渲染", () => { const wrapper = wrappedShallow() expect(wrapper).toMatchSnapshot() }) })
当使用toMatchSnapshot
的时候,Jest
将会渲染组件并创建其快照文件。这个快照文件包含渲染后组件的整个结构,并且应该与测试文件本身一起提交到代码库。当我们再次运行快照测试时,Jest
会将新的快照与旧的快照进行比较,如果两者不一致,测试就会失败,从而帮助我们确保用户界面不会发生意外改变。
到这里,关于前端单元测试的一些基础背景和Jest
的基础api
就介绍完了,在下一篇文章中,我会结合项目中的一个React组件
来讲解如何做组件单元测试
。