写点什么

解决真实世界的单元测试问题

Or how Typemock Isolator evolved

2012 年 10 月 23 日

过去几年的经验告诉我:单元测试已然是“被解决的问题”了。所有的信息、图书、工具都摆在面前,你只要把 NUnit 拣起来就可以上路了,不是么?

不是。

即便是在下决心要开始写单元测试之前,我们也得从别人那里吸取经验,从那些好的坏的故事里,那些令人绝望或是见证奇迹(一个测试就省了我一周时间!)的时刻中,取其精华弃其糟粕。即便这样,等我们勇敢上路之后还会意识到,要学的东西还多着呢!

我想跟你讲讲我在单元测试这片大陆上一段奇妙的旅程。我们 Typemock 的团队已经在这块大陆上游历了数年,这些经历也改变了我们的产品开发过程。Isolator 是我们的主打产品,它最开始是作为 mock 框架出现的,但是当我们对在真实世界中单元测试的问题了解的越来越多,我们开始开发一些特性帮人们解决这些问题。直到现在,还有很多事情没搞定。

不过我们还是从头讲起吧。Typemock 有一个简单的信念:让单元测试变得很容易。

够简单吧,可是容易么?

呵呵……

写单元测试可不是件容易的事情。单元测试的好处数不胜数,这大家都明白。但你得好好加把劲,才能享受到这些好处。

咱们都有个代码库。有些幸运的家伙会面对一块未开垦的处女地,但更多的人却会遇到大量“遗留代码”,这才是常态。我们写测试的时候,测的就是那些遗留代码。这真的很棘手啊。

Typemock 刚刚起步的时候,不修改代码就给遗留代码写测试还是件不可能的事情。但这正是 Isolator 的主要目标:在不修改代码的情况下编写单元测试。当 Isolator 能够 mock 每一种.NET 对象类型,给遗留代码写单元测试已成为可能。

演化中的 API

随着时间推移,我们明白了要好好控制 API。最开始的一版 API 是基于 string 的,比如要伪造 DateTime.Now 的时候,你需要这样写:

复制代码
Mock mockDateTime = MockManager.MockAll<DateTime>();
mockDateTime.ExpectGetAlways("Now", new DateTime(2000, 1, 1));

看上去不太漂亮,但是管用。然而这些代码稍一重构就会废掉。所以我们换成了录制 - 重放(record-replay)模型,对重构的支持友好一些了,虽然看上去有些怪异:

复制代码
using (RecordExpectations recorder = RecorderManager.StartRecording())
{
DateTime.Now = new DateTime(2000, 1, 1);
}

这一版 API 算得上是一次革命性的飞跃,但是录制 - 重放模型已经过时了,而且这个版本还有些技术问题需要解决。所以当 lambda 表达式出现以后,我们的 API 又为了保证可读性和支持重构来了个华丽的转身:

复制代码
Isolate.WhenCalled(() => DateTime.Now).WillReturn(new DateTime(2000, 1, 1));

在当前这个版本中,我们还做了另一个简化,用“fake”换掉了“mock”这个词汇,“mock”和“stub”被用的太多了,而且总是被滥用,被误解。为了避免麻烦,我们决定回避这个问题,省得还要把 mock 和 stub 之间所有的细微差别给新手一一讲述。

贴心的邻居

Isolator 不只是 Visual Studio 的插件而已──我们得让它跟其他工具和供应商集成。代码覆盖率,性能分析器,构建引擎等等,不管是啥,只要你能想得到。Isolator 需要良好的兼容性,这样大家就能在不同的配置下用不同的工具跑测试。

说到跑测试,脱离 Visual Studio 跑怎么样?当你开始做自动化构建的工作以后,你就会学到很多 MS 家族中琳琅满目的工具,当然也包括全能的 TFS 大神。Isolator 在分析器上做了大量的集成工作,让测试能被纳入持续集成流程中运行。因为不同的团队会用不同的工具集和 CI 服务器,为了保证在不同环境下的适用性,我们是花了不少力气的。

健壮的 API

随便找个考虑过写单元测试的人来问问,她都会忧郁地跟你说:我的代码要改,可我不想每次都要改测试。你能帮帮我么?

能力越大责任越大,这句话放到 Mock 框架上也一点没错(蜘蛛侠也是一样)。改变行为的能力来源于了解对象内部的行为。而这种如同 X 射线一般的功能,也正是它的阿喀琉斯之踵──改变内部代码同样也会影响测试。

单元测试也需要维护。在设计 API 的时候我们也考虑了这一点。举个例子看看,下面是一个对象的构造函数(出自一个叫做 ERPStore 的开源项目):

复制代码
public AnonymousCheckoutController({1}
ISalesService salesService
, ICartService cartService
, IAccountService accountService
, IEmailerService emailerService
, IDocumentService documentService
, ICacheService cacheService
, IAddressService addressService
, CryptoService cryptoService
, IIncentiveService IncentiveService)

它的参数很多。在测试里我可能需要伪造这些依赖:

复制代码
var fakeSalesService = Isolate.Fake.Instance<SalesController>();
var fakeCartService = Isolate.Fake.Instance<ICartService>();
var fakeAccountService = Isolate.Fake.Instance<IAccountService>();
var fakeEmailerService = Isolate.Fake.Instance<IEmailerService>();
var fakeDocumentService= Isolate.Fake.Instance<IDocumentService>();
var fakeCacheService = Isolate.Fake.Instance<ICacheService>();
var fakeAddressService = Isolate.Fake.Instance<IAddressService>();
var fakeCryptoService = Isolate.Fake.Instance<CryptoService>();
var fakeIncentiveService = Isolate.Fake.Instance<IncentiveService>();
var controller = new AnonymousCheckoutController(
fakeSalesService,
fakeCartService,
fakeAccountService,
fakeEmailerService,
fakeDocumentService,
fakeCacheService,
fakeAddressService,
fakeCryptoService,
fakeIncentiveService);

如果构造函数需要接受另外一种类型怎么办?或者删掉一个参数?我都得改测试。

所以我们做了一个 API,用来解除构造函数定义和单元测试调用的耦合关系:

复制代码
var controller = Isolate.Fake.Dependencies<anonymouscheckoutcontroller>(); </anonymouscheckoutcontroller>

这就完事了。Fake.DependenciesAPI 会创建一个 AnonymousCheckoutController 类型的真实对象, 然后把所有依赖对象的伪造实现传进去,丝毫不涉及它们的类型。即便构造函数发生变化,测试依然工作。测试和代码之间的耦合变小了,也更容易读懂了。

更友好的测试

有写单元测试经验的人都知道,写测试是一种可以后天获取的技能。我们都能学会怎么把测试写好,但往往都是一路披荆斩棘。所以我们在考虑怎么让这个过程变得简单一些。怎样让别人避免重犯我们曾犯过的错呢?

这时候我们给 Isolator 引入了另一个功能。它可以检测测试,并在 Visual Studio 标记出常见的错误(例如测试中没有断言)。它同时还会给出修复的建议。

(点击下图放大)

改进反馈环

很久以来,Isolator 都没有一个test runner。这代表了我们的态度:用户自己选择一种最好的工具,我们会兼容它。但新问题逐渐产生,在解决问题的过程中,我们不得不开始考虑开发过程的延续性了。

那些曾写过大型测试套件(test suite)的人都会希望测试跑的快一些。我们一直致力于让Isolator 跑的更快,但我们也觉得这似乎并不是最终的答案。大型测试套件执行时间确实长,但人们不必每次都要完整执行。实际上,只有那些跟你修改过的代码相关联的测试才需要执行。其他测试可以换个时间跑,比如提交之前,也可以到服务器上跑。

但这也不是问题的全部。有经验的测试人员看到他们三年前写下的测试时,会不敢相信自己的眼睛:我竟然写过这么烂的测试!烂的测试不仅仅是容易失败,它们有时候根本都不能算是单元测试──我们只能把它们叫做恶心的集成测试了。它们会跑的很慢。大型测试套件跑的慢的原因不仅仅是代码多而已,里面有些测试天生就是慢的。

这时候我们仍然没有决定要写一个特别的runner,直到修bug 进入了我们的视野。它一锤定音。测试失败以后,你会去检查哪部分修改导致了测试失败。你尝试理清脑海中的谜团:我干什么了?我改了哪些代码?为什么这个测试失败了,其他的还都能过?通常得调试上十次八次的,你才能把问题解决掉。

跟其它人一样,我们Typemock 的同事们也不喜欢调试。最后我们恍然大悟:这一切都是紧密联系在一起的。我们要加速完整的开发- 测试体验,而不是仅仅让测试写得更快,或者跑得更快。它的目标是整个迭代式过程:写测试、跑测试、修测试,周而复始。

Isolator 的 test runner 就是要解决这整个一摊子问题。它会自动选择跟修改过的代码有关的测试执行。为了让反馈周期尽可能短,它还会自动忽略运行时间长的那些测试。它会显示哪些测试覆盖了哪些代码。它还可以指示出最近修改的代码有哪些,指引你找到 bug 的可能位置。它会鼓励人用测试覆盖更多代码,于此同时还可以保证反馈周期的紧密,让写测试这件事可以持续进行。

小结

Isolator 的故事讲完了。一开始的时候,我们只想解决一个问题。随着写单元测试的人越来越多,我们意识到可以为他们提供更多帮助,解决他们面对的挑战。

写单元测试依然不是件容易的事情。我们还在路上。

作者简介

Gil Zilberfeld

Typemock 的产品负责人。他在软件开发领域沉浸了 15 年之久,涉足过各种角色,从开发到项目管理,再到流程实施。他在演讲中、博客上讲述单元测试的一切,帮助程序员从新手成长,在项目中把单元测试作为核心实践实施。他的邮件地址是

gilz@typemock.com ,你还可以在

这里找到他的博客。

查看英文原文 Tackling real-world unit testing problems


给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。

2012 年 10 月 23 日 06:134068
用户头像

发布了 197 篇内容, 共 44.1 次阅读, 收获喜欢 15 次。

关注

评论

发布
暂无评论
发现更多内容

练习 3-1

闷骚程序员

Week 03 学习总结

Jeremy

【架构师训练营 -Week-3】总结

Andy

学习总结 - 架构师训练营 - 第三周

走过路过飞过

【架构师训练营 - 作业 -3】单例和组合

Andy

手写单例模式

师哥

架构师训练营 第三周 作业

极客

极客大学架构师训练营

week3作业

张健

设计模式

wei

深入学习单例模式和组合模式

拈香(曾德政)

单例模式 极客大学架构师训练营 组合模式

关于设计模式学习的简短总结

林昱榕

总结 极客大学架构师训练营

第三周学习笔记

子豪sirius

架构师训练营 第三周 作业

极客

架构师训练营第三周学习心得

路人

极客大学架构师训练营

面试官:你们公司用什么框架写UT

java金融

Java 单元测试 powermock

架构师训练营 -- 第三周 -- 总结

lei Shi

week3 命题作业

小叶

极客大学架构师训练营

2020-06-20-第三周学习总结

路易斯李李李

设计模式应用

wei

设计模式——架构师的重要武器

拈香(曾德政)

设计模式 架构师 极客大学架构师训练营

Nginx系列教程(二)| 一文带你读懂Nginx的正向与反向代理

JackTian

nginx Linux 运维 lnmp 正向代理与反向代理

Week 03 作业

鱼_XueTr

组合模式

第3周总结

娄江国

本周总结

Thrine

架构师训练营第三周总结

olderwei

设计模式--组合模式demo

破晓_dawn

第 03 周作业

Jeremy

架构师第三周

Tulane

第三周学习总结

潜默闻雨

架构师课作业-第三周

Tulane

第三周命题作业

Geek_a327d3

作业

InfoQ 极客传媒开发者生态共创计划线上发布会

InfoQ 极客传媒开发者生态共创计划线上发布会

解决真实世界的单元测试问题-InfoQ