“叕”谈 TDD(二)--- 为何TDD

背景:

  • 重新阅读了以下 可以引发思考 的 陈年老文
    • 《TDD并不是那么美》
    • 《TEST-DRIVEN DEVELOPMENT?别逗了》
    • 《“单元测试要做多细?”》

之所以要 “叕” 谈 TDD, 除了上述背景,也是因为自己工作4年来,虽然经常听到 TDD,但着实没有“完整” 的在项目上实践过它。直到最近打算在当前的交付项目上实践,才又重新审视 这项实践,以求回答下列问题:

  • 何为 TDD?
  • 为何 TDD?
  • 如何 TDD?
  • 如何 TDD?

在逐一回答这些问题之前,先说我对 TDD 这种实践的 观点

  • TDD 是确保 Dev 在编写代码时,处于 对需求保持 “清醒(Obvious)” 状态 的方式之一,但并非 唯一 方式
  • TDD 中的测试(T)要面向业务需求,而非代码实现
  • TDD 是一种 快速, 可复用 的 反馈获取 方式,而非唯一方式
  • 如果能 不用 TDD 并做到上述 3 点,那么不 TDD 也没问题。

为何 TDD

关于这一问题,在我刚开始接触 TDD 实践理论时,一定会 ”张口就来“: 为了产品代码的质量有所保证。可是,多想一步,只要有充分且有效的自动化测试,甚至足够的手工测试,产品代码的质量就会有所保证的呀,这和 TDD 有什么关系?
那么,我们为什么要 TDD 呢?
思考再三,还是逃不脱 “为了产品代码的质量有所保证”,只不过,这是根本目的。从根本目的出发,要最终能推导出使用 TDD,就需要引入其他考虑因素:

  • 测试先行 对 测试“性价比”的影响
  • TDD对认知状态的影响

测试先行 对 测试“性价比”的影响

软件质量测试的形式、种类是多种多样的,其测试粒度也不全相同。通常,测试粒度越细,构建、运行越快,自动化程度越高,测试成本也就越低;反之,则成本越高。测试金字塔对此进行了详细的说明。在此不做展开。

测试金字塔

那么,为了能够降低测试成本,通常软件开发过程中,都会选择用更多的金字塔低层类型测试(单元测试、集成测试等)来尽量覆盖足够多的产品代码。

那么问题就会变成:在完成产品代码的开发后,将对应的测试代码都补上,不就可以达到与测试驱动开发同样的效果了吗?
其实上述问题建立在一个假设之上:先于产品代码写出的测试代码 与 后于产品代码写出的测试代码 的测试效果(价值)是相同的

在确认这个假设是否成立之前,我们可以先来进行一个尝试:

需求: 张三有个账本,他每天都会记账,记账的形式包含日期,款项,数额。收入会被记为正数,支出会被记为负数。单位均为人民币单位“元”。这天,他突然想知道上一自然周(周一到周天)的支出总数,你可以帮助他完成计算吗?

  • 代码库
  • 用下列两种方式完成尝试:
    • 先写逻辑,后补测试
    • 先写测试,再写逻辑

----------------------------------------------建议先花些时间尝试后,再向下阅读--------------------------------------


接下来,我会将自己按照上述两种方法的思考和完成历程展示在这里:

先写逻辑(源码)

  1. 读代码,很容易可以锁定新的逻辑应该在 LedgerService 中添加,这里应该需要一个名为GetLastWeekCost的,无参的,public方法,该方法返回上周开销,类型为decimal
  2. 分析需求并拆分:上一个周一到周天的支出总数
    1. 实现找到不早于上一个周一日期的Transaction的逻辑
    2. 实现找到不晚于上一个周天日期的Transaction的逻辑
    3. 找出其中的支出项(Amount < 0
    4. 将上述逻辑组合,作为过滤条件,找到符合时间范围的transactions,对其Amount求和。
  3. 写逻辑代码,按照上述拆分后的需求逻辑实现代码,如下图:
    先写逻辑
  1. 补充测试代码,由于已经完成了逻辑代码,此时应该已经清楚需要考虑的边界条件,故在构建测试数据时,会将边界外,和边界上的情况都考虑在内,那么测试就会是这样:
    后补测试

至此,看上去一切顺利。顺带问一句,你也是这样做的吗?

先写测试(源码)

  1. 读代码,很容易可以锁定新的逻辑应该在 LedgerService 中添加,这里应该需要一个名为CalculateLastWeekCost的,无参的,public方法,该方法返回上周开销,类型为decimal
  2. 分析需求并拆分:上一个周一到周天的支出总数
    1. 实现找到不早于上一个周一日期的Transaction的逻辑
    2. 实现找到不晚于上一个周天日期的Transaction的逻辑
    3. 找出其中的支出项(Amount < 0
    4. 将上述逻辑组合,作为过滤条件,找到符合时间范围的transactions,对其Amount求和。

目前为止,我的思路都和先写逻辑是一样的。接下来,我们要先写测试了,那么我们要测试什么呢?
按照步骤 2中拆分的需求,我们应该分别测试:

  • 找到不早于上一个周一日期的Transaction的逻辑
  • 找到不晚于上一个周天日期的Transaction的逻辑
  • 找到其中支出项Transaction的逻辑
  • 上述条件进行组合后,完成求和的逻辑

这样一来,步骤1中设想的方法CalculateLastWeekCost将会是上述逻辑组合后的结果,而要测试上述的几步,需要有另外的public方法才能将其暴露给测试完成检测,即在实现步骤1中的方法前,应该有另外一个GetLastWeekTransactions的方法,用于校验Transaction的获取逻辑无误。

  1. 将预设的代码结构初步呈现出来,如下:
    初步构想

ps:

  • 当在同一个类中,一个public 方法依赖另一个public方法时,类的 单一职责原则 就被破坏了,这里可以记下一个 Tech Debt,以便后续 重构
  • 理论上,CalculateLastWeekCost 方法中,在这一阶段(还没写测试),不应有任何实现,但由于此时,分析的很充分,并且该逻辑相对简单,故提前写上了
  1. 按照拆分的需求,开始构建测试,并由测试驱动实现逻辑
    4.1. 测试 找到不早于上一个周一日期的Transaction 的逻辑, 如下:
    测试先行

    为了让测试通过,需要补上相应的逻辑实现,如下:
    实现对应的逻辑

    4.2. 测试 找到不晚于上一个周天日期的Transaction的逻辑, 如下:
    测试先行

    要通过测试,就需要对应的逻辑实现,如下:
    实现对应的逻辑

    4.3. 测试 找到其中支出项Transaction的逻辑, 无需新加测试用例,可以在原有的用例上,对方法名和期望进行修改,如下:
    修改后的用例1

    对用例2 也需要完成相似的修改,之后,补充对应的实现逻辑,如下:
    补充实现

    4.4. 测试 上一周的支出总和,也就是CalculateLastWeekCost方法,如下:
    相应的测试
  2. 考虑到之前我们有发现 Tech Debt,故接下来,需要进行重构。但本文暂时对于重构不做讨论。故可以认为 “先写测试” 但这里就结束了。
    那么,先写测试,你也是这样做的吗?

小结:

至此,可以看出之前提到的假设

先于产品代码写出的测试代码 与 后于产品代码写出的测试代码 的测试效果(价值)是相同的

多半是很难成立的。

  • 先写逻辑:
    • 流程相对较短,代码量相对少,实现起来也相对快
    • 后补的测试代码的粒度相对较粗
    • 一些 Tech Debt 可能被会隐藏起来
  • 先写测试:
    • 流程相对较长,代码量相对多,实现起来也相对慢
    • 先写测试,测试的粒度与需求的拆分粒度相关
    • 测试与需求的映射关系相对明显
    • 由于测试本身的限制(只能测试public 方法),会由此出发开发者设计出更易于测试的方法(接口),从而将某些隐藏的Tech Debt 暴露出来

那么再回到 测试“性价比” 这个点,在软件开发领域,成本可以用“人/天”来衡量,也就是工作量。从上面的小结来看,先写测试与先写逻辑,是可能在工作量上产生差别的,也就是成本会不同。但同时,不同成本,带来的回报也是不同的。
如果一个项目,更看重的是代码的整洁度,也有时间打磨代码质量,那么先写测试的“性价比”是更高的;否则,先写测试可能无法带来其他的附加价值以抵消它同时带来的成本增加。

TDD对认知状态的影响

Cynefin 认知模型 通常被用来描述人类对于某项有序事物的认知程度。软件开发过程中遇到的大多问题都是有序的,或者有规律可循的,那么对于这些具体问题(需求)的认知阶段也就应该符合认知模型所描述的变化过程:

引自:http://www.basicsm.com/content/cynefin-and-standardcase.html

理想情况下,当开发人员对问题的解决方案处于“清楚,明确”的认知状态下时,编写的产品代码所包含的Bug应当是最少,甚至没有的。
如何将开发人员的状态向这种理想状态调整,最简单直接的办法就是"多做几遍"

  • 第一遍,尽量探索发现问题
  • 第二遍,尝试解决所有问题
  • 第...遍,解决所有问题,达成目标

但是,显然软件开发、交付的过程中,不可能总将同一功能做多次,也上线多次。那么如何让开发者向“清楚、明确”的状态调整呢?
传统的“瀑布”工作流程给出了一种解决方式:详尽的需求分析文档,以及详细的设计文档。(本文不做展开)
另外与之对应的“敏捷”工作流程也给出了一种解决方式:测试驱动开发(TDD)。

那么 TDD 是如何将开发者的认知向“清楚、明确”的状态调整的呢?主要是靠这两种 TDD 中隐含的实践:

  • Tasking(任务拆分)
  • Fast Feedback (快速反馈)

在尝试解决上一小节中的问题时,我们都先完成了需求的拆分。当开发者将一个内容含量较多且复杂的问题,拆解成一个个的小问题时,Ta 的认知就会在这个过程中发生变化。回想上一小节中的需求:

计算张三上一自然周(周一到周日)的支出总和

为了便于测试,我们将它拆分成了:

  1. 实现找到不早于上一个周一日期的Transaction的逻辑
  2. 实现找到不晚于上一个周天日期的Transaction的逻辑
  3. 找出其中的支出项(Amount < 0
  4. 将上述逻辑组合,对最终过滤出的 Transactions求和

这其实就是简化版的Tasking。

而在后续的实现过程中,通过运行并观察测试结果(红或绿),就可以更快速的获取当前的产品代码是否满足需求的校验结果,从而基于结果进行代码(认知)的校准。这也就是 Fast Feedback。

你可以尝试回忆和思考,每次当你写完一个测试用例,再去写对应的实现时,对于将要编写的产品代码的认知程度是怎样的呢?

总结

为何 TDD ?
TDD 可以通过做到下列几点:

  • 测试与需求的映射关系相对明显,测试可以作为需求文档便于后人维护、学习。
  • 由于测试本身的限制(只能测试public 方法),会由此出发开发者设计出更易于测试的方法(接口),从而将某些隐藏的Tech Debt 暴露出来。
  • TDD 会间接强制开发人员进行 Tasking,拆分复杂问题为多个相对简单的问题,让开发者的认知状态发生变化。
  • TDD 提供了快速获取反馈的途径,从而让开发者能高效的进行认知的校准。

从而使产品代码的质量有所保证。

下一篇《“叕”谈 TDD(三)--- 如何TDD》将详细说明 Tasking 的方式,以及如何进行TDD。

你可能感兴趣的:(“叕”谈 TDD(二)--- 为何TDD)