测试不是问题,问题是怎么测试。
一、单元测试
我认为单元测试已经是无可争议的最佳开发实践之一。但是很多人并不同意这个观点。他们的说法无非是:写测试需要花很多时间,需求又经常变动,一但变动,一大片测试就作废了。这样又浪费时间,又降低效率。
但现实情况是:没有人不测试代码的。哪怕是最牛的开发者,也需要对自己写的代码进行测试。这一点可以去看《Coders at Work》。虽然有一些“牛人”测试的方法很原始——比如使用 printf
来查看运行结果。其实,从现代软件组件的角度来看,就是用 Log。而一但软件变得复杂,涉及十几个,甚至几十个模块相互依赖的时候,看 Log 显然又低效,又费力。很多时候需要添加一些 Log,分析一下是不是这一部分的问题,然后注释掉,再在别的地方再加一些 Log。如此反复。如果是动态语言还好,如果是需要编译的语言(比如做iOS开发),那么这些小的修改最后浪费在编译上的时间也是很多的。
当然,基于现在强大的 IDE 开发工具,还有一种非常方便的调试工具,就是断点调试。使用 Visual Studio 一路杀上来的程序员最喜欢这种方法了。只要在自己怀疑有问题的地方加一个断点,在这点处的上下文就都能看到了。还能通过单步执行来看到实际的执行路径,看看是不是和自己设计时候想的一样。但是,断点调试的主要功能是在已经知道某一部分有问题的情况下,查找和修复问题。关于这一点,其实可以引用质量管理体系中的两个概念:QC 和 QA。所谓 QC,就是质量控制(Quality Control)。通常的方法是对产品进行抽样,检查合格率。然后再通过合格率来改进生产流程。而 QA,叫作质量保证(Quality Assurance),方法是监控生产过程中的每一个环节,确保每一个步骤都是在“误差允许范围内”。当然并不是说 QA 就一定要优于 QC,但这里的比较已经超过了我的能力范围。感兴趣的朋友可以自己去查看网上的各种文章。
自动化的单元测试,就相当于实现了软件开发中的 QA(感觉业内现在常说的 QA,其实做的是 QC 的工作,或者叫 Acceptance,也就是验收)。但并不是每个人都接受自动化测试这个想法。原因之一是上面说的“嫌麻烦”。而还有一派则认为:单元测试并不能完全消除 Bug,所以不值得投入时间和精力去做。持有后一种观点的朋友,很可能是被一些公司的广告给骗了。单元测试本来就不是用来发现 Bug 的工具,如果想发现 Bug,那最好的工具应该是代码的静态分析工具。而单元测试,按照我的理解,应该是使用一组代码来描述系统的行为。具体来说,进行单元测试至少可以获得下面这几个好处:
1. 避免过度设计
一但开始进行测试,开发的目标就会改变。原来比较倾向于“完美设计”的方案,就会被“通过测试”代替。“完美设计”会让人在开发的时候“想入非非”,常常为“不存在”的未来需求过度设计。相信每个程序员,或者架构师都遇到过这样的问题:现在这个设计虽然可以使用,可如果用户量达到 xx 万,就不能用了。虽然这种设计并不总是杞人忧天,可如果你 PV 还不到 100 每天,就使用 Google 的架构,那显然是太复杂了。而使用单元测试之后,开发的目的就无比的明确:通过所有的测试。你当然可以通过“完美设计”来更优雅地通过所有的测试。但这种“完美设计”只局限在这个局部,影响的范围有限。
2. 大胆重构
单元测试的另一个好处就是:定义了被测系统的行为。这个时候,如果你对这个模块/函数进行重构,就不会担心会引入破坏性的改变了。而在没有测试的时候,可能出现的情况就是:我只改动了两行代码,怎么整个程序都不能用了。如果没有单元测试,结果就是一个模块可能变的越来越大,越来越复杂,越来越不可维护。没有人敢去修改那些没有测试保护的旧代码,因为至少它现在还能用,如果改了,不知道还能不能用。相反的,如果有测试保护,那么修改和重构就是有保障的。也许有人会说:就算有测试,也一样可能引入原来没有被测试到的问题,结果还是破坏了代码。这当然是可能发生的情况。但因为有测试,所以那些通过了的测试会告诉你:你的修改没有破坏什么,这就在很大程度上减少了查错的范围。要知道:Bug 是加班之源!
3. 简化集成
虽然单元测试是“自扫门前雪”的测试,但同样对团队合作有很大的促进作用。在没有测试的时候,一个错误的发生通常伴随的是各种甩锅:每个人都会认为自己的代码没有问题,是别的人错误导致的系统错误。为什么会这样呢?因为程序代码中大部分是逻辑判断。哪怕是经历的不完整的逻辑推理,也会给做推理的人一种假象:我的逻辑结果是这样的,所以不会有错。同样,因为没有测试,两个相互交互的模块并不能直观的确认是发生了什么问题,只能大家坐在一起,分析调用者给被调用者发送了什么参数,被调用者应该怎么响应。而这种合作通常也是伴随着争吵和推责的。
如果两个模块都有测试呢?那么至少在每个独立的模块内部,行为已经被详细描述过了,如果出了问题,那最可能的就是在模块之间的界面处有问题。这就比原来更容易确定错误的位置和类型。也就使得模块间的集成成本有所下降。
4. “吃狗粮”
有的时候,我们写的代码(模块、函数)其实是给别人提供的服务。这里有一个常用的说话就是:要吃自己的狗粮。如果在开发的时候同时写测试的话,那么就相当于在测试中使用自己开发的函数,那么就更容易发现使用过程中的不方便的情况。
但我也想强调一个经常被说,但可能没什么人当回事的问题:测试代码也是代码,不要写低质量的测试代码。我相信有人和我一样,会犯懒,对于类似的测试代码,直接复制过来,简单改两下就用。这当然不是不可以,但改的时候也应该把这段代码仔细改全。我之前经常犯的一个错误就是“张冠李戴”,也就是只改了一下函数名,别的都没怎么细看,就去运行测试了。这样的结果当然就是得改很多次,才能能测试通过。
我在 《The Art of Unit Testing》这本书里,学到了一个很实用的技巧:在函数名里用下划线分成三段:被测的函数名_输入条件_返回值或后果。通过这样的命名,一个测试的功能就很显然了。以后在全部折叠的时候,也能知道一个函数是在测试什么东西。非常的方便。
二、测试驱动开发/设计
把单元测试用到“极致”的,可能就是测试驱动开发/设计了。虽然我很推崇这种开发模式,但实际使用的时候也还是不能完全实现:先写测试再写代码。至少我会先写一个骨架,把需要的依赖都摆放好,再开始写测试。酷壳网的陈皓曾经提到一个开发流程(他是用来反驳测试驱动开发的),就是:通常是在脑子里进行一些构思和设计,然后开始实现,并在实现的过程中进行优化。我想大部分和程序员都在使用这个开发的流程(其实我也是)。不过,我认为这个只是习惯问题。就像我前面说的:测试代码也是代码。我们通过写测试代码,对要写的代码的行为进行描述,来确定下面要写的代码的目标行为和开发边界。我在使用过一段时间之后,发现一但基本结构完成之后,实施测试驱动开发,也不是不可能。
我觉得测试驱动开发还有一个好处,就是避免事后去补测试。当代码已经完成,再根据代码逻辑去补测试,会让人感觉很无聊。因为那变成了一个完全的体力活。 如果使用测试驱动开发/设计,就完全不一样了。那相当于你给自己定了一个要现实的目标,然后再去解这个题目,就不再全有这种沮丧的感觉。同时,因为需求一直在增加或者修改,每次修改的结果,可能是一部分代码被丢弃,但因为没有写多余的代码(为了未来或者秀智商而添加的代码),甚至本来就是临时的代码,所以删删改改的,也不会有太多的心理负担。不然本来我用了很大的脑力才想出来的解决方案,巧妙又精致,结果因为需求改了,全没用了。这也是为什么程序员都不喜欢需求变更的原因之一:那些代码都是我智力的杰作,一行也不能改!而如果你的代码只是为了通过测试,就不再有那么强的神圣感了。
三、AppVeyor 上的测试结果显示
本来我是想在 Travis、AppVeyor 和 DaoCloud 上都实现这个功能,结果 Travis 和 DaoCloud 并不支持这个特性。结果就只好在 AppVeyor 上使用了(这也难怪 NuGet 的 packages 都使用 AppVeyor 作为 CI 的平台了)。本来,AppVeyor 为这个功能提供了非常完备的支持:使用一些测试自动发现的机制,直接找到工程里所有的测试用例,然后执行。可这个对于 .NET Framework 很好用的功能,对 .NET Core 好像不是很友好。我在使用 AppVeyor 自带的测试发现的功能时,总是报错。后来我是直接使用 dotnet 的 test runner 来运行测试的。这样的结果就是:测试的结果不会显示在 AppVeyor 的 TESTS 页里,那样非常不爽啊!研究了一下文档,我找到了这个解决方案:
...
test_script:
- cmd: dotnet test CoreCRM.UnitTest -xml .xunit-results.xml
- ps: $wc = New-Object "System.Net.WebClient"
- ps: $wc.UploadFile("https://ci.appveyor.com/api/testresults/xunit/$($env:APPVEYOR_JOB_ID)", (Resolve-Path .xunit-results.xml))
...
首先用 dotnet 的 test runner 来执行测试,并把结果保存到一个文件里。感谢 xUnit.net 的工作,让这个事情变得这么简单。其后使用 PowerShell 脚本把这个结果 Push 到 AppVeyor 的服务器。这样就实现了结合 dotnet 的 test runner 和 AppVeyor 的测试结果页。效果非常不错。不过这里也发现,PowerShell 是个很强大的工具啊!
四、实践才是真理
理论说多少都是理论,实践才是真理。目前,针对 Profile 的 Repository 已经完成了单元测试。测试代码被放在单独和一个 project 里。一开始我的设计是:ProfileRepository 是依赖于 UserManager 的,这样,在 Controller 里就只需要依赖 ProfileRepostory 就可以了。但在测试的时候发现,这样并不方便:ProfileRepository 其实和 UserManager 没有关系,确要依赖于 UserManager,结果就是大家权责不够明确。逻辑上也比较混乱。在重构了 ProfileRepository 和 ProfileController 之后,感觉清爽多了。之前对于 Controller,特别是 AccountController 的测试感觉非常的不好写。在完成了 ProfileRepository 的测试之后,这个问题也迎刃而解了。
我会在后面的开发中,努力实践测试驱动开发,用实际的开发过程来证明这种方式的好与不好。如果你也有兴趣参与,可以 fork 我的 repo:https://github.com/holmescn/CoreCRM ,一起来学习、讨论。提 issue 的朋友请 feel free to use Chinese。