持续交付技术3:TDD已死?开发者测试永生

文摘   2024-07-21 21:58   中国香港  

在目前比较流行的敏捷开发模式(如极限编程、Scrum方法等)中,推崇“测试驱动开发(TDD)”——测试在先、编码在后的开发实践。TDD有别于以往的“先编码、后测试”的开发过程,而是在编程之前,先写测试脚本或设计测试用例。TDD在敏捷开发模式中被称之为“测试优先的编程(test-first programming)”,其理念主要是确保两件事:确保所有的需求都能被照顾到;在代码不断增加和重构的过程中,可以检查所有的功能是否正确。

但后来很长一段时间里,都没再听过 TDD 的消息。有人说,TDD 已经死了, 我想不管 TDD 有没有死,TDD 都不是银弹,TDD 是一种思想,这里的 T 可以是任何种类的测试,测试永生。

一、TDD概念


1、TDD测试驱动开发
敏捷开发中的一项核心实践和技术、是一种方法论。思路是通过测试来推进整个开发的进行。表现为测试代码优先于业务代码。

TDD有别于以往的“先编码、后测试”的开发过程,而是在编程之前,先写测试脚本或设计测试用例。TDD在敏捷开发模式中被称之为“测试优先的编程(test-first programming)”,

TDD具体实施过程,可以看作两个层次

UTDD:在代码层次,在编码之前写测试脚本,可以称为单元测试驱动开发(Unit Test Driven Development,UTDD)

ATDD:在业务层次,在需求分析时就确定需求(如用户故事)的验收标准,即验收测试驱动开发(Acceptance Test DrivenDevelopment,ATDD)


2、TDD已死?

最近几年“TDD已死”的声音不断出现,特别是David Heinemeier Hansson那篇文章——《TDD is dead. Long live testing. (DHH)》引发了大量的讨论。

当前国内很多软件开发人员对于TDD的理解比较模糊,大部分人也没有明确和有意识的去实施TDD,应此很多人都有着不同的理解。

其中最经典的理解就是基于代码的某个单元,使用Mock等技术编写单元测试,然后用这个单元测试来驱动开发,抑或是帮助在重构、修改以后进行回归测试。而现在大部分反对TDD的声音就是基于这个理解,比如:

  • 工期紧,时间短,写TDD太浪费时间;

  • 业务需求变化太快,修改功能都来不及,根本没有时间来写TDD;

  • 写TDD对开发人员的素质要求非常高,普通的开发人员不会写;

  • TDD 推行的最大问题在于大多数程序员还不会「写测试用例」和「重构」;

  • 由于大量使用Mock和Stub技术,导致UT没有办法测试集成后的功能,对于测试业务价值作用不大

总结一下,技术人员拒绝TDD的主要原因在于难度大、工作量大、Mock的大量使用导致很难测试业务价值等。

这些理解主要是建立在片面的理解和实践之上,而在我的认知中,TDD的核心是:先写测试,并使用它帮助开发人员来驱动软件开发。

首先是先写测试,这里的测试并不只是单元测试,也不是说一定要使用mock和stub来做测试。这里的测试就是指软件测试本身,可以是基于代码单元的单元测试,可以是基于业务需求的功能测试,也可以是基于特定验收条件的验收测试。

其次是帮助开发人员,主要是帮助开发人员理解软件的功能需求和验收条件,帮助其思考和设计代码,从而达到驱动开发的目的,所以TDD是包含两部分:ATDD与UTDD。现在很多人所谓的TDD就是UTDD,但是它却只是真正意义上TDD的一部分而已。

3、TDD、ATDD、BDD的区别

在当今的软件开发实践中,确保代码质量与满足用户需求的策略正变得愈加关键。测试驱动开发(TDD)、行为驱动开发(BDD)和验收测试驱动开发(ATDD)是三种流行的开发方法论,它们通过不同的方式来指导开发过程并提高软件的可靠性与效率。了解它们各自的特点、优势以及它们之间的差异,对于选择适合特定项目和团队的工作方法至关重要。本文将探索这些方法论的核心概念,并分析它们在现代软件开发中的应用及其带来的影响。

TDD(测试驱动开发)

测试驱动开发是一种软件开发方法,它遵循一个简短的开发循环:首先编写一个尚未实现的功能的测试案例(失败的测试),然后编写足够的代码以通过测试,最后对代码进行重构以满足设计标准。这个过程被称为红-绿-重构循环。TDD鼓励开发者在编码前先思考,并且通过重构确保代码的可维护性和可扩展性。它主要关注于开发者的视角,即如何以单元测试为导向来构建软件的每一部分。

BDD(行为驱动开发)

行为驱动开发建立在TDD的基础之上,但它更关注软件的行为而不仅仅是软件的功能。BDD使用自然语言(通常是英语)来描述应用程序的预期行为,这些描述可以直接转换为测试代码。BDD促进了开发人员、测试人员和非技术利益相关者(如产品所有者)之间的沟通与协作,因为它使用的是通俗易懂的语言来定义软件的行为。在BDD中,测试用例通常基于用户故事,并围绕系统应如何响应外部输入来编写。

ATDD(验收测试驱动开发)

验收测试驱动开发同样以测试为先导,但它的侧重点在于定义软件的验收标准,并且确保软件能够满足这些标准。这些验收标准通常由业务人员提出,确保软件最终交付的是业务所期望的功能。在ATDD中,测试是在软件开发之前预先定义的,并且是基于用户的需求。开发团队、测试团队和业务代表一同合作,确保开发的软件能够通过预定义的验收测试。ATDD有助于保证最终产品的质量,并且有助于确保开发工作紧密地与业务目标保持一致。

尽管TDD、BDD和ATDD都以测试为核心,它们在关注点和实践方式上各有侧重。TDD侧重于技术面和单元测试,BDD将关注点放在行为和特性上,而ATDD则聚焦于业务需求和产品验收。选择合适的方法应根据项目特性、团队组成以及业务目标来决定

TDD、BDD和ATDD三种方法论在目标、过程和参与者方面有以下主要区别:

首先看一张图,一图胜千言:        

形象类比:

我们可以把 TDD、UTDD、ATDD、BDD几者的关系,看做三世同堂。

  • TDD(测试驱动开发) 看做“爷爷” ,是一切思想的起源(软件质量内建)。

  • UTDD(单元测试驱动开发) 看作“母亲” ,负责家里的各种大小事务——“女主内”(在代码层面,确保软件的实现逻辑代码是整洁可用的)。

  • ATDD(验收测试驱动开发) 看作“父亲”,在外打拼负责在外挣钱养家——“男主外”(在业务层面,确保开发出来的软件是符合业务需求、业务预期的)。

  • BDD(行为驱动开发) 看作“孩子”,是新一代青年,继承了父亲ATDD的衣钵,并做到了 ”青出于蓝,胜于蓝。“(验收标准实例化)。

对比特性

TDD

BDD

ATDD

定义

TDD是一项开发技术,关注点在功能的实现

BDD是一项开发技术,关注点在系统的行为

ATDD是一项类似BDD的技术,关注点更多是围绕需求

参与者

开发者

开发者、用户、QAs

开发者、用户、QAs

主要关注点

单元测试

理解需求

编写验收测试用例


4、TDD 和单元测试是什么关系?

最后,我们来谈谈 TDD 和单元测试的关系。很多人把 TDD 等同于一种在撰写代码前先撰写单元测试的行为,通过上面的分析,现在你应该会觉得这种认识是不妥当的。TDD 是一种思想,这里的 T 可以是任何种类的测试。至于是什么种类,就像上文分析的那样,取决于你在哪个层次考虑问题。下面是应用 TDD 思想在不同层级可以使用的测试方法:

二、UTDD实施


UTDD实施步骤

测试驱动开发(TDD)是一种软件开发方法,要求开发者在编写代码之前先编写测试用例,然后编写代码来满足测试用例,最后运行测试用例来验证代码是否正确。测试驱动开发的基本流程如下:

第一步、编写测试用例

在编写代码之前,先根据需求编写测试用例,测试用例应该覆盖所有可能的情况,以确保代码的正确性。

这一步又称之为 “红灯”,因为没有实现功能,此时测试用例执行会失败,在 IDE 里面执行时会报错,报错为红色。

第二步、运行测试用例

由于没有编写任何代码来满足这些测试用例,因此这些测试用例将会全部运行失败。

第三步、编写代码

编写代码以满足测试用例,在这个过程中,我们需要编写足够的代码使所有的测试用例通过。

这一步又称之为 “绿灯”,在 IDE 里面执行成功时是绿色的,非常形象。

第四步、运行测试用例

编写代码完成之后,运行测试用例,确保全部用例都通过。如果有任何一个测试用例失败,就需要回到第三步,修改代码,直至所有的用例都通过。

第五步、重构代码

在确保测试用例全部通过之后,可以对代码进行重构,例如将重复的代码抽取成函数或类,消除冗余代码等。

重构的目的是提高代码的可读性、可维护性和可扩展性。重构不改变代码的功能,只是对代码进行优化,因此重构之后的代码必须依旧能通过测试用例。

第六步、运行测试用例

重构之后的代码,也必须保证通过全部的测试用例,否则需要修改至用例通过。


UTDD实施工具

        “工欲善其事,必先利其器。” 在实践UTDD之前,我们需要找到能让UTDD落地、更好落地的工具。Java开发,推荐 UTDD工具集 = JUnit + AssertJ +Mockito

  • JUnit:一个Java语言的单元测试框架。用它来执行单元测试用例。

  • AssertJ:一个Java语言的流式断言器。用它来进行单元测试结果的断言。虽然Junit框架也自带断言方法,但奈何可选方法较少,且不如AssertJ断言方法使用起来 方便、优雅、语义化。

  • Mockito:一个Java语言的Mocking框架。用它来构造测试替身。因为在我们编写单元测试用例时,会使用到一些当前尚未实现 或 难以构造的对象,这时我们就可以通过Mockito 构造一个虚拟的对象,帮助我们完成单元测试工作。

三、TDD 案例实战


本案例我们将实现一个奇怪的计算器,通过这个案例完整实践 TDD 的几个步骤。

奇怪的计算器的需求如下:

输入:输入一个int类型的参数
处理逻辑:
(1)入参大于0,计算其减1的值并返回;
(2)入参等于0,直接返回0;
(3)入参小于0,计算其加1的值并返回

接下来采用 TDD 进行开发。

  • 第一步、红灯

编写测试用例,实现上文的需求,注意有三个边界条件,要覆盖完整。

public class StrangeCalculatorTest {

private StrangeCalculator strangeCalculator;


@BeforeEach
public void setup() {
strangeCalculator = new StrangeCalculator();
}

@Test
@DisplayName("入参大于0,将其减1并返回")
public void givenGreaterThan0() {
//大于0的入参
int input = 1;
int expected = 0;
//实际计算
int result = strangeCalculator.calculate(input);
//断言确认是否减1
Assertions.assertEquals(expected, result);
}

@Test
@DisplayName("入参小于0,将其加1并返回")
public void givenLessThan0() {
//小于0的入参
int input = -1;
int expected = 0;
//实际计算
int result = strangeCalculator.calculate(input);
//断言确认是否减1
Assertions.assertEquals(expected, result);
}

@Test
@DisplayName("入参等于0,直接返回")
public void givenEquals0() {
//等于0的入参
int input = 0;
int expected = 0;

//实际计算
int result = strangeCalculator.calculate(input);
//断言确认是否等于0
Assertions.assertEquals(expected, result);
}
}

此时 StrangeCalculator 类和 calculate 方法还没有创建,会 IDE 报红色提醒是正常的。

创建 StrangeCalculator 类和 calculate 方法,注意此时未实现业务逻辑,应当使测试用例不能通过,在此抛出一个 UnsupportedOperationException 异常。

public class StrangeCalculator {

public int calculate(int input) {
//此时未实现业务逻辑,因此抛一个不支持操作的异常,以便使测试用例不通过
throw new UnsupportedOperationException();
}
}

运行所有的单元测试,此时报告测试不通过:

    第二步、绿灯

首先实现 givenGreaterThan0 这个测试用例对应的逻辑:

public class StrangeCalculator {
public int calculate(int input) {
//大于0的逻辑
if (input > 0) {
return input - 1;
}
//未实现的边界依旧抛出UnsupportedOperationException异常
throw new UnsupportedOperationException();
}
}

注意,我们目前只实现了 input>0 的边界条件,其他的条件我们应该继续抛出异常,以便使其不通过。

运行单元测试,此时有 3 个测试用例,其中只有两个出错了。

继续实现 givenLessThan0 用例对应的逻辑:

public class StrangeCalculator {
public int calculate(int input) {

if (input > 0) {
//大于0的逻辑
return input - 1;
} else if (input < 0) {
//小于0的逻辑
return input + 1;
}
//未实现的边界依旧抛出UnsupportedOperationException异常
throw new UnsupportedOperationException();
}
}

运行单元测试,此时有 3 个测试用例,其中有 1 个出错:

继续实现 givenEquals0 用例对应的逻辑:

public class StrangeCalculator {
public int calculate(int input) {
//大于0的逻辑
if (input > 0) {
return input - 1;
} else if (input < 0) {
return input + 1;
} else {
return 0;
}
}
}

运行单元测试:此时 3 个测试用例都通过了:

此时,打开 Jacoco 的测试覆盖率报告(tdd-example 的 pom.xml 文件中将报告生成的位置配置为 target/jacoco-report),打开 index.html

可以看到,calculate 所有的边界条件都覆盖到了。

  • 第三步、重构

本案例 calculate 中只有简单的计算,在实际开发中,我们进行重构时,可以将具体的业务操作抽取为 private 方法,例如:

public class StrangeCalculator {

public int calculate(int input) {
//大于0的逻辑
if (input > 0) {
return doGivenGreaterThan0(input);
} else if (input < 0) {
return doGivenLessThan0(input);
} else {
return doGivenEquals0(input);
}
}

private int doGivenEquals0(int input) {
return 0;
}

private int doGivenLessThan0(int input) {
return input + 1;
}

private int doGivenGreaterThan0(int input) {
return input - 1;
}
}

再次执行单元测试,测试通过。

查看 Jacoco 覆盖率的报告,可以看到每个边界条件都被覆盖到。


研发效能方法论
分享内容的四大方向:研发效能和软件工程方法论,软件工程技术,平台工程设计,通用五力