一、TDD与BDD:不只是测试方法,更是设计思维

说实话,我刚入行那会儿,对TDD是有点抵触的。总觉得「先写测试再写代码」这事反直觉——代码都没写,测什么呢?直到有一次,我在一个电商项目里被坑惨了:一个看似简单的「用户积分计算」功能,因为没想清楚边界条件,上线后出了好几次事故。从那以后,我才真正理解了TDD的价值。

TDD和BDD,说白了就是两种不同的「先想清楚再动手」的方式。它们不是教你写测试,而是教你如何设计出更健壮的代码。

1.1 TDD红绿重构循环

TDD的核心就三个字:红、绿、重构。我习惯叫它「红绿灯法则」。

  1. 红(Red):先写一个会失败的测试。注意,是会失败的测试。如果你写的测试一开始就通过了,说明要么功能已经存在,要么你的测试写错了。
  2. 绿(Green):写最少的代码让测试通过。别想太多,能跑就行。
  3. 重构(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核心流程 BDD:行为驱动开发 关注系统行为,使用Given-When-Then TDD:测试驱动开发 关注代码正确性,红绿重构循环 需求驱动 编写场景 Given-When-Then 自动化测试 Kotest/Spek 写失败测试 绿 让测试通过 重构 优化代码 循环迭代 输出:高质量、可测试的代码 测试即文档,代码即设计 BDD从需求出发,TDD从代码出发,两者互补

五、实践建议

说了这么多,到底该怎么用?我分享几个实战经验:

  1. 从TDD开始:如果你团队刚开始接触,先别急着上BDD。先把TDD的红绿重构循环跑顺了再说。
  2. 用BDD写验收测试:等TDD熟练了,再用BDD框架写端到端的验收测试。这时候你会发现,BDD的Given-When-Then描述,简直就是天然的测试用例文档。
  3. 别追求100%覆盖率:我曾经见过一个团队,要求测试覆盖率必须达到90%以上。结果呢?大家开始写一堆没意义的测试来凑数。记住,测试的目的是验证行为,不是凑数字。
  4. 测试也是代码:测试代码和生产代码一样,需要维护、需要重构。别觉得测试写一次就完事了。

我的小技巧: 写测试时,先写「应该通过」的测试,再写「应该失败」的测试。这样能帮你理清思路,避免遗漏边界情况。

好了,这一章的内容就到这里。TDD和BDD不是银弹,但它们确实是提升代码质量的有效手段。下一章我们会深入Kotlin的测试框架,看看怎么在实际项目中用好这些工具。

公众号:蓝海资料掘金营,微信deep3321