TDD项目实践(一)

TDD项目实践(一)

学习背景

学习原因:自己在开发过程中,一般都是先写总体架构模块,再写模块细节,最后写测试。但发现这样的开发方式有个问题,那就是最后自己在写测试时,总有一种惰性思维,觉得自己流程已经写好且能跑了,应该没有什么错了。写的测试也会根据自己所谓的“正确”思路进行展开,造成一些错误没有注意到。因此学习了TDD(Test-Driven Development,TDD),虽然这是一门很老的开发设计方式,但也被称之为最具效能的一种开发方式。

使用资料:徐昊 · TDD项目实战70讲_重构_测试驱动开发_TDD_单元测试_徐昊_徐八叉_伦敦学派_经典学派_Kent Beck_Clean Code_重构到模式-极客时间
总的来说,这个课程偏实战,目前只学了第一个项目,还在学第二个项目。老师真的对代码那种精益求精的态度,还有“出神入化”ide a适用技巧,跟着做一下还是能学到不少的。但有时老师的思维有点太快了,还是有点跟不上。

个人相关repo:
GitHub - xjlgod/geektime-tdd-learn: 徐昊TDD项目实战 代码实现

TDD流程

验证测试与定位测试,贯穿了整个软件构造的过程。测试构成了整个开发流程的骨架,功能开发可以看作填充在测试与测试之间的血肉。这就是测试驱动开发的核心逻辑:以测试作为切入点,可以提纲挈领地帮助我们把握整个研发的过程。
image.png
TDD的完整流程可以分为以下几步:

  • 理解需求,把需求分为一系列的功能点。
  • 根据功能点进行进一步上下文的划分,称之为功能上下文。
  • 实现每个功能上下午包含的任务项,首先编写测试,并实现为了通过测试的代码,不断重复此过程,重构代码。
    • 红:编写一个失败的小测试,甚至可以是无法编译的测试;
    • 绿:让这个测试快速通过,甚至不惜犯下任何罪恶;
    • 重构:消除上一步中产生的所有重复(坏味道)。

在课程中,老师提出了一套任务分解法,将任务列表作为 TDD 的核心要素。

  • 大致构思软件被使用的方式,把握对外接口的方向;
  • 大致构思功能的实现方式,划分所需的组件(Component)以及组件间的关系(所谓的架构)。当然,如果没思路,也可以不划分;
  • 根据需求的功能描述拆分功能点,功能点要考虑正确路径(Happy Path)和边界条件(Sad Path);
  • 依照组件以及组件间的关系,将功能拆分到对应组件;
  • 针对拆分的结果编写测试,进入红 / 绿 / 重构循环。那么 TDD 的整体工作流程如下图所示:

image.png

TDD中的测试方式

状态验证是指在与待测系统交互后,通过比对测试上下文与待测系统的状态变化,判断待测系统是否满足需求的验证方式。状态验证是一种黑盒验证,它将测试上下文与待测系统当作一个整体。当待测系统不存在内部状态,而通过作用于依赖组件(Depended On Component)达成功能的时候,我们会从依赖组件中获取状态,以验证待测系统。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {

@Test
public void testAdd() {
Calculator calculator = new Calculator();
calculator.add(5);
assertEquals(5, calculator.getResult());
}

@Test
public void testSubtract() {
Calculator calculator = new Calculator();
calculator.subtract(3);
assertEquals(-3, calculator.getResult());
}
}

行为验证是指通过待测系统与依赖组件(Depended On Component)的交互,来判断待测系统是否满足需求的验证方式。行为验证背后的逻辑是,状态的改变是由交互引起的。如果所有的交互都正确,那么就可以推断最终的状态也不会错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;

public class CalculatorTest {

@Test
public void testAddBehavior() {
Calculator calculator = mock(Calculator.class);
calculator.add(5);
verify(calculator).add(5);
}

@Test
public void testSubtractBehavior() {
Calculator calculator = mock(Calculator.class);
calculator.subtract(3);
verify(calculator).subtract(3);
}
}

在 TDD 社区中,行为验证主要是为了降低测试成本。 状态验证是将测试上下文与待测系统当作一个整体的黑盒验证,而行为验证就是将它们看作分离组件的白盒验证。它的逻辑是通过测试功能是如何实现的,来推断结果是否正确。换句话说,行为验证本身并不能验证功能是否正确,而只能验证功能是否按照某种方式实现。如果按照某种方式实现,那么就可以推测出功能是正确的。这与 TDD 的核心逻辑就冲突了。在 TDD 的红 / 绿 / 重构中,重构要求在功能不变的前提下,改变实现方式。 而对于行为验证而言,实现方式改变就是功能改变 。因而重构就无法进行!需要重写!也就是说,行为验证会阻碍 TDD 的进行,应该使用状态验证。
值得一提的是,TDD 中的测试不是行业中所谓的“单元测试”,而是 指能提供快速反馈的低成本的研发测试,也是针对不同粒度单元的功能测试 。我们要从发现问题和定位问题的角度出发,去理解和思考每一个测试的功效。

TDD的极限

让 TDD 丧失驱动力最简单的办法,就是指明某个单元内的实现细节。比如,使用冒泡法对数组进行排序。因为从功能角度来说,冒泡法还是快排序,是没有差别的:

1
2
3
4
5
6
7
8
9
@Test
public void should_sort_by_bubble_sort() {
assertArrayEquals(new int[] {1, 2, 3}, bubbleSort(3, 1, 2));
}

@Test
public void should_sort_by_bubble_sort() {
assertArrayEquals(new int[] {1, 2, 3}, quickSort(3, 1, 2));
}

如果我们需要在测试中体现不同排序算法的差异,以驱动不同的实现,那么就需要改用行为验证。测试驱动开发的主要关注点在于功能在单元(模块)间的分配,而对于模块内怎么实现,需要你有自己的想法。

TDD中的重构

TDD 是一种架构技术,它能通过测试与重构,驱动单元的划分以及功能的归属,因而是一种更为落地的架构软件的方式。
从功能测试出发,逐步完成软件开发,通过红 / 绿 / 重构循环中的重构,而不是预先设计(Upfront Design),完成功能的前提下慢慢演进出新的架构,因而也称演进式设计(Evlutionary Design)。通过重构到模式演进式地获得架构,是一种实效主义编码架构风格(Pragmatic Coding Architect)。

TDD的不同驱动方式

如果架构愿景不清晰, 那么“最晚尽责时刻”让我们不必花费时间进行空对空的讨论,可以尽早开始实现功能,再通过重构从可工作的软件(Working Software)中提取架构。 这种方式也被称作 TDD 的经典学派(Classic School)或芝加哥学派(Chicago School)。
伦敦学派的做法是这样的:

  • 按照功能需求与架构愿景划分对象的角色和职责;
  • 根据角色与职责,明确对象之间的交互;
  • 按照调用栈(Call Stack)的顺序,自外向内依次实现不同的对象;
  • 在实现的过程中,依照交互关系,使用测试替身替换所有与被实现对象直接关联的对象;
  • 直到所有对象全部都实现完成。

经典学派的做法是这样的:

  • 经典学派强调功能优先,设计 / 架构后置,通过重构进行演进式设计。
  • 而“伦敦学派”并不排斥预先存在的设计,更强调如何通过测试替身,将注意力集中到功能上下文中的某个对象上。然后在测试的驱动下,按部就班地完成功能开发。

如果一开始就有了很好的架构规划,可以采用伦敦学派的驱动方式。

TDD的工程优势

使用 TDD 开发软件对人的要求,与其他所有软件工程方法对人的要求是一样的:理解需求,明白架构。但是 TDD 提供了这样几点在工程管理上的优势。

  • 第一,理解需求等于可以针对功能点写出测试。换句话说,写不出测试就是不理解需求。不理解需求就不要开发。在不理解需求的前提下开发功能点,只能带来负的进度。从工程管理角度上看,“判断一个人是否理解了需求”的成本极高。
  • 第二,不写测试,除了不会写测试之外,就是没理解需求。没理解需求就去写测试,那就是瞎干,瞎干不如不干。如果整个团队都写不出测试,那么说明这个需求无法通过可管控的工程化方式交付。
  • 第三,所有软件从业人士都认为架构是重要的,但却很少有人理解架构究竟是如何发挥作用的。架构并不是停留在纸面上的框图,而是约定了构成软件系统的组件,以及组件之间的交互方式。也就是说,架构是组件职责划分的依据以及组件的交互模式。 TDD 则可以通过功能上下文以及任务项拆分的情况,判断成员是否认同并理解了架构。 如果团队已经形成了架构共识,那么对于相同的功能点,团队中所有成员拆分出的功能上下文应该也相同。因而,从工程管理的角度,TDD 降低了“判断团队是否对架构达成了共识”的成本:看功能上下文拆分就行。
  • 第四,架构愿景很难在一开始就想得尽善尽美,随着需求发展,总会出现以当前架构愿景不容易实现的需求。如果硬拗进当前架构,就会出现不当的职责划分和别扭的组件交互,这只会加速架构的腐化。而“发现当前架构愿景不容易实现的需求”成本极高。 TDD 则可以通过功能上下文以及任务项拆分的情况,判断架构是否能够实现当前需求。 如果无法拆分出合理的功能上下文和任务项,那么这个需求,就是当前架构愿景不易实现的需求。因而,从工程管理的角度,TDD 降低了“发现当前架构愿景不容易实现的需求”的成本:看功能上下文拆分就行。

TDD项目实践(一)
http://xjl.info/2023/08/13/tdd/tdd-one/
作者
XJl
发布于
2023年8月13日
许可协议