浅谈现代软件工程TDD最佳实践
# 写在文章开头
这是笔者早起接触到的一种饱受争议的开发模式,它的核心理念是强调:
先编写测试,然后借住这些测试驱动软件开发过程
这种模式很好辅助开发人员理解软件的功能和验收标准,从而尽最大可能编写符合要求的功能代码,但这种编写测试先行的开发模式也带来很多的争议,很少有人能够验证按照传统TDD的开发模式进行功能开发,所以笔者也想借住这篇文章谈谈自己对于TDD的理解并给出一种更符合现代软件工程开发节奏基于TDD理念的开发实践,希望对你有所帮助。
你好,我是 SharkChili ,禅与计算机程序设计艺术布道者,希望我的理念对您有所启发。
📝 我的公众号:写代码的SharkChili
在这里,我会分享技术干货、编程思考与开源项目实践。
🚀 我的开源项目:mini-redis
一个用于教学理解的 Redis 精简实现,欢迎 Star & Contribute:
https://github.com/shark-ctrl/mini-redis (opens new window)
👥 欢迎加入读者群
关注公众号,回复 【加群】 即可获取联系方式,期待与你交流技术、共同成长!
# 关于TDD的一些先行理论
# 什么是TDD
正如上文所说的,TDD(test driven development)强调先有测试,在通过测试驱动开发人员完成软件开发,即思想上让研发人员具备以终为始的一种软件设计思维,确保在研发开始的时候尽最大化努力按照可量化的标准正确的完成功能落地。所以这也是为什么笔者上文中的测试没有强调单元测试的原因,按照TDD的理念,这里的测试不一定是指研发的人员的单元测试,它大体分为:
- 研发人员在功能开发过程中基于自动化测试框架编写的单元测试
- 基于业务要求得出的功能测试
- 按照产品特定标准的软件验收测试
# TDD的种类
按照权威资料的说法TDD大体分为如下两种:
ATDD(acceptance test driven):验收测试驱动开发UTDD(unit test driven development):单元测试驱动开发
我们先来说说ATDD,它更着重于面向业务方,强调构建业务正确的软件系统这种方式是在需求评审阶段,由业务分析师或者测试人员提出的验收标准的测试用例,开发人员基于这些验收测试标准完成功能落地,是一种着重于强调业务正确性的TDD开发模式,ATDD模式有着如下优势:
- 将业务价值和测试融合,更强调软件业务方需求价值,有明确的理性验收指标
- 以验收目标驱动,将测试标准实例化,开发人员可以明确理解需求,不再是以模糊抽象的描述概念进行落地,减少返工的概率
- 可以作为活文档在需求文档中留痕,作为开发人员辅助资料
然后就是UTDD也就是我们开发向的单元测试驱动开发,即面向技术人员的一种测试驱动开发模式,它强调开发人员和测试人员在完成需求梳理和设计后,得出明确的单元模块验收标准,再由开发人员先行编写自动化测试单元,再基于这套技术维度测试单元完成功能落地,相比于以侧重业务向的ATDD,这种模式主要针对更细粒度的函数测试,具备自动化、可复用的技术向优势,也可以做到ATDD所具备的业务构建的准确性,总的来说,UTDD是面向技术研发人员的一种开发模式,它更多是强调:
- 面向技术功能研发维度,关注单元级别的函数,测试粒度更细致,反馈周期比
ATDD更快 - 通过自动化测试框架构建函单元测试,可灵活复用和回归
- 面向技术人员维度的测试用例,降低调试成本
与之对应的UTDD更多的意义是在于对于研发人员的开发理念:
- 它更好的让开发人员研发过程中关注到更细粒度的单元函数,从业务功能和技术单元上都能做到精准把控
- 提升研发人员对于软件的认知,对于系统功能设计的全局把控粒度,学会理性可量化的以终为始的开发模式
- 基于长期积累、刻意练习具备一套更加精准的开发套路
总的来说,两种模式相辅相成,前者面向业务着重强调业务价值和业务正确性,而后者面向技术和代码更强调系统正确性,作为研发人员我们需要从这两种模式的思想中提炼出一套通用的TDD思想的研发模式:

# TDD实施的三个阶段
按照权威资料的说法,研发人员对于TDD的态度大体分为三个阶段:
- 无意识以TDD模式进行开发
- 被动通过技术实现TDD
- 有意识主动通过TDD完成软件研发
虽然说TDD饱受争议,实际上结合对于我们可以看出大部分的研发人员都已经无意识的处于TDD的开发的第一阶段,我们需要经历:
- 需求评审
- 业务软件架构设计业务功能拆解
- 落地编码设计
- 代码实现
对于优秀软件研发人员而言,在进行编码设计时函数设计时,或多或少会在思维上考虑函数的出入参和非法参数,这时就会在思维上给出测试用例和验收标准,这也就是笔者所谓的无意识TDD,例如:我们在开发一个银行转账功能,我们编写转账函数时,就会得出如下的思考过程:
- 输入一个负数抛出异常,事务回滚
- 输入一个大于账户总金额,抛出异常,事务回滚
- 总金额1000,转账500,剩余500
本质上研发人员在无意识TDD阶段都会在脑海中给出一定的测试逻辑,只不过此阶段研发人员因为技术或者理念等诸多原因,没有将单元测试具象化:

随之而来的就是TDD的第二个阶段,即团队意识到TDD的正向意义和价值之后,就会被动让研发人员使用TDD,即要求研发人员会强制将TDD第一阶段所思维层面的测试用例具象化,避免模糊的概念和无据可依以降低后期维护的成本,或许是因为技术理解不到位或认知不足等诸多原因造成如下问题:
- 测试用例缺乏合理性,无法正确反馈需求的正确
- 自动化测试框架和测试代码不熟悉导致软件开发周期延长
此阶段研发人员内心对此还是有所抵触,这个阶段还是没有真正做到TDD,所以笔者认为要想真正的做到TDD,需要团队提升研发人员自动化测试的技术储备同时在思维上提升这方面的认知。
第三个阶段则是研发人员思维上突破对于TDD模式的抵触,研发人员通过长时间的刻意练习,通过大量编码和实践在思考中逐渐理念TDD的核心理念,已经熟练使用的自动化测试框架,并能够结合需求的梳理和拆解编写出正确的测试单元,此刻研发人员已经真正意义上的理解TDD,让TDD成为自己功能开发的得力助手而不是迎合团队要求去应付所谓的绿灯测试,即真正理念TDD核心理念:
将测试左移,将测试用例分析、设计、实现提前到编码之前阶段,正确辅助理解业务需求以及充分考虑功能正确性
# 详解我对TDD的理解与实践
# 浅谈我理解的TDD
按照业界的最常用TDD三部曲,无论是UTDD还是ATDD,都可以按照如下三个步骤实施:
- 写一个以目标导向的单元测试,此时单元测试代码肯定室不通过的(红灯)
- 写一个实现代码,刚刚好使其通过,但不一定可以通过业务的验收标准(绿灯)
- 重构,按照实际验收标准落地完整代码,使其通过本次测试(绿灯)
- 按需继续重构优化代码结构等,依然可以保持绿灯
从笔者的使用经验来看,传统TDD实践存在如下缺陷:
- 步骤1即测试先行时各种文件不存在的编译报错,在如今的IDE编辑模式下,没有任何实质的意义,甚至影响研发人员的工作体感
- TDD整个开发流程将一个完整的功能开发步骤拆解为无数个子单元测试,再基于这些测试落地编码使其绿灯通过,因为编码时没有对整体流程有个连贯性的开发,这种将连贯的功能流程拆解为无数个独立单元进行独立编码,严重的打断了研发人员对于连续的流程编码工作心流
- 必须写一个刚刚好通过的代码,然后在通过重构的方式让函数正确的逻辑落地,过于繁琐
所以笔者对于TDD的实践模式更着重于保留TDD测试驱动的理念,即保留其以测试和验收标准辅助研发人员理解需求和验收标准,在设计阶段即可明确清晰需求的落地规范,再将先行的测试设计编码落地,对应步骤为:
- 在设计阶段明确好功能设计和落地思路后,一并完成测试单元的设计,通过设计阶段的单元测试前置,做到TDD所谓的辅助研发人员自行理解测试和验收标准
- 按照设计连贯的开发功能落地
- 基于设计阶段提出的测试单元,将测试落地,进行功能验收
- 执行单元测试,若发现红灯,修复直到绿灯,如此往复,由此完成一个完整的建模开发
例如后文中要完成的一个购物车功能设计,笔者会在设计阶段给出加购和删除商品的功能设计落地思路,与此同时也会一并给出最终验收的测试设计,由此来辅助自己理解需求边界,梳理一个清晰、完整、可闭环的业务逻辑功能开发:

# 案例场景说明
结合上述的说明和笔者的理念,笔者打算以一个购物车功能的例子介绍一下笔者从传统TDD理念中抽检的最佳实践。我们先来介绍一下这个需求,这个购物车支持:
- 添加不同商品,并维护其数量、单价、总价
- 允许移除指定数量的商品
- 支持获取总售价
如下图,笔者添加了1个键盘300元、一个鼠标100元、还有南孚电池两个共5元,对应购物车中显示,总价为405元:

按照需求的说明,当我们再添加一个鼠标,对应鼠标的数量就会变为2,鼠标总价为200,购物车总计金额为505,与之对应移除商品也是同理,笔者这里就不多做说明了。
# 功能设计与测试用例梳理
我们本次要开发的功能是一购物车模块的维护,整体来说需要声明一个商品类维护购物车中的商品id、名称、数量、单价、总价。然后通过购物车来维护这些商品的添加和删除,同时保证每个品类总价以及购物车总计价的正确性,按照这个思路笔者的落地步骤大体为:
- 声明一个
item类包含上述所有属性,初始化时构造方法会根据单价数量维护总计价 - 创建一个购物车实体,内部创建一个
list列表维护用户的购物清单 - 添加商品时,需要判断当前商品是否存在于购物车,如果有没有则初始化添加,如果有则基于
item更新数量并完成购物车总计价更新 - 删除商品时,需判断商品是否存在,且移除数量必须小于或者等于购物车内部单品数量,同样保证价格等维护
- 删除商品需要保证商品为0时,从购物车移除
与之对应有了完整的功能设计与思考之后,我们就需要给出对应驱动测试用例,实际上笔者的上述思路在这一步编写测试驱动时也有做调整,这也是笔者利用到TDD的地方,即通过测试设计驱动自己梳理需求和验收标准,通过以终为始的方式检查自己上述功能设计是否存在不足,同时保证功能每个模块后续进行重构或者迭代都有回归的依据:
- 创建id为1的键盘,单价300,
item内部维护价格为300,添加后购物车总金额为300 - 创建id为2的鼠标,item内部维护价格为100,单价为100,添加后鼠标总金额为400
- 创建id为3的电池单价为2.5,数量为2,初始化后总计价为5,添加购物车后总金额为405
- 删除一个电池,鼠标item数量变为1,总计价为2.5,购物车总价为402.5,且购物车中存在键盘这一选项
- 删除键盘,购物车总计价为102.5,最后键盘这一选项不存在购物车中
# 功能落地与调测
通过上述这种设计结合TDD理念的设计稿,笔者很快的完成了功能的落地,我们先来看看商品的实体声明代码,可以看到笔者大体做了如下几件事:
- 声明商品必要的属性
- 通过构造方法完成的基于单价和数量完成总计价的初始化
- 为确保购物车能够准确判断当前商品是否存在于购物车,重写
hashCode和equals方法以唯一键id作为条件:
@Data
public class ShopItem {
//商品id
private int id;
//商品名称
private String name;
//商品价格
private BigDecimal price;
//商品数量
private int count;
//商品总价
private BigDecimal total;
//基于传入的单价和数量初始化商品
public ShopItem(int id, String name, BigDecimal price, int count) {
this.id = id;
this.name = name;
this.price = price;
this.count = count;
this.total = price.multiply(new BigDecimal(count));
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
ShopItem shopItem = (ShopItem) o;
return id == shopItem.id;
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
因为有了上述的设计思路和测试用例,笔者在落地购物车的实现时也是非常的清晰明了:
- 添加商品时,先判断
item是否存在,若不存在则添加,反之更新数量和总计价 - 删除商品判断商品是否存在,若不存在则返回,反之完成必要扣减,然后判断数量是否为0,若为0则将商品移除
- 遍历购物车完成总价计算
- 基于商品id定位对应商品数量
public class ShoppingCart {
private final List<ShopItem> itemList = new ArrayList<>();
public void addItem(ShopItem item) {
//若不存在则添加
if (!itemList.contains(item)) {
itemList.add(item);
return;
}
//若存在则数量和总价格相加
int index = itemList.indexOf(item);
ShopItem cartItem = itemList.get(index);
cartItem.setCount(cartItem.getCount() + item.getCount());
cartItem.setTotal(cartItem.getTotal().add(item.getTotal()));
}
public void removeItem(int id, int count) {
//通过id查看商品是否存在
ShopItem item = itemList.stream()
.filter(shopItem -> shopItem.getId() == id)
.findFirst()
.orElse(null);
if (item == null) {
return;
}
//减去数量和价格
item.setCount(item.getCount() - count);
item.setTotal(item.getTotal().subtract(item.getPrice().multiply(new BigDecimal(count))));
//若数量为0则从列表中删除
if (item.getCount() == 0) {
itemList.remove(item);
}
}
public BigDecimal getTotal() {
//获取所有商品总价
return itemList.stream()
.map(ShopItem::getTotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
//获取指定商品数量
public int getItemSize(int id) {
return itemList.stream()
.filter(shopItem -> shopItem.getId() == id)
.findFirst()
.get()
.getCount();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
对应的比这也给出设计阶段的单元测试明确落地后的代码,最终运行结果也都是绿灯:
class ShoppingCartTest {
private ShoppingCart shoppingCart;
@BeforeEach
void setUp() {
shoppingCart = new ShoppingCart();
}
//创建id为1的键盘,单价300,item内部维护价格为300,添加后购物车总金额为300
@Test
void testAddItem() {
ShopItem keyboard = new ShopItem(1, "键盘", new BigDecimal(300), 1);
shoppingCart.addItem(keyboard);
assertEquals(new BigDecimal(300), shoppingCart.getTotal());
assertEquals(1, shoppingCart.getItemSize(1));
}
//创建id为2的鼠标,item内部维护价格为100,单价为100,添加后鼠标总金额为400
@Test
void testAddItem2() {
ShopItem keyboard = new ShopItem(1, "键盘", new BigDecimal(300), 1);
shoppingCart.addItem(keyboard);
ShopItem mouse = new ShopItem(2, "鼠标", new BigDecimal(100), 1);
shoppingCart.addItem(mouse);
assertEquals(new BigDecimal(400), shoppingCart.getTotal());
assertEquals(1, shoppingCart.getItemSize(2));
}
//创建id为3的电池单价为2.5,数量为2,初始化后总计价为5,添加购物车后总金额为405
@Test
void testAddItem3() {
ShopItem keyboard = new ShopItem(1, "键盘", new BigDecimal(300), 1);
shoppingCart.addItem(keyboard);
ShopItem mouse = new ShopItem(2, "鼠标", new BigDecimal(100), 1);
shoppingCart.addItem(mouse);
ShopItem battery = new ShopItem(3, "电池", new BigDecimal(2.5), 2);
shoppingCart.addItem(battery);
assertTrue(BigDecimal.valueOf(5).compareTo(battery.getTotal()) == 0);
assertTrue(BigDecimal.valueOf(405).compareTo(shoppingCart.getTotal()) == 0);
}
//删除一个电池,鼠标item数量变为1,总计价为2.5,购物车总价为402.5,且购物车中存在键盘这一选项
@Test
void testRemoveItem() {
ShopItem keyboard = new ShopItem(1, "键盘", new BigDecimal(300), 1);
ShopItem mouse = new ShopItem(2, "鼠标", new BigDecimal(100), 1);
ShopItem battery = new ShopItem(3, "电池", new BigDecimal(2.5), 2);
shoppingCart.addItem(keyboard);
shoppingCart.addItem(mouse);
shoppingCart.addItem(battery);
shoppingCart.removeItem(3, 1);
assertEquals(1, shoppingCart.getItemSize(2));
assertTrue(BigDecimal.valueOf(2.5).compareTo(battery.getTotal()) == 0);
assertTrue(BigDecimal.valueOf(402.5).compareTo(shoppingCart.getTotal()) == 0);
}
//删除键盘,购物车总计价为102.5,最后键盘这一选项不存在购物车中
@Test
void testRemoveItem2() {
ShopItem keyboard = new ShopItem(1, "键盘", new BigDecimal(300), 1);
ShopItem mouse = new ShopItem(2, "鼠标", new BigDecimal(100), 1);
ShopItem battery = new ShopItem(3, "电池", new BigDecimal(2.5), 2);
shoppingCart.addItem(keyboard);
shoppingCart.addItem(mouse);
shoppingCart.addItem(battery);
shoppingCart.removeItem(1, 1);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# 系统优化和回归
目前的购物车逻辑,无论是商品添加还是删除都需要遍历整个购物车链表,时间复杂度为O(n),所以笔者考虑基于当前架构将其内部数据结构进行优化,改为HashMap。
因为有了上述的单元测试,所以笔者本次迭代开发的成本就相对小一些,对应重构后的代码如下,可以看到笔者直接将底层数据结构替换为id为key,商品为value的map集合,然后根据编译报错信息不断调整各个逻辑方法:
private Map<Integer, ShopItem> itemMap = new HashMap<>();
public void addItem(ShopItem item) {
//若不存在则添加
if (!itemMap.containsKey(item.getId())) {
itemMap.put(item.getId(), item);
return;
}
//若存在则数量和总价格相加
ShopItem cartItem = itemMap.get(item.getId());
cartItem.setCount(cartItem.getCount() + item.getCount());
cartItem.setTotal(cartItem.getTotal().add(item.getTotal()));
}
public void removeItem(int id, int count) {
//通过id查看商品是否存在
ShopItem item = itemMap.get(id);
if (item == null) {
return;
}
//减去数量和价格
item.setCount(item.getCount() - count);
item.setTotal(item.getTotal().subtract(item.getPrice().multiply(new BigDecimal(count))));
//若数量为0则从列表中删除
if (item.getCount() == 0) {
itemMap.remove(item.getId());
}
}
public BigDecimal getTotal() {
//获取所有商品总价
return itemMap.values().stream()
.map(ShopItem::getTotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
//获取指定商品数量
public int getItemSize(int id) {
return itemMap.values().stream()
.filter(shopItem -> shopItem.getId() == id)
.findFirst()
.get()
.getCount();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
完成后基于我们之前驱动开发的测试单元顺利完成逻辑回归,自此我们很顺利的完成购物车性能优化和回归:

# 小结
实施TDD最关键的就是学会理解业务需求和技术需求进行分析,只有明确理解业务需求才能完成准备完成任务拆解并制定正确验收标准,以笔者的购物车案例为例,可以看到在需求设计阶段,笔者就明确的梳理出添加、删除商品的正确落地步骤,同时在设计阶段给出上述行为的验收标准,进一步辅助自己矫正设计阶段可能没有考虑到的问题。
对于研发人员而言,成功实施TDD的核心是学会自动化测试和代码重构,只有正确学会自动化测试技术才能保证自己能够快速准确的完成功能验收和降低后续迭代的回归成本,同时结合自动化测试的单元,通过不断的重构保证内奸代码质量,避免代码的腐化易于维护。
本文详细介绍传统TDD开发模式的发展,同时介绍了传统TDD模式的种类的弊端,并给出笔者基于传统TDD开发模式优化后的汲取TDD核心思想的一种以测试设计驱动开发模式,通过这种模式笔者非常顺利的完成一个购物车模块的完整落地和性能优化,希望这种模式对你有所启发。
你好,我是 SharkChili ,禅与计算机程序设计艺术布道者,希望我的理念对您有所启发。
📝 我的公众号:写代码的SharkChili
在这里,我会分享技术干货、编程思考与开源项目实践。
🚀 我的开源项目:mini-redis
一个用于教学理解的 Redis 精简实现,欢迎 Star & Contribute:
https://github.com/shark-ctrl/mini-redis (opens new window)
👥 欢迎加入读者群
关注公众号,回复 【加群】 即可获取联系方式,期待与你交流技术、共同成长!
# 参考
《全程软件自动化测试 开源实战》
TDD(测试驱动开发)是否已死?:https://www.zhihu.com/question/37623307/answer/3073117112?share_code=1pBcnRtJrx4cA&utm_psn=1990558954527416491 (opens new window)
- 02
- Spring AI Alibaba深度实战:一文掌握智能体开发全流程03-04
- 03
- 告别AI无效对话:资深工程师的提示词设计最佳实践02-07