过去几年的经验告诉我:单元测试已然是“被解决的问题”了。所有的信息、图书、工具都摆在面前,你只要把 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 )关注我们,并与我们的编辑和其他读者朋友交流。
评论