最近几年“TDD已死”的声音不断出现,特别是David Heinemeier Hansson那篇文章——《TDD is dead. Long live testing. (DHH)》引发了大量的讨论。其中最引人注目的是Kent Beck、Martin Fowler、David三人就这个举行的系列对话(辩论)——Is TDD Dead?
前言
当前国内对TDD的理解十分模糊,大部分人也没有明确和有意识的去实施TDD,因此许多人对此都有着不同的理解。
其中最经典的理解就是基于代码的某个单元,使用Mock等技术编写单元测试,然后用这个单元测试来驱动开发,抑或是帮助在重构、修改以后进行回归测试。而现在大部分反对TDD的声音就是基于这个理解,比如:
- 工期紧,时间短,写TDD太浪费时间;
- 业务需求变化太快,修改功能都来不及,根本没有时间来写TDD;
- 写TDD对开发人员的素质要求非常高,普通的开发人员不会写;
- TDD 推行的最大问题在于大多数程序员还不会「写测试用例」和「重构」;
- 由于大量使用Mock和Stub技术,导致UT没有办法测试集成后的功能,对于测试业务价值作用不大
- …
这些理解主要是建立在片面的理解和实践之上,而在我的认知中,TDD的核心是:先写测试,并使用它帮助开发人员来驱动软件开发。
TDD是什么
Kent Beck《测试驱动开发》中的解释如下:
Kent Beck:“测试驱动开发不是一种测试技术。它是一种分析技术、设计技术,更是一种组织所有开发活动的技术”。
分析技术: 体现在对问题域的分析,当问题还没有被分解成一个个可操作的任务时,分析技术就派上用场,例如需求分析、任务拆分和任务规划等,《实例化需求》这本书可以给予一定的帮助作用。
设计技术: 测试驱动代码的设计和功能的实现,然后驱动代码的再设计和重构,在持续细微的反馈中改善代码。
组织所有开发活动的技术: TDD 很好地组织了测试、开发和重构活动,但又不仅限于此,比如实施 TDD 的前置活动包括需求分析、任务拆分和规划活动,这使得 TDD 具有非常好的扩展性。
TDD的目的
《测试驱动开发》一书中提到: “代码简洁可用这句言简意赅的话,正是 TDD 所追求的目标”。
对于如何保证“代码简洁可用”可以使用分而治之的方法,先达到“可用”目标,再追求“简洁”目标。
可用: 保证代码通过自动化测试。
代码简洁: 在不同阶段人们对简洁的理解程度也不一样,不过遵循的原则差不多,例如 OOD 的 SOLID 原则,Kent Beck 的 Simple Design 原则等。
虽然有很多因素妨碍我们得到整洁的代码,甚至可用的代码,无需征求太多意见,只需要采用 TDD 的开发方式来驱动出简洁可用的代码。
TDD的规则
在 TDD 的过程中,需要遵循两条简单的规则:
- 仅在自动测试失败时才编写新代码。
- 消除重复设计(去除不必要的依赖关系),优化设计结构(逐渐使代码一般化)。
第一条规则的言下之意是每次只编写刚刚好使测试通过的代码,并且只在测试运行失败的时候才编写新的代码,因为每次增加的代码少,即使有问题定位起来也非常快,确保我们可以遵循小步快跑的节奏;第二条规则就是让小步快跑更加踏实,在自动化测试的支撑下,通过重构环节消除代码的坏味道来避免代码日渐腐烂,为接下来编码打造一个舒适的环境。
关注点分离是这两条规则隐含的另一个非常重要的原则。其表达的含义指在编码阶段先达到代码“可用”的目标,在重构阶段再追求“简洁”目标,每次只关注一件事!!!
TDD口号
简单来说,不可运行/可运行/重构——这正是测试驱动开发的口号,也是 TDD 的核心。在这个闭环中,每一个阶段的输出都会成为下一阶段的输入。
- 不可运行——写一个功能最小完备的单元测试,并使得该单元测试编译失败。
- 可运行——快速编写刚刚好使测试通过的代码,不需要考虑太多,甚至可以使用一些不合理的方法。
- 重构——消除刚刚编码过程引入的重复设计,优化设计结构。
假设这样的开发方式是可能的,那我采用 TDD 真正的动机是什么?
采用 TDD 的动机
- 控制编程过程中的忧虑感。
有一个有趣的现象,当我感觉压力越大,自身就越不想去做足够多的测试。当知道自己做的测试不够时,就会增加自身的压力,因为我担心自己写的代码有 BUG,对自己编写的代码不够自信,这是一种心态上的变化。此时测试是开发人员的试金石,可以将对压力的恐惧变为平日的琐事,采用自动化测试,就有机会选择恐惧的程度。
- 把控编程过程中的反馈与决策之间的差距。
如果我做了一周的规划,并且量化成一个个可操作的任务写到 to-do list,然后使用测试驱动编码,把完成的任务像这样划掉,那么我的工作目标将变得非常清晰,因为我明确工期,明确待办事项,明确难点,可以在持续细微的反馈中有意识地做一些适当的调整,比如添加新的任务,删除冗余的测试;还有一点更加让人振奋,我可以知道我大概什么时候可以完工。项目经理对软件开发进度可以更精确的把握。
TDD整体流程
根据 TDD 的整体流程,此时需要想一下我要做什么,想想如何测试它,然后写一个小测试。思考所需的类、接口、输入和输出。
-
想一下我要做什么,想想如何测试它,然后写一个小测试。思考所需的类、接口、输入和输出。
-
编写足够的代码使测试失败(明确失败总比模模糊糊的感觉要好)。
-
编写刚刚好使测试通过的代码(保证之前编写的测试也需要通过)。
-
运行并观察所有测试。如果没有通过,则现在解决它,错误只会落在新加入的代码中。
-
如果有任何重复的逻辑或无法解释的代码,重构可以消除重复并提高表达能力(减少耦合,增加内聚力)。
-
再次运行测试验证重构是否引入新的错误。如果没有通过,很可能是在重构时犯了一些错误,需要立即修复并重新运行,直到所有测试通过。
-
重复上述步骤,直到找不到更多驱动编写新代码的测试。
范围
TDD是测试驱动开发(Test-Driven Development)的英文简称,是敏捷开发中的一项核心实践和技术,也是一种设计方法论。TDD的原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。TDD虽是敏捷方法的核心实践,但不只适用于XP(Extreme Programming),同样可以适用于其他开发方法和过程。
条件
- 熟悉Java基本语法。
- 理解OOP。
- 熟悉TDD基本理论知识
- 熟悉测试工具Junit和Mockito及AssertJ的使用
- 熟悉搭建自动化单元测试环境
案例
有一个现有的购物车应用程序,其中包含少量的电子商务/购物车规则。规则包括总价的计算,折扣和忠诚度积分的计算。大多数业务逻辑通过单一方法ShoppingCart.checkout实现。
应用程序代码当前具有以下规则:
- 计算客户获得的总价格和总忠诚度积分。
- 产品代码以DISCOUNT_10开头的产品将享受10%的折扣。
- 产品代码以DISCOUNT_15开头的产品将享受15%的折扣。
- 产品代码以BULK_BUY_2_GET_1开头的产品将享受买二送一的折扣
- 当产品不提供任何优惠时,可以赚取更多的积分。
- 客户每消费$ 5可赚取1积分。
- 客户在产品上每消费10美元,即可获得1点积分,折扣为10%。
- 客户在产品上每消费$ 15可获1积分,折扣为15%。
现在需要你完成一个程序来模拟这个活动,它首先将对应的产品加入到购物车中,然后生成相应的订单,订单中包含总价及获得积分。例如:
输入:
产品对应代码为:DISCOUNT_10
产品对应价格为:100
产品对应数量为:1
输出:
订单总价为:90
订单积分为:10
业务分解
在TDD之前进行需求分析可以在一开始就明确完成任务的目标是什么,以便于减少理解偏差所带来的低级错误;紧接着对每个需求进行任务分解,目的是得到一份可以被验证的任务清单。在实践TDD 的过程可能还会调整任务清单,比如添加新的任务,或者删除掉冗余的任务等等。
在对需求进行分析的过程中,我先会从购物者的角度分析整个活动设计的角色有哪些,发现有商家和消费者;然后再从职责的角度分析得出消费者的职责是购买商品,商家的职责是定义商品折扣力度、进行购物车结算处理。最终得到任务清单:
- 商家定义商品折扣力度
- 消费者选择购买
- 商家根据不同商品的不同折扣进行订单结算
- 验证订单
业务细分
因业务受到一系列折扣活动的影响,因此将业务进行细分,并寻找该业务的特殊需求。以便于对业务做一定程度的规划。细分业务清单如下:
- 定义需出售商品折扣力度(既产品代码包含对应前缀)
- 消费者进行商品加入购物车
- 商家进行订单结算
- 如果商品不参与打折,总价=商品总价* 数量,积分=总价/5
- 如果商品代码以DISCOUNT_10开头,总价=商品价格* 数量* 0.9,积分=总价/10
- 如果商品代码以DISCOUNT_20开头,总价=商品价格* 数量* 0.8,积分=总价/20
- 如果商品代码以BULK_BUY_2_GET_1开头
- 商品数量等于1:按照原价出售,积分=总价/5
- 商品数量大于2:总价=商品价格* 数量 * 2/3,积分=总价/5
- 验证订单
测试命名规范
- 测试类以 XXXTest 命名.
- 测试方法命名必须采用
shouldxxxForxxx
,例如:shouldCalculateLoyaltyPointsFor15PercentDiscount
。 - 测试方法的代码逻辑遵循 Given-When-Then 模式。
知识:Given-When-Then
在编写测试方法时,应该遵循 Given-When-Then 模式(在给定xx情况下,当做了xx操作,会得到xx反馈)这种模式可以让开发人员专注并思考以下这几件事情:
- Given:驱动我们思考这个测试是在一个怎样的上下文中,用到哪些对象,以便于思考需要创建哪些上下文和对象。
- When:驱动我们站在用户的角度去思考这个行为是什么,它有哪些输入,以便于思考方法的命名和入参。
- Then:驱动我们思考行为的反馈是什么,以便于思考方法的返回值。
思考:测试方法采用 shouldxxxForxxx 的意义是什么?
得益于 BDD 思想和工具,这种命名方法是我在 BDD 的实践过程中琢磨出来的(当然不止我在用这种命名规则),它包含但不仅限于以下优点:
- 可以在把关注点放到行为上,避免陷入实现的细节中。
- 命名接近自然语言,表达意图清晰,可读性高,受益人群广。
- 很好地控制测试的范围,大到用户行为(偏 BDD),小到逻辑分支(偏 TDD)。
到现在需求已经明确,测试命名规范已拟定,任务已敲定,可以开始 TDD 了。
案例实践
根据之前的需求分析,商家需要明确商品代码和折扣力度才能进行商品售卖,因此驱动出ShoppingCart
类和Order checkout()
方法,观察checkout方法发现还需要用到商品信息,所以还驱动出Product
类。
测试先行–编写足够的代码使测试失败(明确失败总比模模糊糊的感觉要好)。
编写测试类公共部分
Talk is cheap, show me the code
public class ShoppingCartTest {
private static final int PRICE = 100;
private static final String PRODUCT_NUM = "test_product";
private Customer customer;
@Before
public void setUp() {
customer = new Customer("test");
}
}
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
<version>4.13</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
</dependency>
</dependencies>
编写第一个细分业务测试
如果商品不参与打折,总价=商品总价* 数量,积分=总价/5
-
编写单元测试
价格
@Test public void shouldCalculatePriceWithNoDiscount(){ Map<Product, Integer> productMap = new HashMap<>(2, 1); productMap.put(new Product(PRICE, "", PRODUCT_NUM), 2); ShoppingCart cart = new ShoppingCart(customer, productMap); Order order = cart.checkout(); assertEquals(200.0, order.getTotalPrice(), 0.0); }
积分
@Test public void shouldCalculateLoyaltyPointsWithNoDiscount() { Map<Product, Integer> productMap = new HashMap<>(2, 1); productMap.put(new Product(PRICE, "", PRODUCT_NUM), 1); ShoppingCart cart = new ShoppingCart(customer, productMap); Order order = cart.checkout(); assertEquals(20, order.getLoyaltyPointsEarned()); }
-
由于代码编译不通过,需通过单元测试补上代码:
订单类
package cn.budbreak.tdd.demo.shop; import lombok.Data; /** * @author Baorui.Zhang * @date 2021/2/7 10:03 */ @Data public class Order { private final int loyaltyPointsEarned; private final Double totalPrice; public Order(Double price, Integer loyaltyPointsEarned) { this.totalPrice = price; this.loyaltyPointsEarned = loyaltyPointsEarned; } }
商品类
package cn.budbreak.tdd.demo.shop; import lombok.Data; /** * @author Baorui.Zhang * @date 2021/2/7 9:37 */ @Data public class Product { private final String productName; private final Double price; private final String productNum; public Product(double price, String productName, String productNum) { this.price = price; this.productName = productName; this.productNum = productNum; } }
消费者类
package cn.budbreak.tdd.demo.shop; import lombok.Data; /** * @author Baorui.Zhang * @date 2021/2/7 9:34 */ @Data public class Customer { private final String name; public Customer(String customerName) { this.name = customerName; } }
购物车类
package cn.budbreak.tdd.demo.shop; import lombok.Data; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; /** * @author Baorui.Zhang * @date 2021/2/7 9:39 */ @Data public class ShoppingCart { private final Customer customer; private final Map<Product, Integer> productMap; public ShoppingCart(Customer customer, Map<Product, Integer> productMap) { this.customer = customer; this.productMap = productMap; } public Order checkOut() { return new Order(0.0,0); } }
-
运行单元测试
测试未通过! -
编写刚刚好使测试通过的代码(保证之前编写的测试也需要通过)
public Order checkout() { AtomicReference<Double> price = new AtomicReference<>(0.0); AtomicInteger loyaltyPointsEarned = new AtomicInteger(0); productMap.forEach((product, value) -> { price.set(product.getPrice() * value + price.get()); loyaltyPointsEarned.updateAndGet(v -> (int) (v + (product.getPrice() * value / 5))); }); return new Order(price.get(), loyaltyPointsEarned.get()); }
-
再次运行
两个测试方法刚好通过
编写第二个细分业务测试
如果商品代码以DISCOUNT_10开头,总价=商品价格* 数量* 0.9,积分=总价/10
-
编写单元测试
积分
@Test public void shouldCalculateLoyaltyPointsFor10PercentDiscount() { Map<Product, Integer> productMap = new HashMap<>(2, 1); productMap.put(new Product(PRICE, "DISCOUNT_10", PRODUCT_NUM), 1); ShoppingCart cart = new ShoppingCart(customer, productMap); Order order = cart.checkout(); assertEquals(10, order.getLoyaltyPointsEarned()); }
价格
@Test public void shouldCalculatePriceFor10PercentDiscount() { Map<Product, Integer> productMap = new HashMap<>(2, 1); productMap.put(new Product(PRICE, "DISCOUNT_10", PRODUCT_NUM), 1); ShoppingCart cart = new ShoppingCart(customer, productMap); Order order = cart.checkout(); assertEquals(90.0, order.getTotalPrice(), 0.0); }
-
运行单元测试
测试未通过! -
继续补充刚好使测试通过的代码(保证之前编写的测试也需要通过)
public Order checkout() { final String discountStr = "DISCOUNT_"; AtomicReference<Double> price = new AtomicReference<>(0.0); AtomicInteger loyaltyPointsEarned = new AtomicInteger(0); productMap.forEach((product, value) -> { String productName = product.getProductName(); if (productName.startsWith(discountStr)) { double discount = Double.parseDouble(productName.substring(9)); price.set(product.getPrice() * value * (1 - discount / 100) + price.get()); loyaltyPointsEarned.updateAndGet(v -> (int) (v + (product.getPrice() * value / discount))); }else { price.set(product.getPrice() * value + price.get()); loyaltyPointsEarned.updateAndGet(v -> (int) (v + (product.getPrice() * value / 5))); } }); return new Order(price.get(), loyaltyPointsEarned.get()); }
-
再次运行
全部通过!
编写第三个细分业务测试
如果商品代码以DISCOUNT_20开头,总价=商品价格* 数量* 0.8,积分=总价/20
-
编写单元测试
积分
@Test public void shouldCalculateLoyaltyPointsFor20PercentDiscount() { Map<Product, Integer> productMap = new HashMap<>(2, 1); productMap.put(new Product(PRICE, "DISCOUNT_20", PRODUCT_NUM), 1); ShoppingCart cart = new ShoppingCart(customer, productMap); Order order = cart.checkout(); assertEquals(5, order.getLoyaltyPointsEarned()); }
价格
@Test public void shouldCalculatePriceFor20PercentDiscount() { Map<Product, Integer> productMap = new HashMap<>(2, 1); productMap.put(new Product(PRICE, "DISCOUNT_20", PRODUCT_NUM), 1); ShoppingCart cart = new ShoppingCart(customer, productMap); Order order = cart.checkout(); assertEquals(80.0, order.getTotalPrice(), 0.0); }
-
运行单元测试
由于第二个细分业务已对相同折扣活动进行处理,因此单元测试直接通过!
编写第四个细分业务测试
如果商品代码以BULK_BUY_2_GET_1开头
一、商品数量等于1:按照原价出售,积分=总价/5
-
编写单元测试
价格
@Test public void shouldCalculatePriceForOneBuy2Get1Item() { Map<Product, Integer> productMap = new HashMap<>(2, 1); productMap.put(new Product(PRICE, "BULK_BUY_2_GET_1_ABCD", PRODUCT_NUM), 1); ShoppingCart cart = new ShoppingCart(customer, productMap); Order order = cart.checkout(); assertEquals(100.0, order.getTotalPrice(), 0.0); }
积分
@Test public void shouldCalculateLoyaltyPointsForOneBuy2Get1Item() { Map<Product, Integer> productMap = new HashMap<>(2, 1); productMap.put(new Product(PRICE, "BULK_BUY_2_GET_1_ABCD", PRODUCT_NUM), 1); ShoppingCart cart = new ShoppingCart(customer, productMap); Order order = cart.checkout(); assertEquals(20, order.getLoyaltyPointsEarned()); }
-
运行单元测试
运行通过!注:通过原因为购物车结算时将代码前缀为
BULK_BUY_2_GET_1
的商品判断为不打折商品进行计算,因此测试通过
二、商品数量大于2:总价=商品价格* 数量 * 2/3,积分=总价/5
-
编写单元测试
价格
@Test public void shouldCalculatePriceForTwoBuy2Get1Item() { Map<Product, Integer> productMap = new HashMap<>(2, 1); productMap.put(new Product(PRICE, "BULK_BUY_2_GET_1_ABCD", PRODUCT_NUM), 2); ShoppingCart cart = new ShoppingCart(customer, productMap); Order order = cart.checkout(); assertEquals(133.33, order.getTotalPrice(), 0.1); }
积分
@Test public void shouldCalculateLoyaltyPointsForTwoBuy2Get1Item() { Map<Product, Integer> productMap = new HashMap<>(2, 1); productMap.put(new Product(PRICE, "BULK_BUY_2_GET_1_ABCD", PRODUCT_NUM), 2); ShoppingCart cart = new ShoppingCart(customer, productMap); Order order = cart.checkout(); assertEquals(26, order.getLoyaltyPointsEarned()); }
-
运行单元测试
测试未通过! -
继续补充刚好使测试通过的代码(保证之前编写的测试也需要通过)
public Order checkout() { final String discountStr = "DISCOUNT_"; final String buyOneGetTwo = "BULK_BUY_2_GET_1"; AtomicReference<Double> price = new AtomicReference<>(0.0); AtomicInteger loyaltyPointsEarned = new AtomicInteger(0); productMap.forEach((product, value) -> { String productName = product.getProductName(); if (productName.startsWith(discountStr)) { double discount = Double.parseDouble(productName.substring(9)); price.set(product.getPrice() * value * (1 - discount / 100) + price.get()); loyaltyPointsEarned.updateAndGet(v -> (int) (v + (product.getPrice() * value / discount))); }else if (productName.startsWith(buyOneGetTwo)){ if (value > 1) { price.set(product.getPrice() * value / 3 * 2 + price.get()); loyaltyPointsEarned.updateAndGet(v -> (int) (v + (product.getPrice() * 2 * value / 15))); }else { price.set(product.getPrice() * value + price.get()); loyaltyPointsEarned.updateAndGet(v -> (int) (v + (product.getPrice() * value / 5))); } } else { price.set(product.getPrice() * value + price.get()); loyaltyPointsEarned.updateAndGet(v -> (int) (v + (product.getPrice() * value / 5))); } }); return new Order(price.get(), loyaltyPointsEarned.get()); }
-
再次运行
测试全部通过
至此业务全部完成
总结
知识:是什么让开发人员变得更有勇气去重构代码?
这得益于 TDD 的核心思想——不可运行/可运行/重构。这样的机制可以保证拥有足够多的单元测试以便于支撑实施代码重构,在细微持续的反馈中可以非常自信的做到小步快跑,因为我们可以非常放心的把“后背”交给自动化 BUG 侦察机。
坏消息是一开始因缺乏分析和设计留下来的坑迟早要填,好消息是通过上面的分析,这种问题是可以得到很大程度上的控制,先通过对问题进行分析和建模,再通过领域模型指导程序设计可以有效的降低错误设计的概率,在解决复杂问题域的时候效果更加明显,不过需要注意的是 TDD 主张简单设计,在保证代码可用的前提下追求代码简洁,在重构中消除代码坏味道,并对原有的设计模型进行微观层面的演化和提炼,这种方式可以避免不同程度的浪费(设计浪费、不必要的重写、频繁重构和纠结等)。
早期的开发习惯(编码-运行-观察)会导致开发人员过早陷入实现细节,这种开发习惯的缺陷之一在于反馈周期长,不利于小步快跑的节奏,所以在实践 TDD 的过程中需要时刻提醒自己 TDD 的口号和规则,培养自己养成新的思维习惯。