前言
本文将通过配套的思维导图为基础,为你介绍单元测试的各种知识点,并且以 Jest
为例,逐步带你深入学习单元测试。 至于 BDD(Behavior-Driven Development)
和 TDD(Test-Driven Development)
两大思想流派,这里先不做过多讨论。
当你尝试问 ChartGPT
,单元测试是什么,它会回答你这么一大坨:
简而言之
- 单元测试是为了测试你写的代码,包括各种函数、组件、模块等。
- 前端现在越来越复杂了,屎山堆积速度惊人,所以在前端我们得把单元测试也整起来了,它不只是后端的活。
- 一个好的单元测试可以让你对自己的代码充满信心 ,连
QA
都要夸你一句 泰裤辣!
本文的 思维导图 链接,建议先查看思维导图,对本文有个大概的了解。
常用的前端单元测试框架和工具概述
我们可以把编写单元测试的依赖库分为三部分
- Framework(基础框架)
- Assertion Library(断言库)
- Runner(运行器)
Framework
Framework
提供了包括通用型测试函数describe
和it
,以及用于运行测试的主函数和各种功能函数。
可以通过下面这段简化过的 Jest
测试代码来简单认识下。
test.ts
import xxx;
describe("test env is match", () => {
beforeEach(() => {
// do something
})
afterEach(() => {
// do something
})
it('envirment is dev', () => {
// do something test
})
it('envirment is prod', () => {
// do something test
})
});
- 首先外面的
describe
方法定义了测试组,传入的第一个参数代表这组测试的名字,第二个参数是一个箭头函数,里面就是这组测试具体要执行的代码。 it
则被用来定义测试组里的测试块,一个测试组可以定义N多个测试块。beforeEach
和afterEach
类似于生命周期的钩子函数,它们可以做一些前置环境准备等等事情,我们后续在实践的章节里会讲到,这里先挖个坑。
当然Framework提供的功能远不止这些,它还有很多对测试提供帮助的功能函数,这里就不列举了,可以参考对应库的文档介绍。我们下面来简单介绍下几个比较知名的 test 库
Jest
Jest 是 Facebook 开发的一体式单元测试工具。你不需要关心什么是测试运行环境,什么是测试框架,什么是断言库,什么是Runner,Jest 就是你所需要的全部。它只需要非常少的配置,几乎做到了开箱即用。就像它自我介绍的那样,这是一个令人愉快的测试库。
Jasmine
Jasmine跟Jest类似,该有的断言和Mock都有,也基本做到了开箱即用。它属于老牌测试框架了,Angular官方就默认选择了使用Jasmine + Karma做单元测试。
Mocha
Mocha 是一个灵活的库,提供给开发者的只有一个基础测试结构。然后其它功能像 assertions,spies,mocks等等,需要添加其它库/插件来完成。
它的优点在于社区比较成熟,用的人比较多,测试各种东西社区都有示例,缺点是对比其他库的开箱即用,需要较多配置。
AVA
AVA 可以看成是mocha的替代者,专为编写异步测试而设计(异步测试)。
- 轻量和高效
- 简单的测试语法
- 并发运行测试
- 强制编写原子测试 一旦开始,就一直运行到结束,中间不会切换到另一个测试
- 没有隐藏的全局变量
- 为每个测试文件提供环境隔离
- 用 ES2015 编写测试------不需安装依赖babel 支持
至于在项目中具体选哪个,可以根据团队情况和项目情况去选型。
Assertion Library
断言库(Assertion Library)其实就是 expect() 函数的提供者,用来验证你测试结果是否通过的库。
举个栗子:FuncA 是一个拥有一个入参的函数,我要对它做单测,当它被调用且入参为1时,我可以对 FuncA 做如下断言:
- 断言 FuncA 的 result 一定不等于2
- 断言 FuncA 的 result 一定等于1
当 FuncA 被其他 FuncB 调用时,我可以在FuncB 的测试做如下断言:
- 断言 FuncA 被调用了,且只被调用了1次
- 断言 FuncA 被调用时的入参一定为1
断言库就是做这些事的,我们通过对测试目标进行断言,来确定它符合我们的预期。
断言库有很多语法函数,像是 .be(xx)
, .not.be(xx)
, .equal(xx)
, .contain
等等。
可以分为两种风格:
- BDD风格
expect(foo).to.be("aa")
或foo.should.be("aa");
- Assert风格
assert(foo == "aa");
Assert
Node.js中内置的库
js
assert("foo" == "aa");
Should
BDD风格
js
foo.should.equal('aa');
Expect
BDD风格,Jest和Jasmine都是该风格。
js
expect(foo).to.be("aa")
这里有一点要提到的是,在遇到被测对象为 undefined
时,should
会直接失效报错,而 expect
依然可以给出信息。
js
// foo === undefined
foo.should.equal('aa');
此时会直接报错,错误信息为: Cannot read property 'should' of undefined,而使用 expect
js
// foo === undefined
expect(foo).to.equal('aa');
输出的信息为:expected undefined to equal 'foo'。所以expect要比should友好一点。
Chai
Chai直接全都要!它可以与任何 Unit Test库集成,同时支持上面三种风格的写法。
js
assert("foo" == "aa");
foo.should.be("aa");
expect(foo).to.be("aa");
Runner
测试运行工具(Test Runner)是负责解析和调用单元测试代码并加载测试运行环境(Test Runtime)的工具。
目前Runner分为两派,JSDom和Karma。
JSDom
jsdom 是许多 Web 标准的纯 JavaScript 实现,特别是 WHATWG DOM 和 HTML 标准,可以和 Node.js 一起使用。 一般来说,该项目的目标是模拟足够多的 Web 浏览器子集,以用于测试和 scraping real-world(不知道怎么翻译) Web 应用程序。
看代码应该比较好理解一些。
js
const dom = new JSDOM(`<body>
<script>document.body.appendChild(document.createElement("hr"));</script>
</body>`);
// The script will not be executed, by default:
dom.window.document.body.children.length === 1;
JSDom 顾名思义就是通过纯 JS 实现的 DOM 对象, 但它不能理解 DOM 的布局、尺寸、样式、以及浏览器的高级 API,例如 ResizeObserver,matchMedia()
,只能通过mock去模拟。
Karma
Karma是一个由Google开发的前端测试运行器(test runner),旨在简化前端单元测试和集成测试的执行过程。它可以与多种测试框架(如Mocha、Jasmine、QUnit等)和断言库(如Chai、Expect.js等)配合使用,提供一个可配置的测试环境。
Karma的主要作用是启动浏览器实例,并在这些实例中执行你编写的测试代码。它可以自动监测文件的更改,并重新执行相关的测试,使你能够实时获取测试结果和反馈。Karma还提供了丰富的功能,如测试覆盖率报告、并发测试执行、持续集成等,使你能够更全面地了解和监控你的前端代码的质量和性能。
简而言之 Karma 让你的测试代码可以运行在各种真实浏览器环境,可以直接访问浏览器端 API,而不是 mock, 这是 JSDom 做不到的。
单元测试的目标和原则
目标
单元测试的目标应该是实现软件项目的可持续增长。
这里的可持续是关键,我们在开始一个新项目时,没有任何祖传代码需要担心,此时我们对业务模块的手到拈来,开发的飞快。随着时间的推移,代码变得越来越多并且难以理解,你必须投入越来越多的成本才能保持与开始时相同的进度。
原则
单元测试虽然有助于保持项目增长,但是仅仅编写测试是不够的,写得不好的测试仍然导致增长变缓。在编写单元测试时,应该尽量遵循以下原则:
- 针对代码中最核心的部分进行测试(不应该过度关注代码的实现过程,而应关注目标和结果)
- 可维护性强 ( 以最低的维护成本提供最大的价值,不要为了写单元测试而写单元测试 )
- 产出的单元测试能提高对目标代码的信心
还有一条算是执行过程中的原则,我们应该确保在每次修改原有代码逻辑后,原有单元测试是通过的(如果是业务逻辑发生变化,那么请同步修改你的单元测试)。
Jest
上面简单介绍了单元测试相关的前置知识和基础概念,后面的实战我们选择使用React + Jest。个人觉得 Jest 的文档相比其他几个库更友好一点,比较详细而且部分文档有中文翻译可以看(虽然大概率是机翻的),再加上有大厂背书,社区活跃程度和后续维护都不用担心。
Jest 是 Facebook 开发的一体式单元测试工具。你不需要关心什么是测试运行环境,什么是测试框架,什么是断言库。Jest 就是你所需要的全部。它只需要非常少的配置,几乎做到了开箱即用。
- 支持并行运行测试以及在隔离环境下测试。
- Jest 的运行环境是 JSDOM + Node,一个伪 DOM 引擎。
- Jest 基于Jasmine进行开发,添加了更多的特性
- Jest 支持vue、ng、react等各种项目,但更多用于react。
本章节将会接详细介绍 Jest
的用法。
Basic 基础知识
Simple usage
篇幅有限,建议看 官方文档,Jest 使用比较简单,没什么复杂的配置。 先了解一下 describe 基础写法。
Repeating Setup
如果有一些要为每次测试重复设置的工作,就可以使用下面介绍的几种方法进行设置。
beforeEach(fn, timeout)
每个测试开始前执行的钩子函数,使用 beforeEach 方便在每个测试开始前设置一些全局状态。
如果传入的回调函数返回值是 promise
或者 generator
,Jest 会等待 promise resolve
再继续执行测试。
第二个参数是可选的 timeout(毫秒), 指定函数执行的超时时间,默认是5秒。
js
const globalDatabase = makeGlobalDatabase();
beforeEach(() => {
// Clears the database and adds some testing data.
// Jest 会等待 promise resolve 再去执行测试
return globalDatabase.clear().then(() => {
return globalDatabase.insert({testData: 'foo'});
});
});
test('can find things', () => {
return globalDatabase.find('thing', {}, results => {
expect(results.length).toBeGreaterThan(0);
});
});
test('can insert a thing', () => {
return globalDatabase.insert('thing', makeThing(), response => {
expect(response.success).toBeTruthy();
});
});
afterEach(fn, timeout)
文件内每个测试完成后执行的钩子函数,使用 afterEach 方便清理一些在每个测试中创建的临时状态。
用法跟 beforeEach
差不多。
beforeAll(fn, timeout)
文件内所有测试开始前执行的钩子函数,方便设置一些在每个测试用例之间共享的全局状态。
他跟beforeEach
的区别就是一个针对文件内每个测试用例,一个针对文件内所有测试用例。
用法跟 beforeEach
差不多。
afterAll(fn, timeout)
文件内所有测试完成后执行的钩子函数,方便清理一些在测试用例之间共享的全局状态。
用法跟 beforeEach
差不多。
一些建议
如果你有一个测试作为一个更大用例中的一部分时,经常运行失败,但是单独运行它又是好的, 最好检查下其他测试对这个测试的影响。可以通过修改 beforeEach
来清除一些共享的状态来尝试修复这种问题。
如果不能确定哪些分享的状态被修改了,可以尝试在 beforeEach
打印出来看看。
Async Test
假设你有一个异步方法 fetchData
,需要测试返回的结果是否符合预期
- 直接返回一个
Promise
即可,Jest会等待Promise的resove状态 如果 Promise 的状态变为 rejected, 测试将会失败。
js
test('the data is peanut butter', () => {
return fetchData().then(data => {
expect(data).toBe('peanut butter');
});
});
- 或者可以在测试中使用
async
和await
js
test('the data is peanut butter', async () => {
const data = await fetchData();
expect(data).toBe('peanut butter');
});
test('the fetch fails with an error', async () => {
expect.assertions(1);
try {
await fetchData();
} catch (e) {
expect(e).toMatch('error');
}
});
也可以将 async await
和 .resolves
or .rejects
一起使用
js
test('the data is peanut butter', async () => {
await expect(fetchData()).resolves.toBe('peanut butter');
})
test('the fetch fails with an error', async () => {
await expect(fetchData()).rejects.toMatch('error');
});
Test Timer
假如现在有如下代码需要测试。
js
function timerGame(callback) {
console.log('Ready....go!');
setTimeout(() => {
console.log("Time's up -- stop!");
callback && callback();
}, 3000);
}
module.exports = timerGame;
如果不Mock Timer的话,我们需要写如下测试代码
js
describe("timerGame", () => {
it("waits 3 second before ending the game", (done) => {
timerGame(() => {
expect("???");
done();
});
});
});
但是这就面临一个问题,我们不得不等待3秒才能跑完这个 Case ,这不合理。 因此Jest提供了 jest.runAllTimers()
用来快进时间。
js
jest.useFakeTimers();
test('calls the callback after 3 second', () => {
const timerGame = require('../timerGame');
const callback = jest.fn();
timerGame(callback);
// At this point in time, the callback should not have been called yet
expect(callback).not.toBeCalled();
// Fast-forward until all timers have been executed
jest.runAllTimers();
// Now our callback should have been called!
expect(callback).toBeCalled();
expect(callback).toHaveBeenCalledTimes(1);
});
除此之外还有 jest. advancertimersbytime (1000)
来快进指定的时间,单位是毫秒。
在遇到有嵌套的定时任务时,也就是一个定时器里面创建另一个定时器的场景,此时使用jest.runAllTimers()
会导致无限循环报错。因此 jest 提供了 jest.runOnlyPendingTimers()
来解决这个问题。
Snap Test
Snap test
可以帮我们测试UI是否有意外的改变。
做法是在渲染了UI组件之后,保存一个快照文件,检测他是否与保存在单元测试里的快照文件相匹配。 若两个快照不匹配,测试失败,失败有两种情况: 1.有可能做了意外的更改。 2.或者UI组件已经更新到了新版本(当组件确实要更新到新版本时,也可以执行命令更新快照。)。
快照测试一般是针对代码相对稳定,或针对不方便做断言的场景。
可以通过一个例子来了解快照测试,假设现在要针对一个Link组件做快照测试。
link.test.tsx
import renderer from 'react-test-renderer';
import Link from '../Link';
it('renders correctly', () => {
const tree = renderer
.create(<Link page="http://www.facebook.com">Facebook</Link>)
.toJSON();
expect(tree).toMatchSnapshot();
});
第一次跑这个测试的时候,会在当前测试文件所在的同级目录下创建一个 link.test.tsx.snap
文件
link.test.tsx.snap
exports[`renders correctly 1`] = `
<a
className="normal"
href="http://www.facebook.com"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
Facebook
</a>
后面再跑这个测试的时候,就会拿新生成的snap
跟之前的进行对比,如果HTML结构不匹配,当前测试就会失败。
其实快照测试本质上就是以一次稳定版本的HTML结构为标准,后续不断地去对比结构是否发生了意料之外的变化。
另外所有的快照文件都应该与它们所覆盖的模块及其测试一起提交。它们是测试的一部分,类似于 Jest 中其他断言。快照代表源模块在任何给定时间点的状态。 这样当源模块被修改时,可以知道与以前版本相比发生了什么变化。 它还可以在代码 Review 期间提供许多额外的提示。
快照测试在试用例覆盖程度比较高的时候才能发挥功能。因为项目开发初期,UI会大幅度调整,逻辑也会经常变更,还未达到相对稳定状态,这时看快照测试结果意义不大。所以必须需要经过长时间迭代之后,快照才能趋于稳定。
另外,由于它只是基于"快照",并没有像断言测试一样,有明确的正确/错误判断标准。所以它不会提示代码逻辑错误,所以用来标示测试用例覆盖程度也意义不大。
Mock
Mock 的主要作用是模拟一些在应用中不容易构造或者有副作用的对象,从而把测试与测试边界以外的对象隔离开。
假设现在要测试一个删除数据的Func,我们在测试时肯定不能真的向服务端发起请求,删除数据。这时就需要用到Mock,把请求给Mock掉。
除此之外我们的代码可能会依赖于一些浏览器特有的API等等,受限于 Jest 使用的 JSDom方案(上面有介绍过,Node环境并没有这些浏览器特有的API),在执行测试的时候会直接报错,我们也需要把这些 API Mock掉。
Browser API
就像上面说的那样,由于 Node.js 环境并没有 localStorage
等一些浏览器特有的API。 我们有两种方案去Mock:
- 模拟localStorage实现,然后通过
setupFilesAfterEnv
在运行unit test之前添加全局Mock, 但是我们不可能把浏览器里所有的 API 都 Mock 一遍,而且不可能做到 100% 还原所有功能,所以不建议使用这种。
额外的知识点:setupFilesAfterEnv
它可以指定一个文件,在每执行一个测试文件前都会跑一遍里面的代码。在这个 setup 文件中, 可以放置全局的 Mock 实现,以及一些初始化操作。
js
module.exports = {
setupFilesAfterEnv: ['./tests/jest-setup.ts'],
};
jest-setup.ts
Object.defineProperty(global, xxx , xxx)
- jest 提供了
testEnvironment
配置。在jest.config.js中添加
js
module.exports = {
testEnvironment: "jsdom",
}
添加 jsdom 测试环境后,全局会自动拥有完整的浏览器标准 API。这个库用 JS 实现了一套 Node.js 环境下的 Web 标准 API。
Function
官方的解释是
Mock 函数允许你测试代码之间的连接---包括:
- 擦除函数的实际实现
- 捕获对函数的调用 ( 以及在这些调用中传递的参数)
- 在使用 new 实例化时捕获构造函数的实例、允许测试时配置返回值
我理解的实际场景就是当funcA
里调用了另一个funcB
,在测试funA
时不可能断言funcB
的行为或者执行结果,因为funcB
里面可能还调用了funcC
,所以针对这种情况,我们可以mock FuncB
,不去考虑它的实现,只断言funcB
是否被调用(还可以断言传递的参数是否正确)即可。对于funcB
和funcC
本身应该编写其单独的测试用例。
直接在测试代码中创建一个 mock 函数
假设要测试foreach的实现
foreach.js
export function forEach(items, callback) {
for (let index = 0; index < items.length; index++) {
callback(items[index]);
}
}
我们可以直接 mock 一个函数
forEach.test.js
const forEach = require('./forEach');
const mockCallback = jest.fn(x => 42 + x);
test('forEach mock function', () => {
forEach([0, 1], mockCallback);
// The mock function was called twice
expect(mockCallback.mock.calls).toHaveLength(2);
// The first argument of the first call to the function was 0
expect(mockCallback.mock.calls[0][0]).toBe(0);
// The first argument of thesecond call to the function was 1
expect(mockCallback.mock.calls[1][0]).toBe(1);
// The return value of the first call to the function was 42
expect(mockCallback.mock.results[0].value).toBe(42);
});
还可以控制 mock 函数的返回值
scss
const myMock = jest.fn();
console.log(myMock());
// > undefined
myMock.mockReturnValueOnce(10)
.mockReturnValueOnce('x')
.mockReturnValue(true);
console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true
所有的 mock 函数都有一个特殊的 .mock
属性,它保存了关于此函数如何被调用、调用时的返回值的信息。 .mock
属性还追踪每次调用时 this
的值,所以我们同样可以也检查 this
.
sql
// The function was called exactly once
expect(someMockFunction.mock.calls).toHaveLength(1);
// The first arg of the first call to the function was 'first arg'
expect(someMockFunction.mock.calls[0][0]).toBe('first arg');
// The second arg of the first call to the function was 'second arg'
expect(someMockFunction.mock.calls[0][1]).toBe('second arg');
// The return value of the first call to the function was 'return value'
expect(someMockFunction.mock.results[0].value).toBe('return value');
// The function was called with a certain `this` context: the `element` object.
expect(someMockFunction.mock.contexts[0]).toBe(element);
// This function was instantiated exactly twice
expect(someMockFunction.mock.instances.length).toBe(2);
// The object returned by the first instantiation of this function
// had a `name` property whose value was set to 'test'
expect(someMockFunction.mock.instances[0].name).toBe('test');
// The first argument of the last call to the function was 'test'
expect(someMockFunction.mock.lastCall[0]).toBe('test');
除了 jest.fn
外,还可以使用 mockImplementation
来 mock 函数的实现。
scss
const foo = require('../foo');
// foo is a mock function
foo.mockImplementation(() => 42);
foo();
// > 42
如果想要每次返回不同的调用结果,可以用 mockImplementationOnce
javascript
const myMockFn = jest
.fn()
.mockImplementationOnce(cb => cb(null, true))
.mockImplementationOnce(cb => cb(null, false));
myMockFn((err, val) => console.log(val));
// > true
myMockFn((err, val) => console.log(val));
// > false
当你想在测试的 output 中快速找到你 mock 的 fn, 你还可以 mock name,它相当于给你 mock 的 fn 起了个别名。
ini
const myMockFn = jest
.fn()
.mockReturnValue('default')
.mockImplementation(scalar => 42 + scalar)
.mockName('add42');
Class
跟Function类似,当我们需要测试依赖的Class时,可以Mock这个Class
假如有如下代码需要测试
sound-player.js
export default class SoundPlayer {
constructor() {
this.foo = 'bar';
}
playSoundFile(fileName) {
console.log('Playing sound file ' + fileName);
}
}
sound-player-consumer.js
import SoundPlayer from './sound-player';
export default class SoundPlayerConsumer {
constructor() {
this.soundPlayer = new SoundPlayer();
}
playSomethingCool() {
const coolSoundFileName = 'song.mp3';
this.soundPlayer.playSoundFile(coolSoundFileName);
}
}
Automatic Mock
通过
jest.mock('./sound-payer')
会得到一个Automatic Mock
, 可以监听这个模拟上 constructor 的调用以及它所有方法的调用。 它将会使用一个模拟的 constructor 替换原先的 ES6 类,以及使用返回 undefined 的模拟函数替换掉这个类上所有的方法。这些方法的调用会保存在theAutomaticMock.mock.instances[index].methodName.mock.calls
。
上面是官方文档对Automatic Mock的介绍,看起来有点难以理解,我们简单拆分一下,其实就是做了三件事
- 通过对class的引用,可以获取里面的func等等,然后mock这个类。
- 重写源类中的func(删除func内所有代码),全部
return undefined
。 - 调用这些func的时候,调用记录和调用时传递的参数都记录到
mock.instance
里面。
这样我们就可以通过断言去测试这些func是不是被调用,参数是不是跟预期的一致。这里还有一些要注意就是箭头函数不会被mock,官方文档里说的很清楚
If you use arrow functions in your classes, they will not be part of the mock. 这样做的原因是箭头函数不能代表一个对象的原型,它们仅仅是是一个函数的引用。
下面来看具体的测试用例
scss
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
jest.mock('./sound-player'); // SoundPlayer is now a mock constructor
beforeEach(() => {
// Clear all instances and calls to constructor and all methods:
SoundPlayer.mockClear();
});
it('We can check if the consumer called the class constructor',
() => {
const soundPlayerConsumer = new SoundPlayerConsumer();
expect(SoundPlayer).toHaveBeenCalledTimes(1);
});
it('We can check if the consumer called a method on the class in stance', () => {
// Show that mockClear() is working:
expect(SoundPlayer).not.toHaveBeenCalled();
const soundPlayerConsumer = new SoundPlayerConsumer();
// Constructor should have been called again:
expect(SoundPlayer).toHaveBeenCalledTimes(1);
const coolSoundFileName = 'song.mp3';
soundPlayerConsumer.playSomethingCool();
// mock.instances is available with automatic mocks:
const mockSoundPlayerInstance = SoundPlayer.mock.instances[0];
const mockPlaySoundFile = mockSoundPlayerInstance.playSoundFile;
expect(mockPlaySoundFile.mock.calls[0][0]).toBe(coolSoundFileName);
// Equivalent to above check:
expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
expect(mockPlaySoundFile).toHaveBeenCalledTimes(1);
});
如果不需要替换class实现的话,Automatic Mock
是最简单的方式。
Manual Mock
在_mocks_文件夹下,可以创建一个手动模拟,指定class实现,这个实现将被test文件使用,取代真实的class。
做法就是在项目根目录新建一个 名为_mock_
的文件夹,假如你需要 Mock Class A,A 的文件名是 a.js
, 那就在 _mock_
文件夹里新建一个文件名为 a.js
的文件,在你的单元测试文件内正常 import a.js
原有的路径即可。
在运行单元测试时,jest会查找 _mock_
文件夹内有没有同名文件,有就进行替换。 这里要注意_mock_
是一个命名约定,不能随意更换。而且下面的文件名需要与你 mock 的类同名。
mocks/sound-player.js
ini
export const mockPlaySoundFile = jest.fn();
const mock = jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
export default mock;
在引用时使用原来的模块地址 import 就可以了,不需要包含 mocks。
sound-player-consumer.test.js
scss
import SoundPlayer, {mockPlaySoundFile} from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
jest.mock('./sound-player'); // SoundPlayer is now a mock constructor
beforeEach(() => {
// Clear all instances and calls to constructor and all methods:
SoundPlayer.mockClear();
mockPlaySoundFile.mockClear();
});
it('We can check if the consumer called the class constructor', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
expect(SoundPlayer).toHaveBeenCalledTimes(1);
});
it('We can check if the consumer called a method on the class instance', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
const coolSoundFileName = 'song.mp3';
soundPlayerConsumer.playSomethingCool();
expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
});
ModuleFactory
jest.mock(path, moduleFactory) 能接收 模块工厂 参数。 模块工厂是一个函数,这个函数会返回 mock。
javascript
import SoundPlayer from './sound-player';
const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
});
这里要注意的一点是 const mockPlaySoundFile = jest.fn();
命名必须以mock开头。 由于我们之前说过的jest.mock存在提升问题,会被提升到文件顶部执行,真实的执行顺序其实是下面这样的。
javascript
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
});
import SoundPlayer from './sound-player';
const mockPlaySoundFile = jest.fn();
正常情况下此时 mockPlaySoundFile
访问不到,会直接报错,但 jest 约定了会跳过 mock
开头的变量检查。
除此之外我们还可以在beforeAll()里面为不同的用例,mock不同的实现。
javascript
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
jest.mock('./sound-player');
describe('When SoundPlayer throws an error', () => {
beforeAll(() => {
SoundPlayer.mockImplementation(() => {
return {
playSoundFile: () => {
throw new Error('Test error');
},
};
});
});
it('Should throw an error when calling playSomethingCool', () => {
const soundPlayerConsumer = new SoundPlayerConsumer();
expect(() => soundPlayerConsumer.playSomethingCool()).toThrow();
});
});
除了 mockImplementation()
外还有 mockImplementationOnce()
可选,感兴趣的可以去看下 官方文档
javascript
const mockFn = jest
.fn()
.mockImplementationOnce(cb => cb(null, true))
.mockImplementationOnce(cb => cb(null, false));
mockFn((err, val) => console.log(val)); // true
mockFn((err, val) => console.log(val)); // false
对于非 default export 的类,这个时候需要返回一个对象,这个对象上有一个 key 和这个类的名字要一样。
javascript
import {SoundPlayer} from './sound-player';
jest.mock('./sound-player', () => {
return {
SoundPlayer: jest.fn().mockImplementation(() => {
return {playSoundFile: () => {}};
}),
};
});
SpyOn
比较推荐的做法是通过spyOn来直接替换某个方法的实现。
javascript
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
const playSoundFileMock = jest
.spyOn(SoundPlayer.prototype, 'playSoundFile')
.mockImplementation(() => {
console.log('mocked function');
}); // comment this line if just want to "spy"
it('player consumer plays music', () => {
const player = new SoundPlayerConsumer();
player.playSomethingCool();
expect(playSoundFileMock).toHaveBeenCalled();
});
对于 static or getter and setter methods
javascript
export default class SoundPlayer {
constructor() {
this.foo = 'bar';
}
playSoundFile(fileName) {
console.log('Playing sound file ' + fileName);
}
get foo() {
return 'bar';
}
static brand() {
return 'player-brand';
}
}
test.js
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
const staticMethodMock = jest
.spyOn(SoundPlayer, 'brand')
.mockImplementation(() => 'some-mocked-brand');
const getterMethodMock = jest
.spyOn(SoundPlayer.prototype, 'foo', 'get')
.mockImplementation(() => 'some-mocked-result');
it('custom methods are called', () => {
const player = new SoundPlayer();
const foo = player.foo;
const brand = SoundPlayer.brand();
expect(staticMethodMock).toHaveBeenCalled();
expect(getterMethodMock).toHaveBeenCalled();
});
Module
Axios
对于模块的 mock,这里我们以 axios
为例子。 假定有个从 API 获取用户数据的类。 该类用 axios
向服务端发起请求,然后返回 data,其中包含所有用户的属性:
users.js
import axios from 'axios';
class Users {
static all() {
return axios.get('/users.json').then(resp => resp.data);
}
}
export default Users;
在单元测试中我们肯定要避免真的发起请求,这时候可以 mock axios,返回假数据用于测试
users.test.js
import axios from 'axios';
import Users from './users';
jest.mock('axios');
test('should fetch users', () => {
const users = [{name: 'Bob'}];
const resp = {data: users};
// TS环境下会报类型错误
axios.get.mockResolvedValue(resp);
// 你也可以使用下面这样的方式:
axios.get.mockImplementation(() => Promise.resolve(resp))
return Users.all().then(data => expect(data).toEqual(users));
});
如果请求方式都一致,可通过 url 再细分,返回不同的 response
javascript
axios.get.mockImplementation(url => {
switch (url) {
case '/url-1':
return Promise.resolve(response1)
case '/url-2':
return Promise.resolve(response2)
...
}
})
但是上面 axios.get.mockResolvedValue(resp);
这种写法在使用了ts环境下会报错,因为axios.get
并没有mockResolvedValue
这个方法,所以在ts环境下,可以用
scss
jest.spyOn(axios, 'get').mockResolvedValue(resp);
或者
ini
// 含有 jest 的类型提示
const mockedGet = jest.mocked(axios.get);
mockedGet.mockResolvedValue(resp);
来替代。
Partical Module
有时候我们 module export 不止一个,但像 axios 那个例子里面是一整个模块都被 mock 了。
foo-bar-baz.js
export const foo = 'foo';
export const bar = () => 'bar';
export default () => 'baz';
因此我们可以通过 mock module 实现,然后return 一个具体对象的方式,从而做到部分 mock。
test.js
import defaultExport, {bar, foo} from '../foo-bar-baz';
jest.mock('../foo-bar-baz', () => {
const originalModule = jest.requireActual('../foo-bar-baz');
//Mock the default export and named export 'foo'
return {
__esModule: true,
...originalModule,
default: jest.fn(() => 'mocked baz'),
foo: 'mocked foo',
};
});
test('should do a partial mock', () => {
const defaultExportResult = defaultExport();
expect(defaultExportResult).toBe('mocked baz');
expect(defaultExport).toHaveBeenCalled();
expect(foo).toBe('mocked foo');
expect(bar()).toBe('bar');
});
HTTP Request
就像上面 Mock Module中提到的,项目中不可避免的会涉及到Http请求,我们在针对包含 Http 请求的代码进行测试时,必须要把相关的请求给 Mock 掉。这里我们针对不同的情况进行介绍。
Axios
针对 axios 的 mock 上面已经介绍过了。
Mock Request Func
假设现在有个请求,需要获取用户角色信息
apis/user.ts
export const getUserRole = async () => {
return axios.get<GetUserRoleRes>("https://mysite.com/api/role");
};
我们可以简单粗暴的通过 spyOn
直接 Mock apis/user.ts
里的 getUserRole
方法。
php
import * as userUtils from "apis/user";
jest.spyOn(userUtils, "getUserRole").mockResolvedValueOnce({
data: { userType: "admin" },
} as AxiosResponse);
Mock Http
假设现在有个请求,需要获取用户角色信息
apis/user.ts
export const getUserRole = async () => {
return axios.get<GetUserRoleRes>("https://mysite.com/api/role");
};
我们可以不 Mock 任何函数实现,只对 Http 请求进行 Mock。
但是需要安装 msw
,msw (Mock Service Worker) 可以拦截指定的 Http 请求,有点类似 Mock.js
,是做测试时一个非常强大好用的 Http Mock
工具。
- 安装
msw
后,新建一个mockServer文件夹,在下面添加handlers.ts
文件,该文件默认export
所有 Http 请求的Mock Handler
dart
import { rest } from "msw";
const handlers = [
rest.get("https://mysite.com/api/role", async (req, res, ctx) =>
{
return res(
ctx.status(200),
ctx.json({
userType: "user",
})
);
}),
];
export default handlers;
- 新建
mockServer/server.ts
,使用上面导出的handles
javascript
import { setupServer } from "msw/node";
import handlers from "./handlers";
const server = setupServer(...handlers);
export default server;
- 在
tests/jest-setup.ts
里使用 mockServer
scss
import server from "./mockServer/server";
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
- 然后在 jest.config.js 里添加 setupFilesAfterEnv 配置:
ini
module.exports = {
setupFilesAfterEnv: ['./tests/jest-setup.ts'],
};
setupFileAfterEnv
配置的文件会在jest被引入之后被执行。可以用来在测试开始前,mock一些全局通用的类或方法等,与它相似的还有一个 setupFiles
是在引入了测试环境之后执行。
- 准备测试环境
- setupFiles
- 引入测试框架(Jest)
- setupFileAfterEnv
- 执行xxx.test文件
这样一来,在所有测试用例中都能获得 handlers.ts 里的 Mock 返回了。如果你想在某个测试文件中想单独指定某个接口的 Mock 返回, 可以使用 server.use(mockHandler)
来实现。
声明一个 setup 函数,用于在每个用例前初始化 Http 请求的 Mock 返回。通过传不同值给 setup 就可以灵活模拟测试场景了。
dart
import server from "./mockServer/server";
const setup = (userType) => {
server.use(
rest.get("https://mysite.com/api/role", async (req, res, ctx)
=> {
return res(ctx.status(200), ctx.json({ userType }));
})
);
};
it("user role is correct", async () => {
setup("admin");
// do request and expect something
});
这是最推荐的一种mock方式。
Mock扩展知识
针对对象中的属性,可以通过Object.definePropertity
来mock
ini
export const env = 'test';
test.ts
javascript
import * as envUtils from 'utils/env';
const originEnv = envUtils.env;
describe("env", () => {
afterEach(() => {
Object.defineProperty(envUtils, 'env', {
value: originEnv,
writable: true,
})
})
it('开发环境', () => {
Object.defineProperty(envUtils, 'env', {
value: 'dev',
writable: true,
})
expect(envUtils.env).toEqual('dev');
})
it('正式环境', () => {
Object.defineProperty(envUtils, 'env', {
value: 'prod',
writable: true,
})
expect(envUtils.env).toEqual('prod');
})
});
要注意的是,使用Object.defineProperty
,需要在最开始记录 env 的值,然后加一个 afterEach
在执行每个用例后又赋值回去,否则会造成用例之间的污染。
Mock提升
jest.mock
和 jest.unmock
是一对非常特殊的 API,它们会被提升到所有 import 前。也就是说,测试代码看起是先 import 再 mock,而真实情况是,先 mock 了,再 import。
由于jest.mock
是会被提升的,也就是一次性mock,但在实际代码中,可能会遇到需要多次 mock 一个 func 或 class 的情况。
举个例子
javascript
export const config = {
getEnv() {
// 很复杂的逻辑...
return 'test'
}
}
假如我们想测试一下不同环境下的一些行为:
javascript
describe('环境', () => {
it('开发环境', () => {
// Mock config.getEnv => 'dev'
// ...
})
it('正式环境', () => {
// Mock config.getEnv => 'prod'
// ...
})
})
jest 提供了另一个 API jest.doMock
,它也会执行 Mock 操作,但是不会被提升。利用这个特性再加上内联 require 就可以实现多次 Mock 的效果了。
javascript
describe("doMock config", () => {
beforeEach(() => {
// 这里一共引用了两次 utils/env,因此要用 jest.resetModules 来重置前一次引入的模块内容。
jest.resetModules();
})
it('开发环境', () => {
jest.doMock('utils/env', () => ({
__esModule: true,
config: {
getEnv: () => 'dev'
}
}));
const { config } = require('utils/env');
expect(config.getEnv()).toEqual('dev');
})
it('正式环境', () => {
jest.doMock('utils/env', () => ({
__esModule: true,
config: {
getEnv: () => 'prod'
}
}));
const { config } = require('utils/env');
expect(config.getEnv()).toEqual('prod');
})
});
上面的doMock
写起来比较复杂,还有一种方式是通过我们前面提到过的jest.spyOn
javascript
export const config = {
getEnv() {
// 很复杂的逻辑...
return 'test'
}
}
test.ts
import { config } from "utils/env";
describe("spyOn config", () => {
it('开发环境', () => {
jest.spyOn(config, 'getEnv').mockReturnValue('dev')
expect(config.getEnv()).toEqual('dev');
})
it('正式环境', () => {
jest.spyOn(config, 'getEnv').mockReturnValue('prod')
expect(config.getEnv()).toEqual('prod');
})
});
还可以用 spyOn 来监听函数以及对象属性的 getter 来修改返回值
jest.spyOn(envUtils, 'env', 'get').mockReturnValue('dev')
对于直接导出的函数 export const getEnv = () => 'test'
, 直接转成对象就可以继续用spyOn了
test.ts
import * as envUtils from 'utils/env';
describe("getEnv", () => {
it('开发环境', () => {
jest.spyOn(envUtils, 'getEnv').mockReturnValue('dev')
expect(envUtils.getEnv()).toEqual('dev')
})
it('正式环境', () => {
jest.spyOn(envUtils, 'getEnv').mockReturnValue('prod')
expect(envUtils.getEnv()).toEqual('prod')
})
});
Matcher
介绍几个常用的断言匹配器, 更多Matcher可以参考 官方文档。
toBe
expect(can.name).toBe('pamplemousse');
toContain
expect(getAllFlavors()).toContain('lime');
toContainEqual
验证数组值是否一致,会递归地检查每个值是否一致,而不只是对比引用
ini
describe('my beverage', () => {
test('is delicious and not sour', () => {
const myBeverage = {delicious: true, sour: false};
expect(myBeverages()).toContainEqual(myBeverage);
});
});
toHaveBeenCalled
至少被调用一次
expect(mockFunc).toHaveBeenCalled();
toBeGreaterThan(number)
至少被调用 x 次
expect(mockFunc.mock.calls.length).toBeGreaterThan(2);
toHaveBeenCalledWith
使用指定的参数至少调用了一次该函数
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);
toHaveBeenCalledTimes(number)
被调用了确切的次数
expect(callback).toHaveBeenCalledTimes(1);
React
你的测试越接近软件的使用方式,这些测试越能给你信心 --- Kent C. Dodds
Dom/Component
要测试 Dom 或者 Component,首先需要能获取到对应的dom内容,在 jest 中,我们可以使用相关的库来支持这些功能, @testing-library/react
和 enzyme
这两个库它们都提供了在 jsDOM 中渲染组件的方法,可以让我们对渲染结果进行断言。
- @testing-library/react是一个非常轻量级的React组件测试库,你可以用它提供的api,通过 text 或者 id 等等查找到页面具体的元素,然后模拟用户跟这个元素去交互(包括输入文本,点击等等),模拟真实用户使用应用程序从而进行测试。
添加 @testing-library/react
yarn add --dev @testing-library/react
下面是官方文档给出的例子
CheckboxWithLabel.jsx
import {useState} from 'react';
export default function CheckboxWithLabel({labelOn, labelOff}) {
const [isChecked, setIsChecked] = useState(false);
const onChange = () => {
setIsChecked(!isChecked);
};
return (
<label>
<input type="checkbox" checked={isChecked} onChange={onChange} />
{isChecked ? labelOn : labelOff}
</label>
);
}
CheckboxWithLabel-test.js
import {cleanup, fireEvent, render} from '@testing-library/react';
import CheckboxWithLabel from '../CheckboxWithLabel';
it('CheckboxWithLabel changes the text after click', () => {
const {queryByLabelText, getByLabelText} = render(
<CheckboxWithLabel labelOn="On" labelOff="Off" />,
);
expect(queryByLabelText(/off/i)).toBeTruthy();
fireEvent.click(getByLabelText(/off/i));
expect(queryByLabelText(/on/i)).toBeTruthy();
});
- enzyme React官方并不推荐使用这个库,好像是因为这个库对React新特性的支持并不好,而且现存很多问题。这里不做过多介绍,简单放一个例子。
添加 enzyme
yarn add --dev enzyme
CheckboxWithLabel-test.js
import Enzyme, {shallow} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import CheckboxWithLabel from '../CheckboxWithLabel';
Enzyme.configure({adapter: new Adapter()});
it('CheckboxWithLabel changes the text after click', () => {
// Render a checkbox with label in the document
const checkbox = shallow(<CheckboxWithLabel labelOn="On" labelOff="Off" />);
expect(checkbox.text()).toBe('Off');
checkbox.find('input').simulate('change');
expect(checkbox.text()).toBe('On');
});
Redux
对于Redux的测试, 官方是这么说的:
我们建议通过集成测试来测试使用 Redux 的 React 组件,这些测试包括断言页面的展示和交互是否正常,旨在验证当用户以给定方式与应用程序交互时,应用程序的行为符合预期。
官方针对使用 Redux 的应用编写测试的建议是:
倾向于编写整体性的集成测试。对于使用 Redux 的 React 应用,使用一个真实的 store 实例包裹被测试的组件来渲染一个
Provider
。把Redux中的 API 调用 mock 掉,然后按照真实的用户行为进行测试(比如click事件后触发某个reducer),这样你的应用代码就不需要改变,只需要断言 UI 是否被正确更新。
如果需要,可以对纯函数(例如复杂的 reducer 或 selector)进行基本的单元测试。不过更多情况下,这些实现细节已经被集成测试覆盖了。
不要尝试 mock selector 函数或 react-redux 的钩子函数!因为太多了!而且会让你对测试的正确性丧失信心。
集成测试的关键点有 2 个:
- 像真实用户那样去和组件交互
- Mock Http 请求(外部依赖)
假设现在有一个展示用户信息的组件 <UserDisplay />
,这个组件里用到了Redux,在reducer里存在一个获取用户信息的api。
首先添加用于测试的自定义 render 函数, 自定义 render 的作用就是: 创建一个使用 redux 的环境,用 <Wrapper />
包裹传入的业务组件,并且可以让我们决定当前 redux 的初始状态。 然后在测试时,我们就可以使用自定义的 render 来渲染组件。
utils/test-utils.tsx
import React, { PropsWithChildren } from 'react'
import { render } from '@testing-library/react'
import type { RenderOptions } from '@testing-library/react'
import { configureStore } from '@reduxjs/toolkit'
import type { PreloadedState } from '@reduxjs/toolkit'
import { Provider } from 'react-redux'
import type { AppStore, RootState } from '../app/store'
// 这个 interface 扩展了 RTL(react test library) 的默认 render 选项,同时允许用户指定其他选项,例如 initialState 和 store
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: PreloadedState<RootState>
store?: AppStore
}
export function renderWithProviders(
ui: React.ReactElement,
{
preloadedState = {},
// 自动创建一个 store 实例,如果没有传入 store
store = configureStore({ reducer, preloadedState }),
...renderOptions
}: ExtendedRenderOptions = {}
) {
function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element {
return <Provider store={store}>{children}</Provider>
}
// 返回一个对象,其中包含 store 和所有的 RTL 查询函数
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
}
使用渲染函数进行测试
features/users/tests/UserDisplay.test.tsx
import React from 'react'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { fireEvent, screen } from '@testing-library/react'
// 我们使用自定义的 render 函数,而不是 RTL 的 render
import { renderWithProviders } from '../../../utils/test-utils'
import UserDisplay from '../UserDisplay'
// 我们使用 msw 来拦截测试期间的网络请求,并在 150ms 后返回响应 'John Smith'
// 当收到对 `/api/user` 端点的 get 请求时
export const handlers = [
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.json('John Smith'), ctx.delay(150))
})
]
const server = setupServer(...handlers)
// 在所有测试开始之前启用 API mock
beforeAll(() => server.listen())
// 在每个测试之后关闭 API mock
afterEach(() => server.resetHandlers())
// 所有测试结束时关闭 API mock
afterAll(() => server.close())
test('fetches & receives a user after clicking the fetch user button', async () => {
renderWithProviders(<UserDisplay />)
// 初始时,应该展示 'No user',并且不在获取用户信息
expect(screen.getByText(/no user/i)).toBeInTheDocument()
expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()
// 点击 'Fetch user' 按钮后,应该展示 'Fetching user...'
fireEvent.click(screen.getByRole('button', { name: /Fetch user/i }))
expect(screen.getByText(/no user/i)).toBeInTheDocument()
// 在一段时间后,应该收到用户信息
expect(await screen.findByText(/John Smith/i)).toBeInTheDocument()
expect(screen.queryByText(/no user/i)).not.toBeInTheDocument()
expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()
})
这个集成测试主要做了这么几件事:
- 使用 msw Mock Http 请求
- 渲染
<UserDisplay />
组件 - 用
fireEvent
模拟用户操作,点击按钮获取用户信息 - 断言页面展示是否正常
Hook
本章节内容来自 某大佬的文章
src/hooks/useCounter.ts
import { useState } from "react";
export interface Options {
min?: number;
max?: number;
}
export type ValueParam = number | ((c: number) => number);
function getTargetValue(val: number, options: Options = {}) {
const { min, max } = options;
let target = val;
if (typeof max === "number") {
target = Math.min(max, target);
}
if (typeof min === "number") {
target = Math.max(min, target);
}
return target;
}
function useCounter(initialValue = 0, options: Options = {}) {
const { min, max } = options;
const [current, setCurrent] = useState(() => {
return getTargetValue(initialValue, {
min,
max,
});
});
const setValue = (value: ValueParam) => {
setCurrent((c) => {
const target = typeof value === "number" ? value : value(c);
return getTargetValue(target, {
max,
min,
});
});
};
const inc = (delta = 1) => {
setValue((c) => c + delta);
};
const dec = (delta = 1) => {
setValue((c) => c - delta);
};
const set = (value: ValueParam) => {
setValue(value);
};
const reset = () => {
setValue(initialValue);
};
return [
current,
{
inc,
dec,
set,
reset,
},
] as const;
}
export default useCounter;
这个 Hook 很简单,就是经典的计数器,拥有增加、减少、设置和重置 4 个操作。
有些同学会觉得 hook 不就是纯函数么?为什么不能直接像纯函数那样去测呢?
这是由于hook里用到了 useState
,而 React 规定 只有在组件中才能使用这些 Hooks,所以这样测试的结果就是会直接报错。
当然我们可以通过 mock 来处理掉 useState
,但 hook 太多了,我们不可能 mock 每一个 React 提供的 API。
测试的初衷是为了带我们带来强大的代码信心,我们在编写测试时,需要时刻避免过度关注测试代码的细节。
因此可以把 hook 放在组件里进行测试,来模仿真实使用hook的场景。
比较常见的做法是,我们可以编写一个组件,在组件里放一些Button,通过把要测试的hook绑定到 click 事件上,然后模拟触发 Click 事件,来测试这些hook。
javascript
import useCounter from "hooks/useCounter";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
// 测试组件
const UseCounterTest = () => {
const [counter, { inc, set, dec, reset }] = useCounter(0);
return (
<section>
<div>Counter: {counter}</div>
<button onClick={() => inc(1)}>inc(1)</button>
<button onClick={() => dec(1)}>dec(1)</button>
<button onClick={() => set(10)}>set(10)</button>
<button onClick={reset}>reset()</button>
</section>
);
};
describe("useCounter", () => {
it("可以做加法", async () => {
render(<UseCounterTest />);
const incBtn = screen.getByText("inc(1)");
await userEvent.click(incBtn);
expect(screen.getByText("Counter:1")).toBeInTheDocument();
});
it("可以做减法", async () => {
render(<UseCounterTest />);
const decBtn = screen.getByText("dec(1)");
await userEvent.click(decBtn);
expect(screen.getByText("Counter: -1")).toBeInTheDocument();
});
it("可以设置值", async () => {
render(<UseCounterTest />);
const setBtn = screen.getByText("set(10)");
await userEvent.click(setBtn);
expect(screen.getByText("Counter: 10")).toBeInTheDocument();
});
it("可以重置值", async () => {
render(<UseCounterTest />);
const incBtn = screen.getByText("inc(1)");
const resetBtn = screen.getByText("reset()");
await userEvent.click(incBtn);
await userEvent.click(resetBtn);
expect(screen.getByText("Counter:0")).toBeInTheDocument();
});
});
上面这种做法还是有些麻烦,需要添加 button 还要绑定事件,我们还可以直接操作 inc, dec, set 和 reset 这几个函数。
我们可以创建一个 setup 函数,在里面生成组件,然后把 useCounter 的结果返回出来就可以了:
下面代码中的act
方法 是为了确保回调里的异步逻辑走完再执行后续代码
ini
import useCounter from "hooks/useCounter";
import { act, render } from "@testing-library/react";
import React from "react";
const setup = (initialNumber: number) => {
const returnVal = {};
const UseCounterTest = () => {
const [counter, utils] = useCounter(initialNumber);
Object.assign(returnVal, {
counter,
utils,
});
return null;
};
render(<UseCounterTest />);
return returnVal;
};
describe("useCounter", () => {
it("可以做加法", async () => {
const useCounterData: any = setup(0);
// 为了确保回调里的异步逻辑走完再执行后续代码
act(() => {
useCounterData.utils.inc(1);
});
expect(useCounterData.counter).toEqual(1);
});
it("可以做减法", async () => {
const useCounterData: any = setup(0);
act(() => {
useCounterData.utils.dec(1);
});
expect(useCounterData.counter).toEqual(-1);
});
it("可以设置值", async () => {
const useCounterData: any = setup(0);
act(() => {
useCounterData.utils.set(10);
});
expect(useCounterData.counter).toEqual(10);
});
it("可以重置值", async () => {
const useCounterData: any = setup(0);
act(() => {
useCounterData.utils.inc(1);
useCounterData.utils.reset();
});
expect(useCounterData.counter).toEqual(0);
});
});
上面这种做法,其实 @testing-library
已经把上面的步骤封装成了一个公共函数 renderHook
。
tests/hooks/useCounter/renderHook.test.ts
import { renderHook } from "@testing-library/react-hooks";
import useCounter from "hooks/useCounter";
import { act } from "@testing-library/react";
describe("useCounter", () => {
it("可以做加法", () => {
const { result } = renderHook(() => useCounter(0));
const [value, hooks] = result.current;
act(() => {
hooks.inc(1);
});
expect(value).toEqual(1);
});
it("可以做减法", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current[1].dec(1);
});
expect(result.current[0]).toEqual(-1);
});
it("可以设置值", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current[1].inc(10);
});
expect(result.current[0]).toEqual(10);
});
it("可以重置值", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current[1].inc(1);
result.current[1].reset();
});
expect(result.current[0]).toEqual(0);
});
});
总结一下 React Hook 的测试方法:
- 声明 setup,在里面通过渲染测试组件为 React Hook 提供 React 组件环境
- 把 React Hook 的返回结果返回给每个用例
- 每个用例从 setup 返回结果中拿到 React Hook 的返回值,并对其进行测试
Test coverage
当你 run test后,正常情况在控制台里会得到如下输出
- %stmts 语句覆盖率(statement coverage):是不是每个语句都执行了
- %Branch 分支覆盖率(branch coverage):是不是每个if条件分支代码块都执行了
- %Funcs 函数覆盖率(function coverage):是不是每个函数都调用了
- %Lines 代码行覆盖率(line coverage):是不是每一行都执行了
这里有必要解释下 stmts和lines的区别,我当初看到这里是一脸问号,每个语句和每一行没啥区别啊,Google后我看到一个回答
大致意思就是说,一行可以有多个可执行语句。
如果觉得控制台的覆盖率看着不舒服,可以在 test 的 script 脚本里 加上一句 --coverage
,这样当你 run test 之后,项目根目录下会生成一个叫 coverage 文件夹。
里面有一个 index.html
文件 ,可以直接用浏览器打开,它提供了一个更友好的覆盖率展示页面。
Trouble Shooting
异常调试有两种方案,第一个是按照 官方文档 的方法。
在项目下的.vscode文件夹下面,新建launch.json文件,把文档中给出的配置json 贴进去。然后正常打断点,然后在vscode中按F5进行调试即可。
第二种是添加VsCode的插件,打完断点,通过插件的功能直接run debugger。
- Jest
- Jest Runner
Run single test
我们可以通过配置 test match 参数来实现 run 某个模块某个用例的单侧,可以参考 官方文档, 不过最简单的办法是通过添加 VsCode 插件来实现。
- Jest
- Jest Runner
插件可以帮我们省去复杂的配置步骤。
Performance Optimization
本章节内容转自 某大佬的文章,可以点击直达原文查看。
要解决 Jest 的性能问题,我们得了解一下 Jest 是怎么运行的。之前偶然在 YouTube (opens new window)上看到 Jest 作者非常详细地讲述整个 Jest 执行流程,在这里我只做了一下简单地搬运。英语比较好的同学可以直接看视频进行了解。 从上图可以看到,最影响 Jest 性能的有 3 个地方:
- 使用
jest-haste-map
生成虚拟文件系统 - 多线程执行测试任务
- 转译 JavaScript 代码
虚拟文件系统
如果要在热更新时修改文件,脚手架都要遍历一次项目文件,非常损耗性能。特别在一些文件特别多的 应用中,电脑分分钟就卡得动不了。
为了解决这个问题,Facebook 团队就想到了一个方法 ------ 虚拟文件系统 。原理很简单:在第一次启动时遍历整个项目,把文件存储成 Map 的形式, 之后文件做了改动,那么只需增量地修改这个 Map 就可以了。 他们把这个工具命名为 Haste Map,中文翻译可以理解为快速生成 Map 的东西)。
这种思路不仅可以用于热更新场景,还能应用在所有监听文件改动的场景,其中一种就是 npx jest --watch
这个场景。
因此,上面图中刚开始时,Jest 就用 jest-haste-map
生成了一次虚拟文件系统,这样后续的过滤、搜索文件就非常快速了。这也是为什么执行第一个测试用例时速度比较慢的原因。 这一步的性能我们无法优化。
多线程
Jest 还有一个非常强大的功能,利用 Node.js 的 Worker 开启多个线程来执行测试用例。对于一些大型项目(几千个测试用例)来说,这能提升不少效率。
但线程不是越多越好,每开一个线程都需要额外的开销。如果不做任何配置,那么 Jest 默认最大的 Worker 数是 CPU 数 - 1
。其中的 1
用于运行 jest-cli
, 剩下的都拿来执行测试用例。由于之前我们一直没有对 maxWorkers
进行配置,所以默认会用最多的 Worker,执行这么几十个简单的测试会非常慢。
通常来说,单个测试用例速度应该要做到非常快的,尽量不写一些耗时的操作,比如不要加 setTimeout
,n
个 for
循环等。 所以,理论上,测试数量不多的情况下单线程就足够了。这里我们可以把 jest.config.js
配置改为用单线程:
jest.config.js
module.exports = {
maxWorkers: 1
}
在流水线中,Jest 也推荐使用单线程来跑单测和集成测试:jest --runInBand
,其中 runInBand
和 maxWorkers: 1
效果是一样的。
WARNING
我试了一下在以前的 Intel Mac 里单线程的速度比多线程快了一倍,而 M1 的 Mac 上则是相反,多线程比单线程快。所以,还是要自己的机器的情况来决定使用多少个 Worker。
M1 Macbook Pro,单线程:
M1 Macbook Pro,多线程:
文件转译
最后一个性能优化点就是转译速度(图中第 11 步)。需要注意的是 Jest 是会边执行测试用例边转译 JavaScript。
有的同学会问了:既然 Jest 刚开始遍历项目来生成虚拟文件系统,为什么不顺便把转译的工作做了呢?当然是因为慢了。 首先,对于很多业务项目来说,测试并不会很多。可能就测几个 utils
下的函数,那如果把项目的文件都转译一次,会把很多没用到测试的业务代码也转译。
这些同学还不甘心:那可以在拿到测试文件后,分析出这个文件的依赖,再来做转译(在第 7,8 步)了,然后再执行测试呀?理论上是可以的。但是, JavaScript 引入模块的方式实在是太多了 ,先不说 amd
, es6
, umd
, cmd
, abcd
这么多的引入方式了,单单这个就很难处理:
typescript
// ├── index.ts
// └── instances
// ├── api1.ts
// ├── api2.ts
// ├── api3.ts
// └── api4.ts
// index.ts
const services = (require as any).context('./instances', false, /.*/)
console.log(services); // api1, api2, api3, api4
所以说,通过文件找依赖的方式不是很可靠,有太多不确定因素,最终 Jest 还是选择 "执行到那个文件再做转译" 的方法。
原理说完了,下面来看看怎么提高转译效率。在前面的章节里,我们说到当今 JavaScript 的转译器有很多种,不仅可以用 tsc
和 babel
来转, 还能用别的语言写的转译器 swc
以及 esbuild
来转。
如果想用 esbuild
做转译,可以看 esbuild-jest (opens new window)这个库。这里我用 @swc/jest (opens new window) 做例子, 先安装依赖:
kotlin
npm i -D @swc/core@1.2.165 @swc/jest@0.2.20
然后在 jest.config.js
里添加:
java
module.exports = {
// 不用 ts-jest
// preset: "ts-jest",
transform: {
// 使用 swc 转译 JavaScript 和 TypeScrit
"^.+\.(t|j)sx?$": ["@swc/jest"],
// 静态资源 stub 转译
".+\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$":
"jest-transform-stub",
},
}
配置非常简单,我们来看看使用 ts-jest
以及 @swc/jest
两者的对比。
ts-jest:
@swc/jest:
总结一下Jest 运行过程中 有 3 个地方比较耗性能:
- 生成虚拟文件系统。 在执行第一个测试会很慢
- 多线程。 生成新线程耗费的资源,不过,不同机器的效果会不一致
- 文件转译。 Jest 会在执行到该文件再对它进行转译
解决的方法有:
- 无解,有条件的话拆解项目吧
- 具体情况具体分析,要看机器的执行情况,多线程快就用多线程,单线程快就用单线程
- 使用
esbuild-jest
、@swc/jest
等其它高效的转译工具来做转译
Jest实践
在整理这篇文章的过程中,我发现社区似乎很少有关于单元测试实践的文章,大多数都是介绍下用法,所以后面我会尝试出一篇Jest在实际项目中的实践(可能也不是很规范,因为我也是单侧萌新),从CRUD这种我们最常见的业务场景起步。