在目前比较流行的敏捷开发模式(如极限编程、Scrum方法等)中,推崇“测试驱动开发(TDD)”——测试在先、编码在后的开发实践。TDD有别于以往的“先编码、后测试”的开发过程,而是在编程之前,先写测试脚本或设计测试用例。TDD在敏捷开发模式中被称之为“测试优先的编程(test-first programming)”,其理念主要是确保两件事:确保所有的需求都能被照顾到;在代码不断增加和重构的过程中,可以检查所有的功能是否正确。
但后来很长一段时间里,都没再听过 TDD 的消息。有人说,TDD 已经死了, 我想不管 TDD 有没有死,TDD 都不是银弹,TDD 是一种思想,这里的 T 可以是任何种类的测试,测试永生。
一、TDD概念
1、TDD测试驱动开发
是敏捷开发中的一项核心实践和技术、是一种方法论。思路是通过测试来推进整个开发的进行。表现为测试代码优先于业务代码。
TDD有别于以往的“先编码、后测试”的开发过程,而是在编程之前,先写测试脚本或设计测试用例。TDD在敏捷开发模式中被称之为“测试优先的编程(test-first programming)”,
TDD具体实施过程,可以看作两个层次
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(行为驱动开发) 看作“孩子”,是新一代青年,继承了父亲ATDD的衣钵,并做到了 ”青出于蓝,胜于蓝。“(验收标准实例化)。
对比特性 | TDD | BDD | ATDD |
---|---|---|---|
定义 | TDD是一项开发技术,关注点在功能的实现 | BDD是一项开发技术,关注点在系统的行为 | ATDD是一项类似BDD的技术,关注点更多是围绕需求 |
参与者 | 开发者 | 开发者、用户、QAs | 开发者、用户、QAs |
主要关注点 | 单元测试 | 理解需求 | 编写验收测试用例 |
最后,我们来谈谈 TDD 和单元测试的关系。很多人把 TDD 等同于一种在撰写代码前先撰写单元测试的行为,通过上面的分析,现在你应该会觉得这种认识是不妥当的。TDD 是一种思想,这里的 T 可以是任何种类的测试。至于是什么种类,就像上文分析的那样,取决于你在哪个层次考虑问题。下面是应用 TDD 思想在不同层级可以使用的测试方法:
二、UTDD实施
UTDD实施步骤
第一步、编写测试用例
在编写代码之前,先根据需求编写测试用例,测试用例应该覆盖所有可能的情况,以确保代码的正确性。
这一步又称之为 “红灯”,因为没有实现功能,此时测试用例执行会失败,在 IDE 里面执行时会报错,报错为红色。
第二步、运行测试用例
由于没有编写任何代码来满足这些测试用例,因此这些测试用例将会全部运行失败。
第三步、编写代码
编写代码以满足测试用例,在这个过程中,我们需要编写足够的代码使所有的测试用例通过。
这一步又称之为 “绿灯”,在 IDE 里面执行成功时是绿色的,非常形象。
第四步、运行测试用例
编写代码完成之后,运行测试用例,确保全部用例都通过。如果有任何一个测试用例失败,就需要回到第三步,修改代码,直至所有的用例都通过。
第五步、重构代码
在确保测试用例全部通过之后,可以对代码进行重构,例如将重复的代码抽取成函数或类,消除冗余代码等。
重构的目的是提高代码的可读性、可维护性和可扩展性。重构不改变代码的功能,只是对代码进行优化,因此重构之后的代码必须依旧能通过测试用例。
第六步、运行测试用例
重构之后的代码,也必须保证通过全部的测试用例,否则需要修改至用例通过。
UTDD实施工具
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 覆盖率的报告,可以看到每个边界条件都被覆盖到。