背景:
- 重新阅读了以下 可以引发思考 的 陈年老文
之所以要 “叕” 谈 TDD, 除了上述背景,也是因为自己工作4年来,虽然经常听到 TDD,但着实没有“完整” 的在项目上实践过它。直到最近打算在当前的交付项目上实践,才又重新审视 这项实践,以求回答下列问题:
在逐一回答这些问题之前,先说我对 TDD 这种实践的 观点:
- TDD 是确保 Dev 在编写代码时,处于 对需求保持 “清醒(Obvious)” 状态 的方式之一,但并非 唯一 方式
- TDD 中的测试(T)要面向业务需求,而非代码实现
- TDD 是一种 快速, 可复用 的 反馈获取 方式,而非唯一方式
- 如果能 不用 TDD 并做到上述 3 点,那么不 TDD 也没问题。
如何 TDD
其实 TDD 实践方式的大体轮廓在上一篇《为何TDD》中的代码示例已经提及一二,本篇不过是增添细节,将其更加完整、详尽地展示出来。
简单来说,TDD 可以有两种实践模式:
- 单人模式(Solo)
- 双人模式(Pair)
它们会给你完全不同的体验。从整体来看,我将 TDD 的实践归纳为 3个阶段:
- 准备阶段
- 实施阶段
- 收尾阶段
每个阶段都有明确的目标和需要掌握的辅助技能,下面我会基于 单人模式 进行详细介绍。双人模式如无专门说明,则与单人模式的实践相同。
准备阶段
1. 理解需求
这是一个非常容易遗漏的步骤,因为需求“理应”已经完全标明在故事卡(User Story)中了,只要看完卡片不就好了吗?
其实不然。
只是读完卡片,并不能说明需求被完全理解了。在这一步,要做到明白当前卡片的价值何在,同时还需清楚卡片上的验收条件是否足够完整验证卡片价值的达成。在此之后,如果卡片上的验收条件还不包含了可以使用的具体样例(数值),而只是抽象的公式或逻辑,那么我们需要将这些抽象的公式或逻辑具象化,形成后续写测试时可以直接使用的测试数据。
例如 有👇🏻的需求描述
作为 话费充值用户,
我想 在查询话费时,能够看到当前账户余额,
以便 我能够了解何时应当进行话费充值
可能的验收条件
验收条件1:
给定 一个非欠费用户,
当 该用户查询账户余额时,
即 给出当前实际的账户金额,账户金额应大于等于 0
----------------------------------------
验收条件2:
给定 一个欠费用户,
当 该用户查询账户余额时,
即 给出当前的欠费金额,欠费金额用负数表示,
并 提示友好信息通知用户充值
在理解需求的过程中,需要具象化上述验收条件,于是可以发现 友好信息
的内容尚不明确,于是通过进一步沟通和确认,明确了内容:欠费大于 50 元,则停机,友好信息的内容也会有所差别,即
未停机: “您的余额不足,为了避免停机造成的影响,请尽快缴存话费”
已停机: “您当前处于停机中,缴费后恢复服务”
于是,基于边界条件,可以整理出如下的具象化用例(Exapmle):
对于 验收条件1:
Example 1:
给定 一个账户余额为100的用户
当 该用户查询账户余额时,
即 给出当前余额为 100.00
Example 2:
给定 一个账户余额为0的用户
当 该用户查询账户余额时,
即 给出当前余额为 0.00
--------------------
对于 验收条件2:
Example 3:
给定 一个欠费10元的用户,
当 该用户查询账户余额时,
即 给出当前余额为 -10.00,
并 提示“您的余额不足,为了避免停机造成的影响,请尽快缴存话费”
Example 4:
给定 一个欠费50元的用户,
当 该用户查询账户余额时,
即 给出当前余额为 -50.00,
并 提示“您的余额不足,为了避免停机造成的影响,请尽快缴存话费”
Example 5:
给定 一个欠费50.01元的用户,
当 该用户查询账户余额时,
即 给出当前余额为 -50.01,
并 提示“您当前处于停机中,缴费后恢复服务”
注: Example 2 和Example 4 用于验证验收条件边界处的满足与否。
至此,才算需求被理解了。
2. 明确当前系统的测试策略
通常,每个系统都会有自己的架构,而这些架构也都会分成不同的层级,每个层级都会有相应的一些组件。那么在开始TDD 之前,我们一定要先弄清楚针对当前工作的系统架构,它的测试策略是什么?
先弄明白针对目标系统的测试策略,就可以消除TDD 过程中,对于测试粒度不清楚的问题,即我是该写单元测试呢?还是该写组件测试?又或者其他什么测试类型?
通常,测试策略很难有定式,需要“因地制宜”,结合测试目的,成本,具体问题具体分析,具体制定,这里就不多作说明了。
基于上述理念,特别强调我们要避免认为TDD 就一定要从单元测试(Unit Test)做起
.
3. 拆分任务
有了一个个具象化的用例,和明确的测试策略,“任务”的目标就很明确了:
将具现化的用例转化为符合测试策略的测试代码,并通过测试
但这只是一个非常宏观的“任务”,它并不是我们在在任务拆分时需要完成的任务。因此,在拆分任务前,我们需要思考任务的应具备的特征:
- 可达成:任务最终是一定能够完成的
- 可验收:每个任务的完成与否都有明确的衡量标准
- 可估时间:完成任务所需的时间是可以被大概估计的,可以使用TimeBox追踪
- 目标相关:完成了这些任务,那么这些任务所对应的目标就能被实现
每一个在时限内完成的任务,都是一次“正向反馈”,它会为开发者提供成就感,从而使开发者进入一种“节奏”,有时通过这种节奏,开发者可以更容易地进入“流”(Flow,一种注意力高度集中的状态)。
而每一个时限内未能完成的任务,则都是一次“负向反馈”,它为开发者提供反思的入手点,从而归纳总结出可以进一步提升的知识、技能等个人能力。
我通常在 TDD 的实践中,会将任务拆分到可以在15 - 30 分钟内完成的大小。如果利用需求理解部分的例子具象化这样的一个任务,那么在一个传统的 MVC 分层架构的后端系统中,我的任务拆分结果会是这样:
任务1: 完成 验收条件 1 中的功能,通过 Example 1 和 Example 2 的验证,并通过后端 API 返回期待的结果(20 分钟)
任务2: 完成 验收条件 2 中的功能,通过 Example 3, Example 4, Example 5 并通过后端 API 返回期待的结果(30 分钟)
至此,我们的准备阶段就结束了,可以进入接下来的实施阶段了。
注:双人模式
下,准备阶段会增加更多的讨论,这些讨论在一定程度上是有助于探索遗漏的边界条件,但同时也需要控制讨论的效率,求同存异,以防对整体工作造成影响。
实施阶段
进入实施阶段后,我们就可以带上一顶名为“实现功能”的帽子,专注业务功能实现,开始代码编写了。
在《为何TDD》中,我有贴过一些 TDD 方式产出的代码。这里,就不再贴出额外的代码了。但是,可以尝试利用基于传统的 MVC 分层架构中的核心业务层(Service),单元测试的作为该层组件的测试策略的场景,总结如下的 TDD 实践步骤以供参考:
1. 定义目标 Service 类,**简单设计**类中所需方法的签名
2. 构造目标 Service 类的测试,根据测试需要,Stub/Mock/Spy/Fake/Dummy(测试替身)当前Service 的依赖项(可以是 Repository 接口,HttpClient 接口,Config接口等)
3. 利用具象化的 Example 中的内容,目标方法的签名和已声明的测试替身,编写测试用例,并运行
4. 调整 Service 类中定义的方法逻辑,通过测试
5. 重复 3,4 步骤,直到之前所有的列出的 Example 都被“翻译”为测试代码,并被运行通过
在实施阶段的工作完成后,新添加的代码理应已经可以完全满足业务需求,并且所有的业务需求,也都已经被“翻译”为测试代码。这意味着,无论如何调整代码,只要已有的测试用例能够全部通过,当前的业务功能就不会受到任何影响。
那么,我们就可以放心的拿下“实现功能”的帽子,进入最后的收尾阶段。
注: 双人模式
下, 实施阶段需要合理分配工作,可以采用工作经验较丰富或对当前业务更熟悉的一人来“翻译”测试(编写测试用例),另一人则专注于通过测试。并在合适的时机进行角色交换,平衡两人的参与感。更多细节,可以参照《沉思录---结对编程篇》
收尾阶段
在收尾阶段中,开发者需要带上一顶名为“重构”的帽子。此时,有了充足的测试覆盖的保证,开发人员可以“为所欲为,大刀阔斧,随心所欲”的使用学到的设计模式,技巧,基于整洁代码的规范,优化代码,消除“实现功能”过程中遗留的“坏味道”,使其更易读、易维护。
总结
如何TDD?
- 理解需求,将需求通过验收条件,转化为具象化的Example
- 明确测试策略,结合测试金字塔与测试四象限,设计与测试意图、成本匹配的测试策略
- 拆分任务,基于需求和任务的特性,对业务需求目标进行拆分
- 利用具象化的Example,测试策略,以“翻译”需求为目的编写测试,并以通过测试为目的实现功能
- 通过重构,优化代码,完成收尾工作