一、TDD与BDD:不只是测试方法,更是设计思维
说实话,我刚入行那会儿,对TDD是有点抵触的。总觉得「先写测试再写代码」这事反直觉——代码都没写,测什么呢?直到有一次,我在一个电商项目里被坑惨了:一个看似简单的「用户积分计算」功能,因为没想清楚边界条件,上线后出了好几次事故。从那以后,我才真正理解了TDD的价值。
TDD和BDD,说白了就是两种不同的「先想清楚再动手」的方式。它们不是教你写测试,而是教你如何设计出更健壮的代码。
1.1 TDD红绿重构循环
TDD的核心就三个字:红、绿、重构。我习惯叫它「红绿灯法则」。
- 红(Red):先写一个会失败的测试。注意,是会失败的测试。如果你写的测试一开始就通过了,说明要么功能已经存在,要么你的测试写错了。
- 绿(Green):写最少的代码让测试通过。别想太多,能跑就行。
- 重构(Refactor):在测试的保护下,优化代码结构。这时候你可以放心大胆地改,因为测试会帮你兜底。
关键点: 每次循环只做一件事。不要试图一次搞定所有功能。我见过很多新手一上来就想写个完美的实现,结果测试写了一堆,代码却跑不通。
1.2 Kotlin中的TDD示例
咱们用Kotlin写个简单的例子。假设我们要实现一个「计算器」的加法功能。
第一步:写一个失败的测试(红)
class CalculatorTest {
@Test
fun `should return sum of two numbers`() {
val calculator = Calculator()
val result = calculator.add(2, 3)
assertEquals(5, result)
}
}
这时候运行测试,肯定会报错——因为Calculator类还不存在。嗯,这就对了。
第二步:写最少的代码让测试通过(绿)
class Calculator {
fun add(a: Int, b: Int): Int {
return a + b
}
}
测试通过了。但别急着高兴,这代码太简单了,没什么好重构的。咱们加个复杂点的场景。
第三步:再加一个测试(红)
@Test
fun `should handle negative numbers`() {
val calculator = Calculator()
val result = calculator.add(-1, 5)
assertEquals(4, result)
}
这个测试应该也能通过,因为add方法本身就支持负数。但如果我们要加一个「除法」功能呢?
@Test
fun `should throw exception when dividing by zero`() {
val calculator = Calculator()
assertThrows<IllegalArgumentException> {
calculator.divide(10, 0)
}
}
你看,TDD强迫你在写代码之前,先想清楚「异常情况怎么处理」。我在项目中遇到过很多次,就是因为没提前想好边界条件,导致线上出bug。
我的习惯: 写测试时,先写「正常路径」,再写「异常路径」。正常路径让你确认功能能用,异常路径让你确认代码够健壮。
二、BDD:让测试变成「人话」
TDD很好,但它有个问题:测试代码写出来,业务人员看不懂。你想想看,一个产品经理看到assertEquals(5, calculator.add(2, 3)),他能理解这是在测什么吗?
BDD(行为驱动开发)就是来解决这个问题的。它用「Given-When-Then」这种自然语言来描述行为,让非技术人员也能看懂。
2.1 Given-When-Then模式
这个模式其实很简单:
- Given:给定某个上下文(前置条件)
- When:当执行某个操作
- Then:那么应该得到某个结果
举个例子:
Given: 用户账户余额为100元
When: 用户提现50元
Then: 用户账户余额应为50元
你看,这种描述谁都能看懂。我在团队里推行BDD时,产品经理甚至能直接帮我们写场景描述。
2.2 Kotlin中的BDD框架:Kotest与Spek
Kotlin生态里有两个主流的BDD框架:Kotest和Spek。我个人更常用Kotest,因为它功能更全,而且和Kotlin的语法结合得更好。
Kotest示例:
class CalculatorSpec : StringSpec({
"should return sum of two numbers" {
val calculator = Calculator()
calculator.add(2, 3) shouldBe 5
}
"should throw exception when dividing by zero" {
val calculator = Calculator()
shouldThrow<IllegalArgumentException> {
calculator.divide(10, 0)
}
}
})
你看,Kotest用shouldBe这种自然语言风格的断言,读起来就像在说人话。
Spek示例:
class CalculatorSpek : Spek({
describe("Calculator") {
val calculator by memoized { Calculator() }
describe("add") {
it("should return sum of two numbers") {
calculator.add(2, 3) shouldEqual 5
}
}
describe("divide") {
it("should throw exception when dividing by zero") {
assertThrows<IllegalArgumentException> {
calculator.divide(10, 0)
}
}
}
}
})
Spek的写法更接近传统的「描述-它」结构,适合习惯JUnit风格的人。
注意: 别把BDD和TDD对立起来。它们不是二选一的关系。我通常的做法是:用TDD的思路写单元测试,用BDD的框架写集成测试或验收测试。这样既保证了代码质量,又让测试可读性更强。
三、TDD与BDD的对比
| 维度 | TDD | BDD |
|---|---|---|
| 关注点 | 代码的正确性 | 系统的行为 |
| 受众 | 开发者 | 开发者 + 业务人员 |
| 描述方式 | 断言 + 期望值 | Given-When-Then |
| 典型框架 | JUnit, TestNG | Kotest, Spek, Cucumber |
| 适用场景 | 单元测试 | 集成测试、验收测试 |
四、TDD与BDD的核心流程
下面这张图展示了TDD和BDD在开发流程中的位置和关系。你可以看到,TDD更偏向代码层面,而BDD更偏向需求层面。
五、实践建议
说了这么多,到底该怎么用?我分享几个实战经验:
- 从TDD开始:如果你团队刚开始接触,先别急着上BDD。先把TDD的红绿重构循环跑顺了再说。
- 用BDD写验收测试:等TDD熟练了,再用BDD框架写端到端的验收测试。这时候你会发现,BDD的Given-When-Then描述,简直就是天然的测试用例文档。
- 别追求100%覆盖率:我曾经见过一个团队,要求测试覆盖率必须达到90%以上。结果呢?大家开始写一堆没意义的测试来凑数。记住,测试的目的是验证行为,不是凑数字。
- 测试也是代码:测试代码和生产代码一样,需要维护、需要重构。别觉得测试写一次就完事了。
我的小技巧: 写测试时,先写「应该通过」的测试,再写「应该失败」的测试。这样能帮你理清思路,避免遗漏边界情况。
好了,这一章的内容就到这里。TDD和BDD不是银弹,但它们确实是提升代码质量的有效手段。下一章我们会深入Kotlin的测试框架,看看怎么在实际项目中用好这些工具。