討論交流
我的两分钱 2022-12-01 156 0 0 0 0
程序员,程序开发,在UT中,底特律/芝加哥派和伦敦派是两个经典流派,这两者孰优孰劣有很多讨论,最近看了一个相关的视频,其中提到一本伦敦派的书叫<

在UT中,底特律/芝加哥派和伦敦派是两个经典流派,这两者孰优孰劣有很多讨论,最近看了一个相关的视频,其中提到一本伦敦派的书叫<>,虽然我自己则更偏芝加哥派多一点,但是伦敦派也有很多很好的观点,而且这本书也是Bob大叔推荐的,所以就拿来学习了一番。整体来说,我不得不说Kent Beck大神在序言中的评价非常准确:"这里展示的测试驱动开发的方式不同于我所使用的方式。虽然我还不能清晰地表达其中的区别,但我已从作者清楚、自信的技巧介绍中收益...通过书中的例子,了解作者如何思考编程,如何实践编程。这种体验将丰富您的软件开发方式,有助于您编程,而且同样重要的是,有助于您以不同的观点来看待您的程序。"下面就是我从这本书中摘取的一些我觉得有意思的技巧和思考方式


TDD Basics

  • 验收测试(AT)应该模拟用户的使用场景,只从外部与系统交互
  • AT是TDD的外层循环:
    • {AT失败 -> {TDD循环} -> AT通过 -> 新的AT}
  • AT应该是自动化进行的,这样在做AT的同时,我们也测试了构建和部署
  • 在AT中,只使用领域术语,不使用底层技术术语
  • AT比集成测试更大,集成测试主要是验证我们的代码与第三方系统能否一起工作,这和UT一样都是开发者的视角,而AT则是从用户视角来验证系统
  • UT的伦敦派认为,单测的基本单位是一个class,单测不但要检查一个class的状态,还要检测这个class与其他class的交互关系,甚至正确的交互才是单测的重点
    • 比如WeatherReporter通过调用WeatherForecastService来报告天气情况
    • WeatherReporter的UT不但要检查它确实能报告天气,还要检查它确实对WeatherForecastService进行了正确的调用
    • 由于伦敦派的单测以class为单位,所以在WeatherReporter的测试中,它将借助Mock技术来模拟WeatherForecastService来进行测试(书中用的Mock框架是JMock2)。事实上,也正是因为伦敦派UT大量借助Mock技术,所以作者的本意本来是写一本介绍如何在单测中使用Mock技术的书
    • 由于class间的交互是作者的关注重点,所以书中也强调了迪米特法则,让我们避免那种"火车代码"(obj.m1().m2().m3()...), 可以想象一下,如果对这一长串调用进行mock是多么的痛苦
    • UT还有一个流派是经典的底特律派(也叫芝加哥派),这个流派更注重对系统状态的检测,对象间的交互则不是重点。这两个流派哪个更好有很多争论,这里不展开。我个人比较推崇是Uncle Bob的方法,在模块边界是伦敦派,在边界内部是芝加哥派。(在我的读书笔记匠艺整洁之道中有更多介绍)
  • 启动TDD循环从一个『可行走的骨架』开始,它是一个尽可能薄的真实功能的实现,它应该只包括足够的自动化、主要的组件和通信机制,其中很重要的是要包含部署步骤。在开发『可行走的骨架』时,关注的是应用的高层架架构,因为不了解整体架构,就无法让构建、部署、和测试循环自动化
  • 从最简单的成功场景开始UT
  • 针对行为而不是方法进行单测,比如一个testBidAccepted()的测试只是告诉了我们它做了什么,但并没有说为什么,相反testBidWithHighestPriceAccepted()则更能说明测试的目的
  • (伦敦派)坚持outside-in的方式来开发,也就是从输入 -> 中间层 -> 领域模型 -> 其他的边界对象 (底特律/芝加哥派则反过来,先从领域模型开始开发,伦敦派反对这一点)

可测试的代码是好代码

  • 遵循单一职责原则的对象更容易测试,如果一个对象的名字包含and、or,那么很可能这个对象承担了太多的职责
  • 部分地创建一个对象,然后通过setter来设置属性完成创建工作,这种方法是脆弱的,因为开发者必须记住所有的依赖关系
  • 复合对象的API必须隐藏其组成部分的存在,并隐藏它们的交互,向外暴露出一个更简单的抽象,复合对象的API不应该比它的组件的API更复杂
  • 如果一个对象是上下文无关的(只处理局部的值和实例,或者是显式传入的值和实例),那么这个对象就更容易修改,也更好测试
  • 如果有机会,就把基础的值类型(String, Integer)包装成有业务含义的值对象,这样能让代码的表达性更好
  • 如果我们注意到一组值总是一起使用,这往往意味着,我们可以抽象出了一个业务概念
  • 如果可以就避免使用单例,因为这是一种隐式依赖,增加了UT的难度
  • 不要把技术细节(使用的设计模式,或者data, object, access, impl之类)包含在类或接口的名字中,类或接口的名字应该反映业务概念
  • 有些系统在其内部触发其自己的事件,最常见的例子就是使用定时器来触发某个业务操作。如果定时器隐藏在系统内部,则系统会很难测试,因为你无法从外部触发系统的行为,解决方法是把定时器做成系统的一个显性的可配置的依赖,从而可以从外部驱动系统

为什么UT可以改进代码

  • 从测试开始,意味着你必须首先描述目标,而不是先考虑实现,这有助于让目标对象保持在正确的抽象层次上
  • 要保证UT可维护,就必须限制其规模,这导致对象应该有更清楚的关注点分离,消除隐藏的依赖感关系
  • 写UT就必须向其传入它的依赖关系,这意味这我们将对象间的依赖关系显性化,从而有机会将其优化
  • 当我们在UT中检查对象间的关系/通信方式时,我们最后得到的类型和角色会更多地来自业务领域,而不是来自代码实现,这是因为我们有了更多的较小的抽象,让我们更加远离底层的实现

UT技巧

  • 在使用Mock的时候,我们应该Mock被测对象的同级对象,而不是其内部对象(这一点对于防止滥用Mock很有帮助)
  • 尽量不要Mock第三方库,因为我们很少100%了解第三方库,我们的模拟很多时候并不真正符合外部库的行为,更好的做法是用一个Adapter来集成第三方库,在UT的时候Mock我们自己的Adapter,在集成测试的时候保证Adapter和第三方库能按预期工作
  • Adapter很多时候其工作职责之一是在领域对象和外部对象之间进行转换,要注意的是,很多时候这种转换可以是通用的、和具体的领域对象类型无关的,比如,领域对象<->JSON对象间的序列化/反序列化工作,其实是可以做成通用工具的,这样在对Adapter进行单测的时候,就无需构建复杂的领域对象了
  • 增量式开发的一个技巧是让User Story符合INVEST原则(Independent, Negotiable, Valuable, Estimatable, Small, Testable)
  • Harmcrest是一个框架,通过自定义更能表达意图的匹配器(比如"aStringStartsWith")来提高UT的可读性
  • 改进的TDD:UT失败 -> 使诊断信息清晰有用 -> 让UT通过 -> 重构 -> 下一个UT
  • 不要Mock具体的类,Mock接口。比如对象A调用了B,我们在A的UT中要验证A对B进行了正确的调用,B可能有10个方法,但A只关心其中的两个,那么A就没必要知道其他8个方法的存在(这实际就是SOLID原则中的Interface segregation),这意味这我们在设计上可能漏掉某个概念,我们应该在这里抽象出一个接口,其中只包含A关心的那两个方法,然后在UT中Mock这个接口
  • Mock值对象是没有意义的
  • 如果在一个UT中包含了太多的断言(无论是关于值还是关于对象间的关系),那么要么是我们的测试过大,要么是被测试对象承担了太多职责
  • 不应该需要创建大量的fixture或者大量的准备工作,才能让对象进入可测试状态,这样的测试不但难以理解,也很脆弱
  • 断言/预期应该准确地表达出目标代码的行为中什么最重要,断言太过于细化会使得UT难以理解
  • 为一些常量起一个容易理解的名字,然后在UT中进行引用比直接使用常量更容易理解
  • 如果一个测试对象的构造很复杂,可以通过Builder模式来提高代码可读性
  • 失败的UT要能够清楚地解释失败的位置和原因
    • 断言的消息要有可解释性
    • 每个Mock的对象都应该有一个有意义的名字,以便在报错时提供信息
  • 每个UT要保持小而专注,每个UT只关注它感兴趣的部分,不断言、不预期和该UT无关的状态和行为,不同的关注点应该用不同的UT来覆盖
  • 当UT中既有预期(JMock用来定义期望的函数调用)又有断言的时候,因为预期是在断言之后检查,有可能UT报告断言失败,而真正的失败原因是预期失败,所以有时需要先调用JMock里Mockery的assertIsSatifised,从而先执行对预期的检查,然后再执行断言
  • 当检查对象间的交互关系时,尽量少用Sequence来对交互关系进行限制,这往往使得测试很脆弱,如果一个对象的一组方法必须按严格的顺序执行,这往往意味着,被测对象应该抽象出一个方法来对外屏蔽这种内部细节
  • JMock可以定义被Mock对象的状态,这里要注意的是,这个状态是从外部使用者的角度定义的一种期望的状态,而不是对象内部的真正状态,Mock对象应该独立于目标对象的实现细节
  • 持久层测试
    • 在测试开始的时候清理数据,而不是测试结束的时候,如果测试失败,数据可以用来诊断错误
    • 清理数据的顺序和操作应该保存在同一个地方,因为它必须随着DB schema的演进而更新,理想的方式是将其放在一个专门的对象中,这样任何使用该DB的测试都可以复用它
    • 不同于生产代码,测试应该明确显式地声明事务边界,一种常见的实现是定义一个UnitOfWork接口来定义要在事务中完成的工作,再用一个专门的Transactor对象来开启和结束事务,并在事务中执行UnitOfWork
    • 不要通过回滚事务来清理数据,因为不提交事务,就无法完整测试目标对象和DB的交互,也无法测试不同事务间的影响
    • 用真正的DB测试往往很慢,我们可以Mock持久层或者内存数据库来进行单测,在集成测试中再使用真正的DB
  • 多线程测试
    • 对线程同步机制的测试则没有100%精确的做法,但是通过调整测试的压力,基本能做到稳定捕获代码中同步机制方面的错误
    • 如果测试线程和被测线程没有同步,测试线程在被测线程执行结束前就进行检查,则有可能给出错误的测试结果
    • 同步测试线程和被测试线程的一个简单方法是让测试线程sleep一段时间来等待被测试线程,但这个方法只能偶尔为之,这不但不精确,而且如果大量使用,累加起来的等待时间则会让UT的执行时间无法接受
    • 另一种同步方法则是让JMock模拟一个被测试对象的状态(注意:如前所述,是从使用者角度人为加的一个状态,并不是被测对象内部真的有这么一个状态),当Mock的对象在执行完业务操作之后,JMock会update这个模拟的状态,而测试线程则一直等待这个状态达到某种ready的状态,然后再开始进行断言检查
    • 在Java中,我们可以用一个Executor来封装线程同步机制,在进行功能测试的时候,用一个Executor(JMock提供了DeterministicExecutor)把要执行的业务操作记录下来,避免让其在被测试对象的线程中执行,在对被测试对象的调用返回后,再在测试线程中显式地执行业务操作,进行业务功能测试
    • 上述Executor不但让UT容易进行,这也是一种好的编程方式,并发是一种技术细节,它和业务功能是不同的关注点,应该在执行业务功能的对象之外进行控制
    • 关键点是把对线程同步的测试和业务功能测试进行分离
    • 功能测试
    • 线程同步测试
  • 异步代码测试
    • 有时候,测试必须处理异步行为,比如,我们通过网络连续发起了两笔银行转账,然后我们想在UT中测试账户余额的计算是正确的,由于这两笔转账是通过网络进行的,其天然就是异步的,哪一笔先完成,两笔交易都结束了的时间点都是无法精确确定的,所以何时在UT中进行断言的检查就是一个需要解决的问题
    • 我们可以让测试线程sleep一段时间来等待,但这种策略只能偶尔为之
    • 要观察被测试系统的状态,我们要么轮询取样,要么是监听系统发出的通知,这也就要求被测试系统的状态被开发成可是观察的,这也是一个UT改进产品代码的例子

Tag: 程序员 程序开发
歡迎評論
未登錄,
請先 [ 註冊 ] or [ 登錄 ]
(一分鍾即可完成註冊!)
返回首頁     ·   返回[討論交流]   ·   返回頂部