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

明显,你已经抢了 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)<sub>[1]</sub>。

单元测试的独立,是指单元测试的验证结果不能依赖于其他测试。

test('Given config port is 8080,When setPort(),Then config to be 9090', t =&gt; {
	config.setPort(9090);
  t.is(config.getPort(), 9090);
});

test('Given config, When getUrl(), Then result to be https://xbl.github.io:9090', t =&gt; {
	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 =&gt; {
  // 这里的参数就是一种 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>&gt; {
    // 想象这里是一个很漫长的网络请求,通过网络请求拿到数据
    return new Promise((resolve) =&gt; {
      setTimeout(() =&gt; {
        resolve(['H', 'E', 'L', 'L', 'O']);
      }, 30000);
    });
  }

  static async getTop3(): Promise<array<string>&gt; {
    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 =&gt; {
  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 =&gt; {
  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> =&gt; {
  // 这里是一个很漫长的网络请求,写入...
  return new Promise((resolve) =&gt; {
    setTimeout(() =&gt; {
      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 =&gt; {
  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 =&gt; {
  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) =&gt; {
      setTimeout(() =&gt; {
        resolve(['H', 'E', 'L', 'L', 'O']);
      }, 30000);
    });
  }

  save(user: any) {
    return new Promise((resolve) =&gt; {
      setTimeout(() =&gt; {
        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) =&gt; {
      resolve(this.users);
    });
  }

  save(user: any) {
    return new Promise((resolve) =&gt; {
      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 =&gt; {
  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 =&gt; {
  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 = () =&gt; {
  return a() + b() + c();
}

如果 a() b() c() 都被 Mock 掉,那么测试 getResult 的意义就没有了,当这几个函数的内部被修改甚至无法正确返回,我们的测试无法不会测出来。

所以答应我,只用特殊替身隔离副作用好吗?

『单元测试』还是『集成测试』?

“单元” 这个词在不同语境下它的大小是不一样的,或许它的创立之初就认为不应该被严格定义,越是追求严格的定义往往越容易出现混乱。大多数人们认为 “单元” 的颗粒度应该是 “函数”,我记得某些书上也的确是这样定义的,但当上面这个 getResult 例子出现时,人们开始纠结它究竟是『单元测试』还是『集成测试』,如果使用 TDD (测试驱动开发) 它的演变可能是这样:

(生生凑出这么一坨代码,不必在意这段代码的真实含义。)

const getResult = () =&gt; {
  let result = '';
  const random = ~~(Math.random() * 100) % 2;
  if (random) {
    result += '$';
  } else {
    result += '#';
  }

  const now = (Date.now()).toString();
  for(let i =0; i &lt; now.length; i++) {
    result += ~~(Math.random() * 10);
  }

  const arr = result.split('');
  for(let i = 1; i &lt; 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 =&gt; {
  const result = getResult();
  t.is(result, '$xyxyxyxyyy']);
});

这个时候它一定是单元测试,经过不断的演进:

const getResult = () =&gt; {
  let result = '';
  const random = ~~(Math.random() * 100) % 2;
  if (random) {
    result += '$';
  } else {
    result += '#';
  }

  result += b();
  result += c();
  
  return result;
}

最终变成了我们刚刚看到的样子:

const getResult = () =&gt; {
  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

《有效的单元测试》

转载于:https://my.oschina.net/xbl/blog/3051953

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值