CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ENGINEERING / P-134 · 软件工程方法论

软件工程 014:开发实践:测试驱动开发(TDD)

什么是测试驱动开发(TDD)

测试驱动开发(Test Driven Development,简称 TDD)是敏捷开发中的一项核心实践和技术,也是一种设计方法论。TDD 的原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。

TDD 的基本思路就是通过测试来推动整个开发的进行,但测试驱动开发并不只是单纯的测试工作,而是把需求分析、设计、质量控制量化的过程。TDD 首先考虑使用需求(对象、功能、过程、接口等),主要是编写测试用例框架对功能的过程和接口进行设计,而测试框架可以持续进行验证。

如何进行 TDD

在做 TDD 时,一个周期(红-绿-重构)如下:

flowchart LR
    A[写一个测试<br/>红:测试失败] --> B[让测试通过<br/>绿:编写最少的产品代码]
    B --> C[优化设计<br/>重构:在绿灯保护下改进结构]
    C -->|重复这个周期| A

然后重复这个周期。

什么是 ATDD?和 TDD 是什么关系

通常我们指的 TDD 是狭义的测试驱动开发,它是基于单元测试驱动开发,即 UTDD(Unit Test Driven Development,单元测试驱动开发)。由于单元测试的编写是由开发者来进行的,所以 UTDD 侧重从开发者测试的角度驱动开发。

由于软件开发生命周期中角色并不仅仅是开发测试者,还需要考虑业务用户(在敏捷中角色是 Product Owner),所以我们将测试驱动放宽到 PO 的角色(满足 PO 可接受的测试驱动开发),这便是 ATDD(Acceptance Test Driven Development,验收测试驱动开发)。所以 ATDD 侧重从业务角度进行测试驱动开发。

本质上 ATDD 还是基于 TDD 的基础上进行的,并且可以表述为如下的 4D 模型:

  • Discuss:各个角色一起参与,保障对需求用例(User Story)有一致的理解,避免开发完之后不满足客户需求的情况
  • Distill:需求的提取和拆分,也就是将需求分解成开发人员和测试人员都能理解、且认可的最小单元。为了需求的理解一致,引入了一套满足 Gherkin 语法的需求描述语言(Gherkin 是一种语法,使用 Given、When、Then 等关键字来描述一个 User Story)。通过这种满足 Gherkin 语法的 GWT 语句需求描述语言,可以让需求理解上更加一致
  • Develop:通过单元测试驱动开发,即上面周期(红-绿-重构)的循环。开发根据 GWT 需求描述语言进行开发设计,测试根据 GWT 需求描述语言来写验收测试用例
  • Demo:验证阶段,团队成员向产品负责人和客户演示完成的功能,验证的时候直接执行 GWT 需求,从而验证需求是否被实现

TDD 实践案例

通过一个实践案例向你展示如何进行单元测试驱动开发:以写一个计算器应用为例,看如何用 TDD 思路去实现。准备开发环境,这里使用 IDEA 开发。

第一个需求:计算两个 int 类型的加法功能

  1. 写第一个测试用例(此时 Calculator 类是没有的):
import org.junit.Assert;
import org.junit.Test;

public class CalculatorTest {

    @Test
    public void testAdd() {
        Calculator calculator = new Calculator();
        int result = calculator.add(1, 2);
        Assert.assertEquals(3, result);
    }
}
  1. 通过 IDEA 工具自动创建 Calculator 类,并自动创建 add 方法:
public class Calculator {

    public int add(int x, int y) {
        return 0; // 自动生成的方法,此时还未实现
    }
}
  1. 完善该方法并重新生成代码:
public class Calculator {

    public int add(int x, int y) {
        return x + y;
    }
}
  1. 进行单元测试,测试通过。

第二个需求:如果两个 int 类型相加越界,则抛出异常而不是错误值

  1. 添加测试用例,包含对抛出异常的检查:
@Test(expected = ArithmeticException.class)
public void testAddOverflow() {
    Calculator calculator = new Calculator();
    calculator.add(Integer.MAX_VALUE, 1);
}
  1. 跑单元测试,出现错误;此时重构原有 add 方法,让越界时抛出异常:
public int add(int x, int y) {
    long result = (long) x + y;
    if (result > Integer.MAX_VALUE || result < Integer.MIN_VALUE) {
        throw new ArithmeticException("int overflow");
    }
    return (int) result;
}
  1. 重新跑单元测试,通过。

进一步理解

我们通过几个问题进一步理解 TDD。

TDD 核心思想是什么

从开发的角度谈谈 TDD 的核心思想,可以总结为如下几个要点:

  • 测试先行:更加关注测试的重要性,所以在开发功能代码之前,先编写单元测试用例代码
  • 迭代开发:敏捷的本质其实就是迭代,极限编程(XP)实践中的重要一环。迭代是期望逐步交付,减少一次性交付带来的弊端
  • 持续重构:每一次迭代,通常涉及到冗余,有冗余就需要重构,重构是满足当前的代码符合代码结构的设计,以避免后续迭代(一次性重构)对当前代码结构的冲击

为什么很多团队无法执行 TDD

软件开发领域常讲”没有银弹”,同样 TDD 也不是银弹。很多开发团队无法执行 TDD,可以从场景和团队两方面总结。

有些场景并不适合 TDD 方式

总结而言,不符合迭代开发的或者无法进行对应的快速测试的场景不适合 TDD 方式开发。

  1. 无法迭代开发的:适合迭代的都是具体的需求实现,对于整体系统的设计是不适合迭代的,也不适合用测试驱动开发。因为后续的重构会对之前的代码逻辑有冲击,迭代将带来的持续冲击式重构,这种场景下的测试驱动开发对开发者而言是一种灾难。比如复杂算法的实现,举个例子:你现在要开发一个傅里叶变换的模块,输入一些数据,得到傅里叶变换的结果。你花了一些时间看懂了傅里叶变换的公式,对其实现也有把握,但是在写断言的时候就发现结果的验证太困难了,你可能需要借助 matlab 或者 python 来预先得到结果,然后复制结果到断言中。数据驱动的算法也无法用 TDD 来开发,例如机器学习、深度学习,因为其结果与数据相关,当数据发生变化时,算法行为也会发生变化,这就没法写测试用例了
  2. 无法快速测试的:测试驱动开发的另外一个必要条件是每次的迭代是可以被快速验证的,如果无法快速测试,那么就不适合
  3. 存在交互边界的:比如硬件的开发,因为存在物理的边界

TDD 对团队本身有一定的要求

  1. 团队对质量管理的要求和认同:测试驱动开发只是一种敏捷开发方式,它是将测试提升到与开发同等重要的位置,以保证在敏捷的迭代开发中保持软件的质量。有些团队没有完善的软件开发质量体系和资源,没有将质量规范融入到整个开发流程中(也没有专人或团队来约束规范),甚至团队生存都存在问题,追求短平快更不会关注质量(想起一句经典台词:我都混到吃泡面了,我还在乎健康?)。在这些情况下团队对质量管理缺乏认同,对开发测试的工作就变成了负担
  2. 团队对需求和任务拆分的能力:测试驱动开发有一个很重要的前提条件是有相对可迭代开发的拆分需求,所以团队对需求的理解以及任务的拆分能力极为重要。拆分任务时需要对任务的粒度和可持续性有较高要求;频繁的需求变更和拆分不到位,将让测试驱动开发陷入疲于奔命
  3. 团队对重构的理解和能力:重构对于测试驱动开发是极为重要的,正如上面的例子看到的,在后续的用例需求开发时需要对前面的开发代码进行必要的重构,以满足当前的代码是相对最佳实践的。此时你可能会发现,增量写的用例需要对前面写的代码大幅度的重构,而这些代码可能不是你写的,所以在团队没有对重构有统一的理解或者缺乏对重构的把控时,测试驱动开发可能会成为一种负担
  4. 团队对协作的理解和能力:一个团队中是有很多角色的(产品负责人、PM、架构、开发、测试等),测试驱动开发不仅仅是开发者要做的事情,而需要其它角色的协作。举个简单的例子:如果在必要时候后续开发需要对之前代码进行重构,但是产品负责人或者 PM 无法认同,认为只是一个平行功能的开发,没有给到开发者足够的重构时间,这时便会错失好的重构时间点,对后续整体质量将埋下一个坑,而且这种技术债务有一天是要还的

TDD 是否已死

在行业里最有名的是 Kent Beck、Martin Fowler、David 关于 Is TDD Dead? 的辩论。

添加这节内容是期望读者明白:

  1. 无论采用何种方式开发,都是需要平衡项目、资源、团队等多种因素的
  2. TDD 过不过时都是主观的,但是它提供了一种敏捷开发的思路;作为开发而言,不断思考、不断实现自我的迭代永远不会过时

AMDD:通过敏捷模型拓展 TDD

Agile Model Driven Development(AMDD)即敏捷模型驱动开发。TDD 非常擅长详细的规范和验证,但是它不适合很大的问题,如总体设计、系统的使用或 UI。AMDD 解决了 TDD 没有解决的敏捷扩展问题,因此 AMDD 用于更大的问题。如下内容主要翻译自 Scaling TDD via Agile Model Driven Development (AMDD)。

在 AMDD 中,每个开发活动如下:

  • 迭代 0:设想(Envisioning):设想是预测/想象测试的 TDD 过程之一,将在项目的第一周进行。设想的主要目标是确定系统范围和系统架构,高级需求和架构建模是为成功的设想而进行的。这是一个过程,没有对软件/系统进行详细说明,而是探索软件/系统的需求,确定了项目的总体战略。它有两个主要的部分:
    • 最初需求的设想(Initial requirements envisioning):确定系统的高级需求和范围可能需要几天时间,主要重点是探索使用模型、初始域模型和用户界面模型(UI)
    • 最初架构的设想(Initial Architectural envisioning):识别系统的体系结构也需要几天时间,它允许为项目设置技术方向,主要重点是探索技术图、用户界面(UI)流、领域模型和变更案例
  • 迭代建模(Iteration modeling):在这里,团队必须计划每个迭代将要完成的工作。敏捷过程用于每次迭代,即在每次迭代期间,将优先添加新的工作项:首先,将考虑更优先的工作;添加的工作项可以随时重新排序或从项堆栈中删除。团队讨论如何实现每个需求,建模用于此目的,建模分析和设计是针对将要为该迭代实现的每个需求进行的
  • 模型风暴(Model storming):这也称为即时建模。在这里,建模课程涉及一个由 2/3 名成员组成的团队,他们在纸上或白板上讨论问题:一名团队成员将要求另一名成员与他们一起建模,本次建模课程大约需要 5 到 10 分钟,团队成员聚集在一起共享白板/纸张。他们探索问题,直到找到问题的主要原因。如果一个团队成员确定了他/她想要解决的问题,那么他/她将迅速得到其他团队成员的帮助,其他小组成员随后探讨这个问题,然后每个人都像以前一样继续。它也被称为独立建模或客户 QA 会议
  • 测试驱动开发(TDD):它促进了对应用程序代码和详细规范的确认性测试。验收测试(详细需求)和开发人员测试(单元测试)都是 TDD 的输入。TDD 使代码更简单和清晰,它允许开发人员维护较少的文档
  • 回顾(Reviews):这是可选的,它包括代码检查和模型审查,可以为每个迭代或整个项目完成,是为项目提供反馈的好选择

TDD 研究文献推荐

有关 TDD 的有效性和成本已经有很多研究了,下表是个总结:

作者/年份重要发现
Siniaalto, 2017TDD 不总是能提高开发效率,特别是在对 TDD 不熟悉的情况下。但是 TDD 所生产的代码具有更高的测试覆盖率
Neil, 2017github 上使用 TDD 开发的 Java 项目非常少,将 TDD 开发的项目与其他项目进行对比,并没有发现 TDD 开发的项目有明显的优势
Wilson, 2016TDD 能够生产更好质量的代码,但是开发效率不如 TLD(开发完再测试)
Davide, 2016TDD 声称的好处可能不是由于其独特的测试先行产生的,但类似于 TDD 所鼓励的细粒度稳步的增量式开发,可以改善开发质量
Nagappan, 2008TDD 消除缺陷是 40% ~ 90%,其成本在开发初期多出 15% ~ 35%
Sanchez, J.C, 2007TDD 产生的缺陷密度低于行业标准。TDD 或许能减少代码复杂度随着软件年限而增长的程度
Bhat, 2006TDD 的使用可以让代码质量提升,初始成本增加至少 15%
Siniaalto, 2006有些情况 TDD 会大幅度提升效率,大概在 2/13 的情况下会降低生产效率(但会提升代码质量)
George, 2003TDD 开发的代码质量更好(可以多通过 18% 或者更多的功能测试),且多用 16% 的开发时间。事后测试的代码测试不够充分

参考文章

系列导航

← 设计模式 014:享元(Flyweight) 目录 JHipster 开发 14:从登录页读懂前端安全流 →
← 返回文章列表