【转贴】敏捷质疑: TDD

Q: 为什么通过单元测试发现的 Bug 很少 ?

A: 单元测试不是用来发现 Bug 的, 而是用来预防 Bug 的. 如果采用 TDD, 测试用例完成之时, 产品代码尚未编写, Bug更无从谈起.

Q: 那是否写单元测试就能提高代码质量了 ?

A: 关于这一点, 似乎有人不这么看, <<TDD Opinion: Quality Is a Function of Thought and Reflection, Not Bug Prevention>>. 不错, 代码质量并不必然关联到单元测试, 诸如净室软件开发之类的方法依然可以在没有单元测试的情况下得到高质量的代码, 但这是另外一个问题. 或许主观上, TDD的本质更接近于促使你把质量内建在思维中, 但客观上, 在其它条件都相同的情况下, 单元测试依然能够起到预防 Bug 的作用.

Q: 单元测试怎么能反映/代替需求 ?

A: 单元测试未必能直接反映宏观上的需求, 但

  1. 功能测试和集成测试能够反映宏观需求.

  2. 单元测试能够反映系统的其它部分对当前单元的需求.

而从文本的角度, 测试用例的名字就是需求的描述. 换句话说, 你从传统的需求文档中把描述抠出来, 放到测试代码中作为测试用例的名字, 你便拥有了可执行的需求文档

一个 RSpec 写的功能测试用例 (不要怀疑, 它确实是可以运行的):

  it "should show welcome message after login" do

    login_as_chelsea

    get :index

    response.should have_text(/欢迎 chelsea/)

  end

  it "should not show welcome message after logout" do

    logout

    get :index

    response.should_not have_text(/欢迎/)

  end

单元测试的例子:

    public void testShouldBeFreeFrom2amTo5am() throws Exception { //直接业务需求

        ...

    }

    public void testShouldThrowExceptionIfCannotFindConfigFile() throws Exception { //来自系统其它部分的需求

        ...

    }

测试用例并不排斥业务层面的需求文档, 一个高层的, 突出业务价值的需求/愿景描述对于快速理解系统是非常有帮助的, 但/只是测试用例以另一种方式描述了真实的系统, 它具有两个突出的优点:

  1. 它不会说谎, 即永远与系统真实的行为同步

  2. 它是可执行的, 它可以不知疲倦的, 成本极低的, 时时刻刻, 反反复复的追问你的系统是否符合需求

Q: 需求变了怎么办? 岂不是有大量测试用例需要修改?

A: 难道不是应该的吗? 难道以前的需求文档在需求发生变化时不需要修改?  哦, 或许它们不需要, 因为没人会关心, 对代码也没什么影响, 需求文档在度过最初的几周后便被扔在配置库里再也没人管它了.

1073x: "这也是很多程序员不愿意作单元测试,并且抵制TDD的原因(虽然他们两者根本不是一回事)。在开发过程中确定什么应该用测试用例描述,什么可以不用,是很重要的。一段产品代码与之对应的测试代码的粒度,数量,都是需要斟酌的问题。"

是的, 这关系到你的测试策略. 然而通常的测试策略对单元测试的要求都是尽可能周全. 于是这就是一个测试设计的问题. 是的,测试代码也需要设计, 也需要重构, 也需要 Domain Specific.

Q: 我的单元测试编译链接速度很慢, 而且有些条件很难测, 比如内存不足, 或者环境很难搭建, 比如需要网络或数据库, 怎么解决?

A: 这是集成测试, 不是单元测试. 你一定把系统所有的组件都编译链接起来了. 那么如果你的测试失败了, 是哪一部分的问题呢?

通常单元测试需要满足一个条件: 不依赖任何其它单元, 即隔离性. 实现手段就是在测试环境中能够轻易的假冒依赖, 并设定依赖按照我们的意愿进行工作. 一个例子就是你的代码依赖 malloc 获取内存, 而你想测试内存不足的情况. 那么我们应在能够/需要在单元测试中使用使用一个假冒的 malloc 来代替真正的 malloc, 并且我们能控制假冒的 malloc 返回 NULL 以模拟内存不足的情况. 关于如何做到这一点, 可参考一些成熟的"假冒"框架, 如 mockcpp 等.

Q: 我原来的测试都是用真实的代码来跑, 一个测试能覆盖多个单元. 你现在都把依赖替换掉了, 那被替换掉的模块有问题怎么办? 怎么保证集成真实的代码后还能正确工作?

A: 其它单元有其它单元自己的单元测试, 各自关注自己. 集成测试像以前一样, 该怎么测还怎么测, 并不是有了单元测试就不要其它测试了.

Q: 单元测试就是设计? 单元测试怎么能反映/代替设计 ?

A: 单元测试反映的是局部的设计, 局限于本单元以及与之交互的其它单元. 前面说的单元测试能够反映系统的其它部分对当前单元的需求, 所谓设计就是单元之间的职责划分, 交互和依赖关系

当你试图测试一个单元时, 却发现需要创建大量的其它对象, 而且按照你脑海中的实现, 有些对象是在单元内部创建的, 根本无法在测试环境中假冒它们. 这时候, 你即使只是为了减少测试的难度, 也会逼迫自己思考:

  1. 这个单元是否做了太多的事, 承担了额外的职责, 违反了单一职责原则?

  2. 是否应该把依赖让外界设置进来, 而不是自己在内部创建, 这样测试时就能把依赖设置为假冒的实现?

是的, 单元测试警示你思考一下自己的设计

Q: 单元测试是设计, 还有人说源代码是设计, 到底是测试是设计还是源代码是设计?

A: 这实际上是另外一种角度. 源代码就是设计的论断基于两个假设

  1. 设计阶段中工程师的工作产物, 也就是他的设计, 是应该能够在实施阶段被不同的实施者严格并且几乎一模一样的实现

  2. 软件开发人员也是工程师, 即软件工程师

如果我们认同这两个假设, 那么软件工程师的什么产物能够被严格并且重复实现的呢? 是你的Word形式的"设计"文档吗? 是CAD工具画出的UML图吗? 都不是, 因为它们都不精确, 有无数种实现方式, 根本谈不到严格, 不同的开发人员会有完全不同的实现. 事实上, 只有源代码,才能满足这个约束. 这样软件的设计阶段, 就是直到软件工程师完成源代码的那一刻, 而软件的实施阶段, 其实就只剩编译和部署了. 跑题了.

Q: 单元测试是需求"文档", 单元测试又是设计"文档", 它怎么能既是需求又是设计呢?

A: 名字和断言描述需求, 环境设置描述设计 ...

Q: 既然单元测试描述的是需求, 它就应该是黑盒测试了? 可单元测试不一直都被认为是白盒测试吗?

A: 黑白都是相对于你观察的层次. 相对于其它从外部观察"系统"行为, 不涉及源代码的测试来说, 单元测试深入到内部观察盒子的行为, 所以是白盒. 而具体到每个单元测试用例, 依然在尽可能的从外部观察"单元"的行为, 所以又是黑盒.

Q: 但是你们常用的 Mock 技术, 明显把单元测试推向白盒的境地.

A: 说来话长, 但可以先说结论: 基于状态的测试 over 基于交互/行为的测试, 虽然右边的也有巨大的价值, 但我们认为左边的更稳定和更富有对系统的洞察力

基于状态的测试描述的是需求, 基于交互行为的测试描述的是实现. 相对于需求来说, 实现更易发生变化, 尤其在另外一种实践"重构"的冲击下, 描述实现的测试将被修改的面目全非, 带来相当的返工和维护成本

一种例外, 就是交互本身就是需求, 这时 mock 是合适的选择. 一个杜撰的例子参见<<TDD: Tricky Driven Design 3, 方法>>中最后银行API的例子

而现实生活中, 存在一些情况, 虽然使用 mock 可能带来后期的维护成本, 但它带来的好处也是不可代替的. 比如对先期整体测试代码的编码量的降低. 这在 C/C++ 项目中尤其明显:

受限于C/C++的编译模型, 使用常用的预处理期接入点和编译链接接入点技术来接入 stub 实现时, 要小心维护头文件的防卫宏, 头文件的名称, 不同环境下构建脚本的include路径设置, 库路径设置等. 手工写stub的方式变的及其繁琐和容易出错. 这时候, 一个易用的 mock 框架如 mockcpp 等将节省大量的编码和先期维护工作

而几乎所有的mock框架, 都支持将 mock 对象退化为 stub, 如 mockcpp 中 mock 对象的 defaults() 设置, 或者 JMock 2 中的 Allowing . 事实上, 这是我推荐的 mock 使用方式: 通常情况下让它退化为简单的stub, 必要时才使用它强大的期待设置和验证能力.

通常单元测试有两个公认的约束需要满足:

  1. 隔离依赖.

重申一遍结论就是: 在满足单元测试的快和隔离依赖的前提下,

  1. 优先选择基于状态的黑盒测试(可使用手写stub或mock退化的stub)

  2. 除非交互和行为本身就是需求(可使用mock对象的全部特性)

Q: 怎么测 private 函数?

A: 把它变成 public 的.

我是认真的. 如果发现 private 函数无法简单的通过某个public函数的测试来覆盖而需要专门的测试, 意味着你的单元可能承担了太多的职责, 应该拆分到一个单独的单元中, 并开放为 public 函数.

如果使用 C++, 在测试环境中 #define private public.

如果使用 g++, 在测试环境中加入 -fno-access-control.

Q: 类似 private, 一些意图实现良好设计的语言特性, 如 static, sealed, final, 非虚函数等, 却总是给代码的易测试性带来麻烦, 该如何取舍?

A: 没什么好办法. 这些语言特性和测试的目的是相同的, 都是为提高代码质量, 减少出错的可能, 虽殊途同归, 但却互相限制, 效果也不一样.

我认为工业界是时候严肃认真的考虑测试环境了, 最好在语言中内建对测试的支持, 一些为产品环境设计的语言特性, 应该在测试环境中关闭, 而在产品环境中生效. 其实之前很多编译器都支持 Release 和 Debug 两种环境, 也是从代码质量的方面考虑的. 现在毫无疑问证实单元测试比 Debug 更有效, 是时候与时俱进增加对 Test 的支持而逐渐罢黜对 Debug 的支持.

在语言本身增加对测试的支持之前, 我们不得不想办法在测试环境中绕过语言特性的限制, 尤其对遗留系统, 代码已经存在的情况. 比如对于 C++ 中的 static 函数, 可以将整个被测单元 #include, 或者 #define static 为空. 宏代表了一层间接, 在测试环境中, 这层间接是至关重要的. 其它方法可参考 <<Working Effectively with Legacy Code>>, <<假冒的艺术>>中的介绍.

Q: 刚才提到了要支持"测试"而不是"Debug", 测试和Debug难道有什么矛盾吗?

A: 有. 如果你发现不得不 Debug, 就是测试粒度太粗, 步子迈的太大, 产品代码过长等导致的, 甚至可能你卷入了过多的单元而破坏了测试的隔离性. Debug还是代码逻辑不清, 行为难以断言的表现.  用测试帮你定位错误.

Q: 我知道为遗留系统增加新特性是要先写测试保证系统原来的行为, 可遗留代码很庞大, 我甚至都不知道系统目前的行为, 怎么办?

A: 特征测试: 保持代码行为的测试, 获取当前运行结果, 来填充测试, 以获取系统目前行为. 其实测试可以分为两类: 试图说明想要实现的目标, 或者试图保持代码中既有的行为; 在特性实现后, 前者会转化为后者. 详细信息请参见<<Working Effectively with Legacy Code>>

Q: 有成熟的关于在遗留系统上实践 TDD 或者单元测试的实践吗?

A: 还是<<Working Effectively with Legacy Code>>, 或者<<在大型遗留系统基础上运作重构项目>>

Q: 前面经常说到 C++ 或其它面向对象语言, 却没有提到 C, 那么过程式语言中如何应用 TDD ? 有什么不一样?

A: 基本一样, 并且在过程式语言中应用 TDD, 可能会导出面向对象风格的设计. 比如如果直接调用某个函数, 那么不得不通过编译时替换或链接时替换来接入假的实现. 这样其实比较麻烦, 因此可能会促使你选用函数指针 ,以便方便的在测试环境中进行替换. 随着时间的推移, 你会发现一组组概念相关的函数指针出现了, 那么把它们和它们操作的数据绑定在一起, 定义一个 struct, 就形成了一种对象风格. 当然这反而可能会令你的代码更复杂, 这需要在实践中取舍.

也有可能在过程式语言中你觉得 TDD 对设计的促进不大, 而且测试用例也比较枯燥, 就是测个分支, 返回值什么的. 是的, 逻辑就隐藏在分支和返回值中, 如果习惯了过程式思维并不打算改变, TDD 对设计的影响则更多的体现在依赖管理上, 如头文件和编译单元的职责划分. 如果把不同职责的函数混在一个编译单元里面, 则很难实施链接替换等手段, 除非你选择一个类似mockcpp 的框架, 不需要链接替换.

Q: 如果使用 TDD, 那么测试人员怎么安排? 是不是一开始就要进入项目组? 可那时还没有产品代码,测什么?

A: 是, 是一开始就要进入项目组, 可不是因为 TDD.  是, 测试人员是一开始没什么可测的, 可不代表就没活干.

TDD是一种开发方法, 是开发人员参与的活动. 其效果是以可执行的形式文档化你的需求, 迫使你分清职责隔离依赖以驱动你的设计, 编织安全网以扼杀Bug在摇篮状态防止逃逸. 可传统测试人员的活动是试图找到已经逃逸的Bug. 这两种活动都是必要的, 而且毫不冲突, 互为补充.

那么测试人员在新的特性还没开发完成之前做什么呢? 除了提前写测试用例, 无论是自动化的还是非自动化的, 而需要测试人员参加的一项重要活动, 就是参与特性验收条件的制定. 之前经常发生开发人员按照自己的理解去编码, 测试人员按照自己的理解去测试, 直到开发完成, 测试过程中才发现理解的不一致, 开始产生争执并阻塞等待业务分析人员(如果幸运的话)或者行政主管(如果开发过程混乱的话)的仲裁. 解决办法就是就在开始开发新特性前的一刹那, 由业务分析人员, 测试人员, 开发人员进行一次讨论, 就验收条件达成一致并形成记录, 然后测试人员和开发人员分头去写测试和实现.

Q: 之前会有一个阶段, 就是一组相关的特性开发完成后, 测试人员接手测试, 几轮Bug修复过去后, 产品基本稳定就可以发布了. 现在测试人员提前介入到每个迭代中, 针对单个特性进行测试, 那如何保证产品集成起来的质量?

A: 跟以前一样, 该有那么个集成测试阶段还得有那么个集成测试阶段, 取决于产品当时的质量状态. 并不是说有了迭代级别, 单个特性级别的测试就不需要发布级别的集成测试了, 两者没有任何矛盾.

Q: 那么测试人员提前进入迭代有什么好处?

A: 尽早发现问题, 降低修复错误的成本. 有几种手段, 一是前面提到与业务人员和开发者一起讨论验收条件, 这样就能防止理解偏差而导致的返工. 二是开发完成立即测试, 发现问题立即反馈, 这样开发人员对代码依然印象深刻,能快速定位和修复错误. 这样流入最后集成测试阶段的Bug就会少, 会缩短最后的集成测试时间, 保证产品更平稳的发布.

Q: 有时候后续的特性会影响前面的特性, 那么迭代过程中测试人员只测单个特性, 怎么保证以前的特性依然工作?

A: 几个手段. 测试尽量自动化, 以便能够持续集成. 再就是做好依赖管理, 每当一个新特性完成, 就应该能够发现它影响的其它特性, 看看是否应该补充一些集成测试.

Q: 有时候开发人员完成一个特性时已接近迭代结束, 测试人员没有时间进行充分测试, 怎么办?

A: 下个迭代测呗, 并且在计算开发速度时, 只应该计算本迭代通过测试人员验收的特性, 那些仅仅是开发人员完成, 没有经过测试人员充分测试的特性不计在内. 这种情况是不可避免的. 但我们能通过一些手段让测试与开发更加同步, 尽量缩短滞后性, 包括让测试人员与开发人员更紧密合作, 尽量让测试用例自动化等.

Q: 我还是觉得在开发迭代过程中, 测试人员的工作量不饱满.

A: 如果这不是您的感觉, 而是事实, 并且前面测试人员必须要做的工作也都做了, 还是不饱满, 那么恭喜你, 可以省下一些测试人员, 去做别的事了. 但不推荐的是, 不要让测试人员同时为两个团队工作. 这会大大增加沟通的成本. 你会经常发现, 当你的开发者想找测试人员协助时, 却找不到人了, 于是你的团队便被堵塞在那里. 而测试人员本身的Context切换也是痛苦的.

Q: 你们说验收测试应该由客户来编写, 可在我们这里根本不可能.

A: 验收, 当然是由客户来验收, 这在理论上是毫无疑问的, 而且肯定在各行各业发生着. 只是具体到测试用例的编写和执行, 无论是自动化的还是非自动化的, 都需要掌握一定的技术, 需要周密的思考, 需要专门的时间, 客户可能无法同时满足这几个条件, 我们要尽力争取, 争取不到, 便只好通过更充分的交流来弥补越俎代庖的失真. 这时业务分析人员和测试人员要通力合作, 完成验收测试的编写.

Q: 你们说你们之前的项目产品代码和测试代码的比例大约 1:3, 这不是平白增加了 3 倍的工作量吗?

A: 是增加了 3 倍的代码量而不是工作量. 它节省了你几十人做几个月庞大的预先设计的工作量, 节省了你详细设计每个模块并为之编写几百页详设文档的时间, 节省了无数不眠之夜通宵Debug的时间, 它节省了集成阶段修复难以计数的Bug的工作量, 甚至它缩减了你产品代码的数量, 大量的重复代码被消除了, 大量过度设计的复杂代码被废除了, 你的代码更易理解了, 添加新特性更容易了, 发现的Bug更易定位了, 以致于大大减少了长达数年的生命周期内维护的工作量. 有点夸张了? 可这就是 TDD 和敏捷开发带给我们的好处(如果你已经实践了)和vision(如果你还在观望)

Q: 我们也做单元测试, 但是是先写产品代码后写测试的. 难道非得 TDD, 非得测试先行吗?

A: 没什么事是非做不可的. 取决于你要什么. TDD 只是以可验证的方式迫使你将质量内建在思维中, 长期的测试先行将历练你思维的质量. 而事后的单元测试只是惶恐的跟随者.

相关推荐