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

Or how Typemock Isolator evolved

阅读数:3963 2012 年 10 月 23 日 06:13

过去几年的经验告诉我:单元测试已然是“被解决的问题”了。所有的信息、图书、工具都摆在面前,你只要把 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(
         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 ();  

这就完事了。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 )关注我们,并与我们的编辑和其他读者朋友交流。

评论

发布