过去几年的经验告诉我:单元测试已然是“被解决的问题”了。所有的信息、图书、工具都摆在面前,你只要把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();
var fakeCartService = Isolate.Fake.Instance();
var fakeAccountService = Isolate.Fake.Instance();
var fakeEmailerService = Isolate.Fake.Instance();
var fakeDocumentService= Isolate.Fake.Instance();
var fakeCacheService = Isolate.Fake.Instance();
var fakeAddressService = Isolate.Fake.Instance();
var fakeCryptoService = Isolate.Fake.Instance();
var fakeIncentiveService = Isolate.Fake.Instance();
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的故事讲完了。一开始的时候,我们只想解决一个问题。随着写单元测试的人越来越多,我们意识到可以为他们提供更多帮助,解决他们面对的挑战。
写单元测试依然不是件容易的事情。我们还在路上。 |