我为什么不建议你写单元测试

明显,你已经抢了 Tester 的饭碗!你知道 bug 对于 Tester 意味着什么吗?这是 KPI 啊,是工作效率啊。当测试你写的程序的时候,bug 却寥寥无几,你知道 Ta 的内心有多麽焦虑,多麽惶恐。这让会让 Ta 感到深深的挫败感,你也应该为此感到内疚。

当然,受到影响的不只有 Tester,还有你的同僚们。当其他开发同学都在忙着修 bug 的时候,而你却守着全篇绿色的测试结果,在一边喝着茶水,一边和前台妹妹打趣,这会使同僚们心生妒忌,从此结下梁子。日后你再也没有机会与这群汉子一起吃饭,只能和妹子一起吃点牛排唱唱歌什么的,真是太惨了!!!

事情还没结束。质量高,会显得你很优秀,这会让老板陷入纠结,明明不想给你涨工资,却又怕你离职。就如同情窦初开貌美少女看到面相丑陋精壮汉子,虽然心里一万个不愿意,却无法控制自己出卖灵魂。

毕竟公司请你来是为了写代码,而不是写测试。你伤害了太多的人!

为了不让你四面树敌,我会告诉你什么是正确的单元测试,这样你就可以成功的避开它。不要太感动,请叫我 “雷锋”!

单元测试的基本原则:

  • 有明确的预期
  • 快速的
  • 独立性

有明确的预期

你可能看到过许多的单元测试中没有任何的断言 (Assert),全部是 System.outconsole.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 。

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

https://yq.aliyun.com/articles/118921

《有效的单元测试》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342