这是鼎叔的第六十四篇原创文章。行业大牛和刚毕业的小白,都可以进来聊聊。
欢迎关注本专栏和微信公众号《敏捷测试转型》,星标收藏,大量原创思考文章陆续推出。
本文观点参考自Lasse Koskela,他是《测试驱动开发的艺术》的作者。
软件缺陷通常是由低质量的代码引起的,但是在复杂项目中,要维护这些代码简直就是噩梦。新加入的开发者想对它进一步修改,更是举步维艰。测试驱动开发,或许能解决这个问题,利用测试构建出高维护性和满足客户需求的软件,它也是XP(极限编程)的核心实践。
我们常说的TDD,通常指细节层面的UTDD(单元测试驱动开发),以测试驱动的方式编写开发。行业还有一个概念- ATDD(验收测试驱动开发),指在较高层次(特性功能层),以测试驱动的方式构建系统。前者保证内部质量,后者保证可见的外部质量。
TDD带来高质量和高效率
提倡短周期的TDD从一开始就能保证较高的代码质量,它完全颠倒了软件开发的旧方式(即先设计,然后编码实现,再测试),即先写测试描述目标,然后写代码达成目标,最后重构改进设计。
完全可测试的代码和不断演化的简洁设计,能避免开发陷入代码越写越糟的恶性循环。TDD保证了所有代码都是可用,且被测试覆盖的。由于不用实现考虑实现细节,我们可以从更健壮的角度思考函数接口行为和外部调用方式。
写代码要基于测试先行的小步前进,增量式构建系统,在发生错误时很容易定位代码行,这样可以大幅节约后期的调试时间。我们也就有时间做更有意义的事,如清理代码,学习工具及技术。
而ATDD则是把客户需求全部转换成可执行的具体功能测试,拉近了客户和开发者的距离。在ATDD中我们更关注系统行为的测试,而非对象行为的测试。
TDD和ATDD非常互补,ATTD能驱动开发过程(做正确的事),而每个功能点上则使用TDD(正确地做事)。它们的组合让我们对交付的代码更有信心。
TDD三步曲(测试-编码-重构):正确地做事
三步曲代表着红(测试失败)-绿(测试通过)-重构代码的状态迁移。
写恰好足够的代码,仅仅是为了修复当前失败了的测试,不要一下子写出整个功能的代码再来花很长时间让测试通过。
所谓增量式开发(小步快跑),就是在“实现新功能”和“调整设计”两件事中来回切换,采用更经济的演进式设计方法。我们需要为可能的变化进行准备,但又要避免做无用功。最后的重构则是“在不改变外在软件行为的基础上,改进程序的内部结构”。
TDD整个过程要遵守严格的纪律,每次修改后运行自动化回归测试,用“测试PASS的信心”来交换开发速度。
ATDD-做正确的事
增量式开发模式中,客户有权决定哪些功能优先开发,从而被激发对项目的热情,而验收测试(AT- Acceptance Testing)则是整个团队(开发,测试,产品,客户)沟通的共同语言。“以需求文档为沟通媒介”很难清晰地表达出意图,而“以测试为规约”则更加精准、可靠和直接,缩短反馈周期。
测试驱动开发的工具支持
针对单元测试级别的TDD工具统称为xUnit。而ATDD的测试框架类型就更多了。前些年软件公司使用的最热门的验收测试工具是Fit/Fitness,表格形式的工具可以让非技术背景的客户和产品经理一起参与测试。Fit表格关联了测试夹具,可以自动执行表格内容的测试,并显示对用户友好的、多彩的测试结果。
此外还有纯文本的测试工具,比如通过关键字驱动或者利用日志来测试。
持续集成基础设施也是至关重要的保障,因为采用TDD的团队会共享代码,任何人都可以修改代码。
静态代码分析工具和代码覆盖率分析工具,对刚采用TDD的团队可以具体指出代码测试的不足,这个帮助很有必要。
TDD实践步骤详解
第一步:从需求到测试
开发者首先要把需求划分为要做的事(任务),用测试的形式来表达任务有利于我们记住要“完成”的定义,避免脱离了用户需求。一个好的“测试”应该是原子化的,独立的。
我们如何为一段还不存在的代码写测试呢?这需要我们想象产品代码应该如何易用,这种想象可以称之为“意图编程”。我们把注意力集中在“能有”的,而不是“已经有”的东西上,把需求分解为一系列小的,紧凑的测试。
第二步:用TDD开发模版引擎
第一步得到的测试列表是一个活文档,可以根据我们的进展而不断添加,接下来要让它们挨个通过。用自己认为合理的方式设计模板引擎的工作方式,把模板文本作为参数传给构造函数,验证结果与期望是否一致。
当然,编译器一开始会报错,因为某些类根本不存在,我们添加类,继续补充相应的方法。然后运行测试,测试必然会失败(因为我们还没有实现这些方法),继续补充最基本的产品代码,直到第一个测试通过,这个过程要尽可能简单快捷。
对于复杂逻辑的实现,我们可以采用广度优先或者深度优先的方式。如果是广度优先,我们会集中实现高层的功能,低层功能暂时用伪实现。若采用深度优先,我们会先实现底层功能,在所有底层功能都实现后才会组合来实现高层功能。
第三步,清除伪实现(尤其是硬编码),重构代码。
验证添加的测试确实被执行了,测试列表都通过了(包括特殊情况的数据测试),我们通过重构避免代码的“腐坏”,清理重复和冗余的代码,移除多余的测试。利用夹具使测试更加紧凑,用测试替身替代真实对象。
第四步,添加错误处理,最后让代码尽量精简。注意验证异常中的详细信息,保持方法中的代码抽象层次的一致性,这样会提高代码的可读性。
第五步,增加更多的系统测试,如耗时的性能测试。测试替身可以提高测试速度,降低依赖性。
TDD的指导原则
总之就是:绝不跳过重构,尽快变绿,犯错后减慢速度。
为了提高可测试性,设计上要尽量使用组合而非继承,掌握参数化测试手段(数据驱动测试),正确恰当地隔离依赖。
如果我们是在糟糕的遗留代码上进行TDD,首先要进行代码分析,确定变更点代码,进而确定测试点,从近距离测试,也从远距离寻找合理的测试点(如网络和日志),小心地移除某些依赖,并暴露依赖的接缝。一旦有了足够的测试覆盖,就可以放心地引入变更。
集成测试中的TDD
TDD并非只对应单元测试,集成测试也是用来做TDD的。和单元测试的不同在于,集成测试会真实地访问数据库,访问文件系统,花费的时间更多,需要更完整的基础设施,可能需要改变数据库模式,进行针对性的重构。不足之处是集成测试难以模拟特定的异常场景。建议我们充分利用单元测试和集成测试两者的优点来实践TDD。
多线程并发是TDD实践的难点,代码中的任何同步都会对相邻线程的并发性产生影响,因此并行编程出错的可能性更大,还可能遇到“死锁”和“饥饿”等现象。我们需要针对“线程安全”进行编码,在测试中尽量避免用“钩子”来控制产品代码的执行过程,以及等待验证异步调用的结果。
ATDD的进一步阐述
验收测试是用业务问题领域的语言来描述的测试用例,描述简洁准确,无歧义,侧重于“做什么”和原因,而非“如何做”。最终的所有权属于客户(利益相关方)。测试角色在团队中既属于领域专家,也属于技术专家。
验收测试的格式,可能是声明式的表格结构,因此验收测试自动化工具可能和系统实现的语言不同,强调客户容易理解,简单易懂。ATDD要在功能层面保证“软件做了我想要的事情”,而不是从技术上保证。
一个ATDD过程周期非常简单,分为:挑选一个用户故事(从需求中拆解)、写测试用例、自动化测试、实现功能,这四大步骤。
建议一步步实现验收测试,而不是一下都实现。大部分团队都会自己实现验收测试,不依赖于专门的验收测试人员。
注意,实现功能这个“第四步”,就可以扩展为一个或一系列的TDD小周期(测试-编码-重构),这样ATDD和TDD就在不同层次上形成紧密协作的闭环,相辅相成。
ATDD给出了需求完成的“定义”,通过有意义的例子,而不是复杂模糊的描述来表达需求。每个人都会贡献自己特有的知识和技能来解决问题。客户能看到自己的需求被真正满足了;开发人员看到客户参与验收测试,并认可了自己代码的价值。
验收测试是否应该操作真实用户的外部界面?答案是“看情况”,如果真实界面难以访问,或者其反馈成本高、性能慢,我们也可以绕过界面,通过API或者内存数据库来进行验收测试。但是在这么做之前,我们先要确认替换后的被测系统和替换之前是否足够相近,或者不得不这么做。
验收测试不必测试所有东西,而是聚焦用户故事的本质特质,同时避免波动频繁带来的维护性问题,选择技术障碍最小的地方越过它。
实现ATDD的方式主要有:
1 端到端。理想情况下,应该把被测系统当成一个整体,端到端的视角,和客户观察系统的角度一样,最能体现系统的真实情况,以及对系统的广泛覆盖。但是这种测试太脆弱,尤其UI变更频繁,同时速度也慢。
2 绕过UI的测试,即绕过系统的壳,避免了不必要的改动,调用抽象UI或者API来访问系统内部。这样易于实现且执行速度快,但会让客户困惑,也需要手工测试弥补图形界面的测试质量。
3 直接测试内部逻辑。利用验收测试工具,把业务逻辑隔离再几个精准的测试中。它和单元测试的不同在于,前者是用客户领域的语言编写的,后者是为开发者编写的。
写在最后,在ATDD技术实践上可以用到的技巧还包括:
1 把系统的一些非关键构件替换成测试桩或者仿真器,利用测试后门(替代性接口)等。
2 加快测试执行速度。比如检查所有的测试是否真的需要这么多的初始化,以及用一次初始化完成一批用例的前置准备。
比如把测试套件分为两组,一组是有副作用的(如写数据库),一组是没有副作用的,先执行后者。
还有,减少磁盘I/O访问的动作,或者分布式执行任务,利用好负载均衡。
3 减少测试的复杂度。如消除代码重复,优化命名,利用公共函数把验收测试组织成有机的整体,利用缓存环境对象。
4 管理好测试数据。在保持好自动化验收测试的代码干净整洁的同时,提高测试数据的可管理性。如把测试数据小块化,或动态产生测试数据,以及对测试数据做好版本控制。