单元测试1-基础、选型、落地
单元测试其实只是前端测试的一种。前端测试分为单元测试,UI 测试,集成测试和端到端测试。
前端测试分类
- 单元测试:是指对软件中的最小可测试单元进行检查和验证,通常指的是独立测试单个函数,属于白盒测试。
- UI 测试:是对图形交互界面的测试。
- 集成测试:就是测试应用中不同模块如何集成,如何一起工作,这和它的名字一致。
- 端到端测试(e2e/开端到末端):End-to-End是站在用户角度的测试,把我们的程序看成是一个黑盒子,我不懂你内部是怎么实现的,我只负责打开浏览器,把测试内容在页面上输入一遍,看是不是我想要得到的结果。
认识单元测试
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义。
目的就是保证你写的模块能够完成一定任务并且不出现bug.
单一职责原则的一个具体体现,如果在你的代码测试过程中,需要require多个模块时,这说明你测试的主体模块的耦合性比较高,这也是提醒你进行重构的flag
在 web 前端领域,单元测试通常包括:对某个 JS 的方法进行测试,对某个组件进行测试。除了单元测试,前端经常会有端到端测试。相对于端到端测试来说,单元测试编写更复杂。但是完整的单元测试的样例能够覆盖更多端到端测试覆盖不到的点,对于前端代码通常比较关键的模块可以通过添加单元测试来规避后续修改或者重构带来的风险。单元测试样例的编写过程也有助于进一步审视模块的功能。
单元测试适用于功能不会经常改动的工具方法模块和一些基础的公共组件,对于会经常在快速迭代中更新的业务组件和功能模块端到端的测试会更适合,但这并不是说不需要写单元测试。这其实是一个投入和产出比的一个权衡,编写单元测试可能会需要频繁的更新测试样例,对于部分业务尤其是中后台的应用来说成本是偏高的。
测试模式
测试模式和单元测试的关系: 测试模式 -> (包括)单元测试. 通常测试模式有BDD和TDD模式。
TDD
全称为Test-driven development,即测试驱动开发. 这个可以算是自主测试。通常的步骤是:
- 先写(单元)测试代码
- 再写业务代码
- 用测试代码检验业务代码
- 根据结果重构业务代码
- 循环迭代以上步骤
- 通过测试
而在大部分公司里面,通常使用的是BDD测试。
BDD
全称为: Behavior-Driven development。 即行为驱动开发。 BDD的应用场景就是给一些QA工程师使用的。通常的步骤是:
- (以自然语言)定义用户故事,包括描述功能的行为目标和验收标准
- 将用户故事转化为自动化测试脚本
- 根据场景需求编写业务代码,确保功能符合业务描述的行为逻辑
- 运行自动化测试,根据结果重构代码
- 循环迭代以上步骤
- 通过测试
TDD与BDD区别
维度 | TDD | BDD |
---|---|---|
驱动核心 | 开发者视角的代码逻辑验证 | 业务视角的行为需求实现 |
参与者 | 以开发者为主 | 涉及业务方、测试、开发等多角色协作 |
用例形式 | 基于代码的单元测试(如JUnit) | 自然语言场景(如Gherkin语法) |
验证层级 | 代码单元级别 | 功能或系统行为级别 |
流程起点 | 编写测试代码 | 定义用户故事和验收标准 |
前端为什么需要单元测试?
- 必要性:JavaScript 缺少类型检查,编译期间无法定位到错误,单元测试可以帮助你测试多种异常情况。
- 正确性:测试可以验证代码的正确性,在上线前做到心里有底。
- 自动化:通过 console 虽然可以打印出内部信息,但是这是一次性的事情,下次测试还需要从头来过,效率不能得到保证。通过编写测试用例,可以做到一次编写,多次运行。
- 保证重构:互联网行业产品迭代速度很快,迭代后必然存在代码重构的过程,那怎么才能保证重构后代码的质量呢?有测试用例做后盾,就可以大胆的进行重构。
我怎么知道要测试什么
在测试方面,即使是最简单的代码块也可能使初学者也可能会迷惑。最常见的问题是“我怎么知道要测试什么?”。
如果您正在编写网页,一个好的出发点是测试应用程序的每个页面和每个用户交互。但是网页其实也需要测试的函数和模块等代码单元组成。
大多数时候有两种情况:
- 你继承遗留代码,其自带没有测试
- 你必须凭空实现一个新功能
那该怎么办?对于这两种情况,你可以通过将测试视为:检查该函数是否产生预期结果。最典型的测试流程如下所示:
- 导入要测试的函数
- 给函数一个输入
- 定义期望的输出
- 检查函数是否产生预期的输出
一般,就这么简单。掌握以下核心思路,编写测试将不再可怕:
输入 -> 预期输出 -> 断言结果。
前端怎么做单测
1、想好测试用例;
2、动手写测试;
3、查看测试结果,通过则Pass,否则应该进行repeat。
在 React 诞生之前,前端的单元测试往往只能针对于一些纯粹的 JS 模块。由于对浏览器环境的依赖,很难去做涉及到 dom 操作的模块的单元测试。但是对于前端来说,大部分代码其实都是 UI 组件,这就导致长期以来前端的代码甚至一些开源的被应用得很广泛的 UI 组件库都缺乏完整的单元测试。
但是 React 的诞生伴随着虚拟 dom 被发明,这使得前端组件的测试变得更方便了。虚拟 dom 使得一个组件可以脱离真实的浏览器环境模拟 dom 的相关操作。我们可以通过测试虚拟 dom 的表现是否正常来测试组件的逻辑,让编写组件的测试能够脱离对浏览器 dom 环境的依赖。
接下来介绍如何使用 jest 对 JS 方法或者组件进行测试。
技术选型
当挑选测试工具的时候,有些细节值得我们权衡考虑:
- 迭代速度 vs 真实环境: 一些工具在做出改动和看到结果之间提供了非常快速的反馈循环,但没有精确的模拟浏览器的行为。另一些工具,也许使用了真实的浏览器环境,但却降低了迭代速度,而且在持续集成服务器中不太可靠。
- mock 到什么程度: 对组件来说,“单元测试”和“集成测试”之间的差别可能会很模糊。如果你在测试一个表单,用例是否应该也测试表单里的按钮呢?一个按钮组件又需不需要有他自己的测试套件?重构按钮组件是否应该影响表单的测试用例?
不同的团队或产品可能会得出不同的答案:
- 单元测试有 Mocha, Ava, Karma, Jest, Jasmine 等。
- UI 测试有 ReactTestUtils, Test Render, Enzyme, React-Testing-Library, Vue-Test-Utils 等。
- e2e 测试有 Nightwatch, Cypress, Phantomjs, Puppeteer 等。
因为我们的项目使用的是 React 技术栈,这里主要介绍 React 项目的技术选型和使用。
单元测试选型
框架 | 断言 | 仿真 | 快照 | 异步测试 | 环境 | 并发测试 | 测试覆盖率 |
---|---|---|---|---|---|---|---|
Mocha | 默认不支持,但可以通过配置实现 | 默认不支持,但可以通过配置实现 | 默认不支持,但可以通过配置实现 | 友好 | 全局环境 | 否 | 需要额外配置 |
Ava | 默认支持 | 不支持,需第三方配置 | 默认支持 | 友好 | 隔离环境 | 是 | 不支持,需第三方配置 |
Jasmine | 默认支持 | 默认支持 | 默认支持 | 不友好 | 全局环境 | 否 | 不需配置 |
Jest | 默认支持 | 默认支持 | 默认支持 | 友好 | 隔离环境 | 是 | 不需配置 |
Karma | 不支持,需第三方配置 | 不支持,需第三方配置 | 不支持,需第三方配置 | 不支持,需第三方配置 | 未提及 | 未提及 | 需配置 |
- Mocha 是生态最好,使用最广泛的单测框架,但是他需要较多的配置来实现它的高扩展性。
- Ava 是更轻量高效简单的单测框架,但是自身不够稳定,并发运行文件多的时候会撑爆 CPU.
- Jasmine 是单测框架的“元老”,开箱即用,但是异步测试支持较弱。
- Jest 基于 Jasmine, 做了大量修改并添加了很多特性,同样开箱即用,但异步测试支持良好。
- Karma 能在真实的浏览器中测试,强大适配器,可配置其他单测框架,一般会配合 Mocha 或 Jasmine 等一起使用。
每个框架都有自己的优缺点,没有最好的框架,只有最适合的框架。Augular 的默认测试框架就是 Karma + Jasmine,而 React 的默认测试框架是 Jest.
Jest 被各种 React 应用推荐和使用。它基于 Jasmine,至今已经做了大量修改并添加了很多特性,同样也是开箱即用,支持断言,仿真,快照等。Create React App 新建的项目就会默认配置 Jest,我们基本不用做太多改造,就可以直接使用。
什么是 Jest
Jest 是 Facebook 开发的 Javascript 测试框架,用于创建、运行和编写测试的 JavaScript 库。
Jest 作为 NPM 包发布,可以安装并运行在任何 JavaScript 项目中。
它允许你使用jsdom操作DOM。尽管jsdom只是对浏览器工作表现的一个近似模拟,对测试React组件来说它通常也已经够用了。
使用 jest
umi 内置了 jest 测试。执行 umi test
会匹配所有 .test.js
结尾的文件运行。通常我们约定把测试的代码统一放到 test
文件夹下,当然你也可以按照你的习惯组织,比如可以和测试对应的模块放到一起。
初始化jest
> npx jest --init
Need to install the following packages:
jest
Ok to proceed? (y) y
The following questions will help Jest to create a suitable configuration for your project
? Would you like to use Jest when running "test" script in "package.json"? » (Y/n)
√ Would you like to use Jest when running "test" script in "package.json"? ... yes
// 您要使用Typescript作为配置文件吗?
√ Would you like to use Typescript for the configuration file? ... yes
// 选测试环境 node 或 web
√ Choose the test environment that will be used for testing » jsdom (browser-like)
// 是否生成测试覆盖率
√ Do you want Jest to add coverage reports? ... yes
// 使用哪个提供程序来检测覆盖范围的代码
√ Which provider should be used to instrument code for coverage? » babel
// 自动清除模拟调用和实例
√ Automatically clear mock calls, instances, contexts and results before every test? ... yes
✏️ Modified \package.json
📝 Configuration file created at \jest.config.ts
ts与jest
- React项目添加Typescript https://maomao.ink/index.php/IT/960.html
- 项目建议越早引入ts越好, 引入ts支持也可以先不用, 但至少有人要用的时候支持
配置 jest
Jest 单元测试快速上手指南 https://mp.weixin.qq.com/s/YoEguqKa7ankIVWmpSQ_cA
jest 是 Facebook 退出的一个开源的测试框架,它有它自己的配置。它约定了它的配置可以在 package.json
中,也可以在项目根目录的 jest.config.js
中。在该课程中我们以 jest.config.js
来做示例,它是 jest 的默认配置文件,当然你也可以 jest 提供的方式指定配置文件,可以参考 jest 的配置文档。
如果项目支持ts可以将jest.config.js改为jest.config.ts
那么,让我们在项目的根目录下添加一个 jest.config.js
,并填上内容如下:
// 需要注意的是这里不能使用 export default 这样的 ES6 的语法,因为它是被 jest 直接读取的,不会被 umi 编译。
module.exports = {
testURL: 'http://localhost:7001',
};
其实 jest 的配置不是必须的,在下面的示例中的第一个示例 测试一个方法
中其实是不需要的。但是在 测试一个组件
中因为我们引入了 enzyme
来测试组件。最新版的 enzyme
依赖浏览器的 localStorage
等环境,而 jest 中 testURL
的默认值是 about:blank
,这样会导致运行时报错,设置了 testURL
为一个有效的 URL 后能够避免这个问题。当然不一定必须是 http://localhost:7001
,只要是合法的 URL 地址即可。不过这不意味着 testURL
是没有意义的,实际上 testURL
还有其他作用,你可以参考它的文档说明查看具体内容。
结合husky
- 文档 https://typicode.github.io/husky/#/?id=manual
- 项目引入husky-拦截git提交, 插入单元测试验证逻辑, 不通过禁止提交
测试一个方法
jest 在执行测试文件的时候会默认注入一些方法,对于最简单的测试,你只需要了解 test
和 expect
这两个方法即可。 test
方法接收两个参数,第一个是测试描述,第二个是一个函数,它包裹了一个测试样例。在这个样例中你可以调用 expect
方法检测你的代码。比如,我们新建一个 test/helloworld.test.js
的文件,然后写上如下的内容:
// 待测试的函数
function sum(a, b) {
return a + b;
};
sum(1, 2)
// test测试函数简化版
function test(desc, fn) {
try {
fn();
} catch(e) {
console.log(desc + '未通过测试');
}
}
// ecpect预期函数简化版
function expect(result) {
return {
toBe: function(actual) {
throw new Error('预期值和实际值不符');
}
}
}
// 对函数进行测试
test('adds 1 + 2 to equal 3', () => {
// toBe匹配器
expect(sum(1, 2)).toBe(3);
});
//常见断言方法
expect({a:1}).toBe({a:1})//判断两个对象是否相等
expect(1).not.toBe(2)//判断不等
expect({ a: 1, foo: { b: 2 } }).toEqual({ a: 1, foo: { b: 2 } })
expect(n).toBeNull(); //判断是否为null
expect(n).toBeUndefined(); //判断是否为undefined
expect(n).toBeDefined(); //判断结果与toBeUndefined相反
expect(n).toBeTruthy(); //判断结果为true
expect(n).toBeFalsy(); //判断结果为false
expect(value).toBeGreaterThan(3); //大于3
expect(value).toBeGreaterThanOrEqual(3.5); //大于等于3.5
expect(value).toBeLessThan(5); //小于5
expect(value).toBeLessThanOrEqual(4.5); //小于等于4.5
expect(value).toBeCloseTo(0.3); // 浮点数判断相等
expect('Christoph').toMatch(/stop/); //正则表达式判断
expect(['one','two']).toContain('one'); //不解释
{[()]}
describe("math",()=>{
beforeAll(()=>{
console.log('所有测试用例开始之前执行')
})
afterAll(()=>{
console.log('所有测试用例完成后才执行')
})
beforeEach(()=>{
console.log('每个测试用例 开始 之前执行')
})
afterEach(()=>{
console.log('每个测试用例 结束 之后执行')
})
// 分组函数
describe("+",()=>{
beforeAll(()=>{
console.log('所有测试用例开始之前执行')
})
afterAll(()=>{
console.log('所有测试用例完成后才执行')
})
beforeEach(()=>{
console.log('每个测试用例 开始 之前执行')
})
afterEach(()=>{
console.log('每个测试用例 结束 之后执行')
})
// 不同用例的单元测试
expect(value).toBeCloseTo(0.3); /
expect(value).toBeCloseTo(0.3); /
expect(value).toBeCloseTo(0.3); /
})
describe("-",()=>{
// 不同用例的单元测试
expect(value).toBeCloseTo(0.3); /
expect(value).toBeCloseTo(0.3); /
expect(value).toBeCloseTo(0.3); /
})
})
在 package.json
中添加 scripts.test
为 umi test
,然后运行 cnpm run test
。接下来你就能看到如下的结果:
测试一个组件
在一个简化的测试环境中渲染组件树并对它们的输出做断言检查。
接下来我们尝试测试一个最简单的组件,首先我们在 src/component
下面新建一个 TestDemo.js
的组件,组件内容如下:
export default () => {
return <div>test</div>;
};
enzyme测试库(来自Airbnb)
然后我们在 test/helloworld.test.js
中添加对它的测试。在这之前你需要先安装 测试一个组件 包,它是针对 React 的测试工具库,使得我们可以很方便的利用 React 虚拟 dom 来编写测试样例。
在项目根目录下执行:
cnpm i --save-dev enzyme enzyme-adapter-react-16
然后添加如下的测试样例:
import { mount } from 'enzyme';
import TestDemo from '../src/component/TestDemo';
test('TestDemo', () => {
const wrapper = mount(<TestDemo />);
expect(wrapper.find('div').text()).toBe('test');
});
然后运行 cnpm run test
就可以看到结果了。
enzyme 提供了大量的方法可以让你能够测试组件中的内容,更多信息可以参考 enzyme 的官网
React Testing Library测试库
React Testing Library 基于DOM Testing Library的基础上添加一些API,主要用于测试React组件。如果是其它的技术栈,可以选择对应的Testing Library库。该库在使用过程并不关注组件的内部实现,而是更关注测试。该库基于react-dom和react-dom/test-utils,是以上两者的轻量实现。
官方文档 https://testing-library.com/docs/react-testing-library/setup/
基本能力
- 组件渲染(render)
- 元素查找(get/query/find)
- 事件触发(fireEvent)
重点介绍
react-dom/test-utils
提供了一个名为act()
的 helper,它确保在进行任何断言之前,与这些“单元”相关的所有更新都已处理并应用于 DOM:act名称来源于Arrange/Act/Assert 布局-操作-断言 Given/When/Then, 执行测试所需要的设置和初始化 > 采取测试所需的行动 > 验证测试结果, 这种模式有几个显著的好处。它在测试设置、操作和结果中创建了一个很清晰的界限。 这种结构使得代码更容易阅读和理解 如果你将步骤按顺序排列并格式化代码, 将它们分开,你可以扫描测试并可以很快理解它的功能
act(() => { render(<Hello />, container); }); expect(container.textContent).toBe("嘿,陌生人"); // Typography排版 it('copy to clipboard', () => { jest.useFakeTimers(); const spy = jest.spyOn(copyObj, 'default'); const originText = 'origin text.'; const nextText = 'next text.'; const Test = () => { const [dynamicText, setDynamicText] = React.useState(originText); React.useEffect(() => { setTimeout(() => { setDynamicText(nextText); }, 500); }, []); return ( <Base component="p" copyable> {dynamicText} </Base> ); }; const { container: wrapper } = render(<Test />); const copyBtn = wrapper.querySelectorAll('.ant-typography-copy')[0]; fireEvent.click(copyBtn); expect(spy.mock.calls[0][0]).toEqual(originText); act(() => { jest.runAllTimers(); }); spy.mockReset(); fireEvent.click(copyBtn); expect(spy.mock.calls[0][0]).toEqual(nextText); jest.useRealTimers(); });
React Testing Libary VS Enzyme
- Enzyme提供一种测试React组件内部的能力。而React测试库不直接测试组件的实现细节,而是从一个React应用的角度去测试。
Mock数据
- 你可以使用假数据来 mock 请求,而不是在所有测试中调用真正的 API。使用“假”数据 mock 数据获取可以防止由于后端不可用而导致的测试不稳定,并使它们运行得更快。注意:你可能仍然希望使用一个“端到端”的框架来运行测试子集,该框架可显示整个应用程序是否一起工作。
异步测试+定时器测试(异步测试)
声明周期钩子
beforeAll(()=>{
console.log('所有测试用例开始之前执行')
})
afterAll(()=>{
console.log('所有测试用例完成后才执行')
})
beforeEach(()=>{
console.log('每个测试用例 开始 之前执行')
})
afterEach(()=>{
console.log('每个测试用例 结束 之后执行')
})
Snapshots快照
- 可以“保存”渲染的组件输出,并确保对它的更新作为对快照的更新显式提交。
- 通常,进行具体的断言比使用快照更好。这类测试包括实现细节,因此很容易中断,并且团队可能对快照中断不敏感。选择性地 mock 一些子组件可以帮助减小快照的大小,并使它们在代码评审中保持可读性。
覆盖率报告
指标 说明 %stmts(statement coverage) 语句覆盖率:是不是每个语句都执行了? %Branch(branch coverage) 分支覆盖率:是不是每个if代码块都执行了? %Funcs(function coverage) 函数覆盖率:是不是每个函数都调用了? %Lines(line coverage) 行覆盖率:是不是每一行都执行了?
项目中比较有用的场景
- 判断平台系统类型是否为苹果系
// 判断平台系统类型是否为苹果系
export function isApplePlatform(platform) {
const APPLE_OSTYPE_ENUM = ['ios', 'ipad', 'macos'];
return APPLE_OSTYPE_ENUM.includes(platform);
}
if (this.platform === 'ios') { // ios应用-不全面-以后全部杜绝 }
ios应用-不全面-以后全部杜绝
if (isApplePlatform(this.platform)) { // ios应用属于苹果系-判断更全面 }
const IOS = 'ios'
=== IOS
推荐的文章
- 万字长文总结前端测试体系建设与最佳实践 https://mp.weixin.qq.com/s/IgA29U-etBKUls7JnpE2Zw
- Jest 单元测试快速上手指南 https://mp.weixin.qq.com/s/YoEguqKa7ankIVWmpSQ_cA
- 万字详文:彻底搞懂 Jest 单元测试框架 https://mp.weixin.qq.com/s/az-qAkIca7jl4GqUp1Relw
- 估计很多前端都没学过单元测试~ https://mp.weixin.qq.com/s/0XFjBIoy7IENqbw8U6v2Cg
- React官方测试指南 https://zh-hans.reactjs.org/docs/testing.html