很明显,你已经抢了 Tester 的饭碗!你知道 bug 对于 Tester 意味着什么吗?这是 KPI 啊,是工作效率啊。当测试你写的程序的时候,bug 却寥寥无几,你知道 Ta 的内心有多麽焦虑,多麽惶恐。这让会让 Ta 感到深深的挫败感,你也应该为此感到内疚。
当然,受到影响的不只有 Tester,还有你的同僚们。当其他开发同学都在忙着修 bug 的时候,而你却守着全篇绿色的测试结果,在一边喝着茶水,一边和前台妹妹打趣,这会使同僚们心生妒忌,从此结下梁子。日后你再也没有机会与这群汉子一起吃饭,只能和妹子一起吃点牛排唱唱歌什么的,真是太惨了!!!
事情还没结束。质量高,会显得你很优秀,这会让老板陷入纠结,明明不想给你涨工资,却又怕你离职。就如同情窦初开貌美少女看到面相丑陋精壮汉子,虽然心里一万个不愿意,却无法控制自己出卖灵魂。
毕竟公司请你来是为了写代码,而不是写测试。你伤害了太多的人!
为了不让你四面树敌,我会告诉你什么是正确的单元测试,这样你就可以成功的避开它。不要太感动,请叫我 “雷锋”!
单元测试的基本原则:
- 有明确的预期
- 快速的
- 独立性
有明确的预期
你可能看到过许多的单元测试中没有任何的断言 (Assert
),全部是 System.out
或 console.log
,以肉眼的方式来判断是否通过。没有断言意味着没有人知道你想得到的结果是什么,控制台输出无法判断结果是 正确 或是错误 。一个函数固定的输入一定会有确定的输出不是吗?那么请把它明确出来:
function plus(a, b) {
return a + b;
}
// unit test
import test from 'ava';
test('Given a = 5 And b = 6, When plus(a, b), Then result to be 11', t => {
const result = plus(5, 6);
// 👎 错误的做法:
console.log(result);
// 👍 正确的做法:
t.is(result, 11);
});
注:这里使用 AVA 作为测试框架,断言使用其自带的 t.is
,而非常见的 Assert
,没什么区别。
当我们修改了 plus
函数的方法体(也可能是无意为之),如下:
function plus(a, b) {
return a + b + 10;
}
当你再次运行单元测试时断言则会告诉我们与之前的预期不符,而 console.log
却不能。
快速的
单元测试是由程序员自己来编写的,运行速度快可以让开发者频繁的运行。当我们不小心写了错误代码,它总是能够马上通知我那些改动引发错误。如果不能及时给人们反馈结果,人们就会对它产生厌烦情绪,不愿意运行它,质量就没有办法保证。所以单元测试的运行一定要快。
独立性
单元测试的具备独立性表现为两种:被测试函数的独立,单元测试的独立。
被测试函数的独立,是指被测试的函数不要有外部依赖,如全局变量、时间日期、随机数副作用等。没有外部依赖就可以做到可重复,可重复就可以自动化。
// 全局变量
const config = { port: 8000 };
function getURL() {
return `https://xbl.github.io:${config.port}`;
}
config.port = 9090;
// 随机数
function randomStr() {
return Math.random().toString(16).slice(2);
}
function schedule() {
// 小于 2019-04-30 日期做某事
var begin = new Date('2019-04-30')
if(begin < (new Date()) {
// do something...
}
}
这些外部依赖都会导致函数不可测,所以要在编码时将外部依赖抽离到参数中,或者使用下面会提到的测试替身(Test Double)[1]。
单元测试的独立,是指单元测试的验证结果不能依赖于其他测试。
test('Given config port is 8080,When setPort(),Then config to be 9090', t => {
config.setPort(9090);
t.is(config.getPort(), 9090);
});
test('Given config, When getUrl(), Then result to be https://xbl.github.io:9090', t => {
const result = config.getUrl();
t.is(result, 'https://xbl.github.io:9090');
});
后面测试依赖于第一个测试的 setPort
的结果,一旦有人调整顺序或者删除上面的单元测试,后面的测试就会受到影响。
没有副作用
- 并发性
- 基础设施(磁盘、OS...)
- 数据库
- 网络
是的,没有副作用很难!我们写的任何程序都有可能依赖于网络、OS 和数据库等等。我怎么可能告诉你世界上还有 测试替身 这种东西。😼
测试替身 (Test Double)
测试替身是一组工具(方法)集 (Dummy,Stub,Spy,Mock,Fake),用来很好的保证单元测试的隔离性。
测试替身-Dummy
最为简单的一种替身,作为参数填充。其目的通常只是为了满足编译通过,或运行时不报错而已。
class Config {
host: string;
port: string;
constructor(host: string, port: string) {
this.host = host;
this.port = port
}
getUrl(): string {
return `https://${this.host}:${this.port}`;
}
}
// unit test
import test from 'ava';
import Config from '@/config';
test('Given host "" And port "", When Config.getUrl(), Then result to be https://:', t => {
// 这里的参数就是一种 Dummy 测试替身方法
const config = new Config('', '');
const result = config.getUrl();
t.is(result, 'https://:');
});
作为最简单的测试替身,通常不会被人们提起,也不需要第三方库来支持。
测试替身-Stub
在测试中会被执行,但仅仅返回固定值。
product-service.ts
export default class ProductService {
static getList(): Promise<Array<string>> {
// 想象这里是一个很漫长的网络请求,通过网络请求拿到数据
return new Promise((resolve) => {
setTimeout(() => {
resolve(['H', 'E', 'L', 'L', 'O']);
}, 30000);
});
}
static async getTop3(): Promise<Array<string>> {
const list = await this.getList();
return list.slice(0, 3);
}
}
ProductService 有两个方法,getList
通过网络获取数据列表,getTop3
取得列表中的前 3 条数据。
product-service.spec.ts
import test from 'ava';
import ProductService from '@/product-service';
test('Given ProductService, When ProductService.getTop3(), Then result to be [A, B, C]', async t => {
const result = await ProductService.getTop3();
t.deepEqual(result, ['A', 'B', 'C']);
});
显然,这个单元测试运行缓慢且不能通过,因为依赖网络和外部数据。在这个场景下我们只想测试 Top 3 的功能是否正确,只要返回正确的 3 个值就 ok,这时候可以使用 sinon.js 库。
sinon 是专门用来制作替身的第三方库。
修改如下👇🏻:
import test from 'ava';
import sinon from 'sinon';
import ProductService from '@/product-service';
test('Given ProductService, When ProductService.getTop3(), Then result to be [A, B, C]', async t => {
const stub = sinon.stub(ProductService, 'getList');
stub.resolves(['A', 'B', 'C', 'D']);
const result = await ProductService.getTop3();
t.deepEqual(result, ['A', 'B', 'C']);
stub.restore();
});
使用 sinon 对 ProductService.getList
进行打桩,使其返回固定的数据 ['A', 'B', 'C', 'D']
,这样就可以验证我们的获取 Top 3 的逻辑正确性。
测试替身-Spy
函数分类两种职责,一种是读操作:有明确的返回值,另一种是写操作:会对数据进行修改。上面的例子都是属于读操作的,对于写操作的似乎不太行。
Spy 是专门针对写操作提供的方法,让 ‘她’ 悄悄潜入到程序中,把那些秘密数据带出来。快挥舞小皮鞭,让你的小间谍们干活去~ 😈😈😈
util.ts
const sendEmail = (subject: string, content: string): Promise<any> => {
// 这里是一个很漫长的网络请求,写入...
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 30000);
});
};
export default {
sendEmail
}
Product-service.ts
import Util from '@/util';
export default class ProductService {
...
static async sendEmail(list: Array<string>): Promise<any> {
Util.sendEmail('Top 3', list.slice(0, 3).join(','));
}
}
product-service.spec.ts
import test from 'ava';
import sinon from 'sinon';
import Util from '@/util';
import ProductService from '@/product-service';
...
test('Given ProductService, When ProductService.sendEmail(), Then send email subject to be Top 3 And content to be A,B,C', async t => {
const spy = sinon.spy(Util, 'sendEmail');
await ProductService.sendEmail(['A', 'B', 'C', 'D']);
t.truthy(spy.calledOnce);
t.truthy(spy.calledWith('Top 3', 'A,B,C'));
spy.restore();
});
使用 sinon 对 Util.sendEmail()
拦截,得到它被调用时的参数,验证参数和调用次数是否符合我们预期。
测试替身-Mock
这里的 Mock 与我们常规的理解略有不同,Mock 有点像 Stub 和 Spy 的集合,Mock 是把限制条件写在调用的前面,验证也更加严格。
import test from 'ava';
import sinon from 'sinon';
import Util from '@/util';
import ProductService from '@/product-service';
...
test.serial('Given ProductService, When call ProductService.getTop3() once, Then got verify to be true', async t => {
const mock = sinon.mock(ProductService).expects('getList');
mock.once().resolves(['A', 'B', 'C', 'D']);
const result = await ProductService.getTop3();
t.deepEqual(result, ['A', 'B', 'C']);
t.truthy(mock.verify());
});
当调用 ProductService.getTop3()
只允许调用一次 getList
,没有调用或者调用多次都会抛出异常。
测试替身-Fake
是一个更加复杂的替身,制作 Fake 对象的成本较高。Fake 与真实事物行为完全一致,只是不能用于生产,典型的例子是 H2 。
当然,代码仍然可以使用 Fake :
repository.ts
export default interface Repository {
get();
save(... any);
}
user-repository.ts
import Repository from './repository';
export class UserRepository implements Repository {
get() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(['H', 'E', 'L', 'L', 'O']);
}, 30000);
});
}
save(user: any) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 30000);
});
}
}
假设这是真实的接口实现,get、save 都需要走网络请求。
user-repository-fake.ts
import Repository from './repository';
export default class UserRepositoryFake implements Repository {
users = ['A', 'B'];
get() {
return new Promise((resolve) => {
resolve(this.users);
});
}
save(user: any) {
return new Promise((resolve) => {
this.users.push(user);
resolve();
});
}
}
制作一个 fake 对象与 Repository
行为一致。
user-service.spec.ts
import test from 'ava';
import UserService from '@/fake/user-service';
import UserRepositoryFake from '@/fake/user-repository-fake';
test('Given UserService, When userService.get(), Then result to be [A, B]', async t => {
const userService = new UserService(new UserRepositoryFake());
const result = await userService.get();
t.deepEqual(result, ['A', 'B']);
});
test('Given UserService, When userService.save(C), Then result to be [A, B, C]', async t => {
const userService = new UserService(new UserRepositoryFake());
await userService.save('C');
const result = await userService.get();
t.deepEqual(result, ['A', 'B', 'C']);
});
通过 UserService
构造函数传入一个 UserRepositoryFake
的实例。
随着工具的发展,Fake 的实用性已经降低 ,大多数时候并不需要我们手动去制作 Fake 对象,但基本手法还是有必要了解的。
测试替身可以很好的帮助我们隔离副作用,当然,如果这种方法被滥用,单元测试很有可能失去它的作用。
const getResult = () => {
return a() + b() + c();
}
如果 a()
b()
c()
都被 Mock 掉,那么测试 getResult
的意义就没有了,当这几个函数的内部被修改甚至无法正确返回,我们的测试无法不会测出来。
所以答应我,只用特殊替身隔离副作用好吗?
『单元测试』还是『集成测试』?
“单元” 这个词在不同语境下它的大小是不一样的,或许它的创立之初就认为不应该被严格定义,越是追求严格的定义往往越容易出现混乱。大多数人们认为 “单元” 的颗粒度应该是 “函数”,我记得某些书上也的确是这样定义的,但当上面这个 getResult
例子出现时,人们开始纠结它究竟是『单元测试』还是『集成测试』,如果使用 TDD (测试驱动开发) 它的演变可能是这样:
(生生凑出这么一坨代码,不必在意这段代码的真实含义。)
const getResult = () => {
let result = '';
const random = ~~(Math.random() * 100) % 2;
if (random) {
result += '$';
} else {
result += '#';
}
const now = (Date.now()).toString();
for(let i =0; i < now.length; i++) {
result += ~~(Math.random() * 10);
}
const arr = result.split('');
for(let i = 1; i < arr.length; i++) {
if (~~(arr[i]) % 2) {
arr[i] = 'x';
} else {
arr[i] = 'y';
}
}
return arr.join('');
}
我们测试了 getResult
函数:
test('When getResult(), Then result to be $xyxyxyxyyy', t => {
const result = getResult();
t.is(result, '$xyxyxyxyyy']);
});
这个时候它一定是单元测试,经过不断的演进:
const getResult = () => {
let result = '';
const random = ~~(Math.random() * 100) % 2;
if (random) {
result += '$';
} else {
result += '#';
}
result += b();
result += c();
return result;
}
最终变成了我们刚刚看到的样子:
const getResult = () => {
return a() + b() + c();
}
我们是否有必要删除 getResult()
的测试吗?肯定是不需要。
那么我们是否需要增加 a()
b()
c()
的测试呢?答案是没必要。getResult
的测试已经完全覆盖了 a()
的所有使用场景,没有必要在为其添加测试。
这就像地图上的缩放迟,缩放到不同尺寸时,地图能够显示的最小单位是不一样的。所以无需追求严格的定义,让自己陷入不必要的纠结。
总结
为了你可以成功避开正确的单元测试,我也是拼了老命把单元测试的技巧都罗列了一遍。单元测试并不复杂,但想要精巧的避开还需要多加练习,为此我还特意创建了 git 仓库,包含以上示例。
追求完美的测试覆盖率是没有意义的,但如果你想要有一个较高的测试覆盖率,又不想拼命去补单元测试,TDD(测试驱动开发)是唯一的法门。
随着程序的演进,单元测试还是集成测试界限会变的模糊,严格区分并不能为我们带来明显的好处,我们何苦还要较真儿呢?如果你还纠结要不要测试 private
私有方法那我这段算是白讲了...
这世界上还有一个更加邪恶的东西——持续集成,每次提交代码后自动跑单元测试以验证程序的准确性。想都别想我会去讲它,永远不会!!!
参考
https://martinfowler.com/bliki/UnitTest.html
https://martinfowler.com/bliki/TestDouble.html