zoukankan      html  css  js  c++  java
  • CoreCRM 开发实录 —— 单元测试、测试驱动开发和在线服务

    测试不是问题,问题是怎么测试。

    一、单元测试

    我认为单元测试已经是无可争议的最佳开发实践之一。但是很多人并不同意这个观点。他们的说法无非是:写测试需要花很多时间,需求又经常变动,一但变动,一大片测试就作废了。这样又浪费时间,又降低效率。

    但现实情况是:没有人不测试代码的。哪怕是最牛的开发者,也需要对自己写的代码进行测试。这一点可以去看《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。

  • 相关阅读:
    Haskell Interactive Development in Emacs
    Access Java API in Groovy Script
    手工设置Eclipse文本编辑器的配色
    Color Theme of Emacs
    Gnucash的投资记录
    Special Forms and Syntax Sugars in Clojure
    Use w3m as Web Browser
    SSE指令集加速之 I420转BGR24
    【图像处理】 增加程序速度的方法
    TBB 入门笔记
  • 原文地址:https://www.cnblogs.com/holmescn/p/corecrm-unittest.html
Copyright © 2011-2022 走看看