文章目录
- 2.3 单元测试框架的作用 What unit testing frameworks offer
- 2.3.1 xUnit 框架 The xUnit frameworks
- 2.3.2 xUnit、TAP 及 Jest 的结构 xUnit, TAP, and Jest structures
- 2.4 示例项目:密码校验器
- 2.5 创建第一个单元测试
- 2.5.1 代码实战
- 2.5.2 ~ 2.5.4 `AAA` 模式与 `USE` 命名规范
- 2.5.5~2.5.6 describe() 方法的使用 Using describe()
- 2.5.7 it() 函数的使用 The it() function
- 2.5.8 Jest 的两种测试风格 Two Jest flavors
- 2.5.9 再次重构密码校验器 Refactoring the production code
(接上篇)
2.3 单元测试框架的作用 What unit testing frameworks offer
主要作用详见本专栏 【第 02 篇自学笔记】。
2.3.1 xUnit 框架 The xUnit frameworks
xUnit
是 Visual Basic
时代大多数单元测试框架的统称,它的框架理念源自 SUnit
,即 Smalltalk
的单元测试框架。
单元测试框架通常以构建它的语言的首字母来命名——
C++
:CppUnit
Java
:JUnit
.NET
:NUnit
与xUnit
Haskell
:HUnit
大部分情况下是这样,只有少数例外(比如本书用的 Jest
,哈哈)。
2.3.2 xUnit、TAP 及 Jest 的结构 xUnit, TAP, and Jest structures
除了命名风格的一致外,测试的结构(用例编写与结果输出等)也要统一。比如 xUnit
框架的测试报告就是 XML 格式的,并且至今仍广泛应用与大多数构建工具内。例如 Jenkins
就提供了相关插件支持 XML 格式的测试结果。
鉴于大部分静态语言的单元测试框架都是基于 xUnit
来建模的,这就意味着一旦学会其中一种,就能触类旁通其他框架。
测试报告结构的统一最后是由一个称为 TAP 的协议来实现的,全称为 Test Anything Protocol
,最初应用于 Perl
语言的测试框架,由 Dave Hampton 于 2001 年设计。现如今已全面覆盖 C
、C++
、Python
、PHP
、Perl
、Java
、JavaScript
等主流编程语言。具体到 JavaScript
,TAP 也是久负盛名的测试框架,原生支持 TAP 协议。
严格来说,Jest
既不属于 xUnit
架构,也不属于 TAP
框架。Jest
的默认输出不符合二者的标准。但由于 xUnit
仍是构建环节的主流测试框架,Jest
也提供了 jest-xunit
的 npm
模块来予以支持;若要基于 TAP
输出内容,则使用 jest-tapreporter
模块。报告格式的切换配置在 jest.config.js
中完成。
2.4 示例项目:密码校验器
全书将基于一个密码校验的示例项目,层层递进来介绍单元测试的相关话题。该项目用于构建一个密码验证工具库。起初只有一个 verifyPassword()
函数,其中自定义校验规则 rules
的结构如下:
{passed: Boolean,reason: String
}
第 0 版实现很简单(ch2/password-verifier0.js
):
const verifyPassword = (input, rules) => {const errors = [];rules.forEach(rule => {const result = rule(input);if (!result.passed) {errors.push(`error ${result.reason}`);}});return errors;
};
2.5 创建第一个单元测试
2.5.1 代码实战
函数 verifyPassword
对应的单元测试也很简单:
test('badly named test', () => {const fakeRule = input =>({ passed: false, reason: 'fake reason' });const errors = verifyPassword('any value', [fakeRule]);expect(errors[0]).toMatch('fake reason');
});
实战演练:
# (Powershell 环境)
> (pwd).Path
C:\Users\ad\Desktop\ch2
# 删除上一章无关内容
> rm __tests__/hellojest.test.js
# 新增第0版密码校验函数
> vim password-verifier0.js
> cat password-verifier0.js
const passwordVerifier = (input, rules) => {const errors = [];rules.forEach(rule => {const result = rule(input);if (!result.passed) {errors.push(`error ${result.reason}`);}});return errors;
};module.exports = passwordVerifier;
# 新增测试用例
> vim __tests__/password-verifier0.spec.js
> cat __tests__/password-verifier0.spec.js
const verifyPassword = require('../password-verifier0');test('badly named test', () => {const fakeRule = input =>({ passed: false, reason: 'fake reason' });const errors = verifyPassword('any value', [fakeRule]);expect(errors[0]).toMatch('fake reason');
});
# 运行 Jest 本地测试
> yarn run testw
运行结果:
【图 6 启用 Jest 监听模式、并运行第 0 版 verifyPassword 函数的首个单元测试的运行结果】
如果注释掉原函数中数组 errors
的 push
操作,则测试报错:
【图 7 注释掉原函数对错误原因的记录逻辑,则测试报错(符合预期)】
2.5.2 ~ 2.5.4 AAA
模式与 USE
命名规范
刚才的测试结构,称为 准备-执行-断言(Arrange-Act-Assert) 模式,即 AAA
模式 ——
- Arrange(准备):设置所需环境和数据(创建对象、初始化变量等)
- Act(执行):调用要测试的功能或方法
- Assert(断言):验证执行结果是否符合预期(检查返回值或状态等)
刚才的测试在命名方面很糟糕,好的命名应该一目了然测试的目的。推荐使用 USE
命名规范来给单元测试命名,它由三个部分构成 ——
- U:被测的工作单元(The unit of work under test):本例即为
verifyPassword
函数; - S:被测单元的场景或输入内容(The scenario or inputs to the unit):即给定的无法通过的校验规则;
- E:预期行为或退出点(The expected behavior or exit point):返回带报错原因的校验结果(未通过)
按照 USE
命名规则修改的单元测试:
【图 8 按照 USE 命名规范重构的单元测试描述及运行结果】
注意第 9 行:改为 toContain
后,既更贴近自然语言表述,更能提高测试的容错率(包含几个关键词即可)。
因为字符串也是某种意义上的用户界面,人们关心的重点是它传递的信息,而不是哪些枝端末节的空格、制表符、星号、换行……
测试最理想的状态,应该是仅当代码存在错误时测试才不通过,尽量降低误报率。这便是改为 toContain
的目的所在。
2.5.5~2.5.6 describe() 方法的使用 Using describe()
Jest
的 describe()
函数为单元测试提供了更丰富的结构,可将 USE
规范中的三个部分拆分开,既为读者提供了逻辑意义上的上下文,同时也为单元测试提供了更细分的作用域:
describe('verifiyPassword', () => {test('given a failing rule, returns errors', () => {const fakeRule = input =>({ passed: false, reason: `${input} fake reason` });const errors = verifyPassword('any value', [fakeRule]);expect(errors[0]).toContain('fake reason');});
});
这里作了四处修改:
- 使用
describe()
嵌套test()
的结构; USE
规范中的U
移至describe
方法;AAA
模式之间使用空行隔开;- 报错原因中包含
input
参数,提示信息更丰富。
也可以在 describe
中嵌套 describe
,完全拆开 USE
的三个部分(前三行):
describe('verifiyPassword', () => {describe('with a failing rule', () => {test('returns errors', () => {const fakeRule = input =>({ passed: false, reason: `${input} fake reason` });const errors = verifyPassword('any value', [fakeRule]);expect(errors[0]).toContain('fake reason');});});
});
此时,单元测试的层次更加丰富,暗含在特定场景下,可能存在多个预期行为的意味。此时可以将通用部分(如 fakeRule
)提取到中间的 describe
上下文,变为:
describe('verifiyPassword', () => {describe('with a failing rule', () => {const fakeRule = input =>({ passed: false, reason: `${input} fake reason` });test('returns errors', () => {const errors = verifyPassword('any value', [fakeRule]);expect(errors[0]).toContain('fake reason');});// test(...);});
});
这样一来,对于存在多个出口点的工作单元,每一个出口点都能对应一个独立的单元测试,这是符合单元测试整体设计的。
2.5.7 it() 函数的使用 The it() function
describe
函数常与 it
函数搭配,在英语语境下可读性更好:
describe('verifiyPassword', () => {describe('with a failing rule', () => {it('returns errors', () => {// ...});});
});
2.5.8 Jest 的两种测试风格 Two Jest flavors
Jest
支持两种主要的测试编写方式——
- 简洁的
test
风格; - 更注重文字描述(
describe-driven
)的风格(即分层写法);
后者很大程度上归功于 Jest
的前身 —— Jasmine
框架。这种风格最早可以追溯到 Ruby
中的著名测试框架 RSpec Ruby
,也被成为 BDD
风格,即 行为驱动开发(behavior-driven development)。
两种风格的选择都是个人喜好,没有严格的新旧好坏之分。
关于 BDD 的历史掌故
BDD
与TDD
无关。当年 Dan North 发明BDD
主要是想用场景叙事和案例来描述应用程序的运行原理,目标人群也主要是非技术人员,比如产品所有人、客户等。RSpec
受RBehave
的启发,将这个叙事驱动(story-driven
)的方法推广出来,其他类似的框架也相继问世,比如著名的BDD
框架Cucumber
。Cucumber
使用Gherkin
语言编写测试场景,支持多种编程语言:Ruby
、Java
、JavaScript
、C#
等,通常包括以下几个部分:
- Feature:功能描述
- Scenario:测试场景
- Given:前置条件
- When:执行的操作
- Then:预期的结果
例如(详见
Cucumber
官网:https://cucumber.io/):Feature: User loginScenario: Successful loginGiven a user with username "user1" and password "password123"When the user logs inThen the user should see the dashboard
然而现实却与
BDD
的设计初衷完全背道而驰 —— 绝大部分测试框架都是开发人员在使用和维护,跟非技术方半毛钱关系都没有(笑死)。现在的BDD
更多是作为测试框架的某种语法糖来使用,几乎从未在利益相关方之间促成真正的沟通与交流。它们更多是被当成一种便于开发人员运行自动化测试的指定工具。时至今日,Cucumber
也有了类似的迹象。
2.5.9 再次重构密码校验器 Refactoring the production code
接着给示例项目引入状态,实现规则配置和运行校验的分离。这里用的是 ES6
的 class
语法糖:
class PasswordVerifier1 {constructor () {this.rules = [];}addRule (rule) {this.rules.push(rule);}verify (input) {const errors = [];this.rules.forEach(rule => {const result = rule(input);if (result.passed === false) {errors.push(result.reason);}});return errors;}
}
此时,工作单元的范围扩大了,之前的测试逻辑也需要同步调整,规则配置和结果校验需要分别调用 addRule(rule)
方法和 verify(input)
方法(第 4、10、11 行):
describe('PasswordVerifier', () => {describe('with a failing rule', () => {it('has an error message based on the rule.reason', () => {const verifier = new PasswordVerifier1();const fakeRule = input => ({passed: false,reason: `${input} fake reason`});verifier.addRule(fakeRule);const errors = verifier.verify('any value');expect(errors[0]).toContain('fake reason');});});
});
由于引入了状态,代码间产生了耦合,这些状态仅限于测试内部使用,不能暴露出去。此时若要在同一场景下编写多个测试,比如验证错误的数量为 1,貌似加一句断言即可(第 3 行):
verifier.addRule(fakeRule);
const errors = verifier.verify('any value');
expect(errors.length).toBe(1); // A new assertion
expect(errors[0]).toContain('fake reason');
但这样写非但与前面介绍的单元测试的定义不符,在第 3 行报错时还容易漏掉它后面的测试。
有人可能觉得小题大作,测试第 4 行时注释掉第 3 行不就行了,干嘛这么较真?这种做法在 Gerard Meszaros 所著的《xUnit
测试模式》(xUnit Test Patterns)一书中称为 断言轮盘(assertion roulette),容易造成大量混乱和误报情况:某些功能点未通过,但实际可能通过了;反之亦然。
正确的写法,应该是按出口点(exit point
)编写独立的单元测试用例:
describe('v3 PasswordVerifier', () => {describe('with a failing rule', () => {it('has an error message based on the rule.reason', () => {const verifier = new PasswordVerifier1();const fakeRule = input => ({passed: false,reason: 'fake reason'});verifier.addRule(fakeRule);const errors = verifier.verify('any value');expect(errors[0]).toContain('fake reason');});it('has exactly one error', () => {const verifier = new PasswordVerifier1();const fakeRule = input => ({passed: false,reason: 'fake reason'});verifier.addRule(fakeRule);const errors = verifier.verify('any value');expect(errors.length).toBe(1);});});
});
这样,断言轮盘 的问题倒是解决了,却导致了严重的代码冗余。于是引出下一节的重点:beforeEach()
路由。
(更多重构技巧,详见本章自学笔记的下篇,敬请关注!)