3、第一个TDD实践:需求分析→写测试→写实现→重构,以计算器为例走通完整TDD流程

好,咱们终于到了动手环节。

前面两章讲了TDD的理念和工具链,说实话,光听不练容易飘。这一章咱们就真刀真枪干一把——用计算器这个经典例子,把TDD的完整流程走一遍。

你可能会想:“计算器?太简单了吧?”

嗯,我当年也这么想。但后来发现,越是简单的例子,越能让你把注意力放在流程本身,而不是被复杂的业务逻辑带偏。说白了,咱们这章的目标不是写一个多牛的计算器,而是让你彻底搞懂“红-绿-重构”这三个步骤到底怎么玩。

3.1 需求分析:别急着写代码,先想清楚要什么

我个人习惯,拿到需求第一件事不是打开IDE,而是拿张纸或者开个备忘录,把需求一条条列出来。

咱们这个计算器,需求其实很明确:

  • 支持加法、减法、乘法、除法
  • 输入两个数字,返回计算结果
  • 除法要考虑除数为0的情况
  • 结果要精确,不能有浮点数精度问题

你看,就这么几条。但别小看它,每一条背后都有坑。

比如“结果要精确”——用Float还是Double?用BigDecimal?我刚开始做项目时,就因为在计算器里用了Double,结果0.1+0.2等于0.30000000000000004,被测试小姐姐追着骂了一下午。

避坑指南: 做金融或科学计算时,浮点数精度问题会直接导致线上bug。建议从一开始就用BigDecimal。

好,需求明确了。接下来咱们进入TDD的核心环节。

3.2 写测试:先让测试失败(红)

TDD的第一条铁律:没有测试之前,绝不写实现代码

你可能会觉得别扭:“我连代码都没写,怎么测?”

别急,咱们先写一个最简单的测试——加法。

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import java.math.BigDecimal

class CalculatorTest {

    @Test
    fun `should return 4 when adding 2 and 2`() {
        // 给定
        val calculator = Calculator()
        
        // 当
        val result = calculator.add(BigDecimal("2"), BigDecimal("2"))
        
        // 那么
        assertEquals(BigDecimal("4"), result)
    }
}

写完之后,你猜会发生什么?

对,编译失败。因为Calculator类根本不存在。

这就是“红”阶段——测试失败,而且失败得明明白白。IDE会告诉你:找不到Calculator类,找不到add方法。

小技巧: 我习惯在测试方法名里直接写清楚场景和期望结果,比如“should return 4 when adding 2 and 2”。这样以后看测试报告,一眼就知道哪里出了问题。

3.3 写实现:让测试通过(绿)

好,现在测试是红的。咱们的目标只有一个:让这个测试变绿。

注意,是“让测试变绿”,不是“写出完美的代码”。

所以,最简单的实现是什么?

import java.math.BigDecimal

class Calculator {
    fun add(a: BigDecimal, b: BigDecimal): BigDecimal {
        return a.add(b)
    }
}

就这么简单。运行测试——绿了。

你可能会觉得:“这也太敷衍了吧?”

嗯,没错。TDD的精髓就是:用最少的代码让测试通过。别想着一步到位,后面还有重构环节呢。

接下来,咱们用同样的方式,把减法、乘法、除法的测试和实现都补上。

// 减法测试
@Test
fun `should return 2 when subtracting 2 from 4`() {
    val calculator = Calculator()
    val result = calculator.subtract(BigDecimal("4"), BigDecimal("2"))
    assertEquals(BigDecimal("2"), result)
}

// 乘法测试
@Test
fun `should return 6 when multiplying 2 and 3`() {
    val calculator = Calculator()
    val result = calculator.multiply(BigDecimal("2"), BigDecimal("3"))
    assertEquals(BigDecimal("6"), result)
}

// 除法测试
@Test
fun `should return 2 when dividing 6 by 3`() {
    val calculator = Calculator()
    val result = calculator.divide(BigDecimal("6"), BigDecimal("3"))
    assertEquals(BigDecimal("2"), result)
}

对应的实现:

fun subtract(a: BigDecimal, b: BigDecimal): BigDecimal = a.subtract(b)
fun multiply(a: BigDecimal, b: BigDecimal): BigDecimal = a.multiply(b)
fun divide(a: BigDecimal, b: BigDecimal): BigDecimal = a.divide(b, 10, RoundingMode.HALF_UP)
注意: 除法这里有个坑。BigDecimal的divide方法如果不指定精度和舍入模式,遇到除不尽的情况会抛异常。我当年第一次写计算器时就踩了这个坑,测试死活过不去,后来查文档才发现。

3.4 处理边界情况:除数为0

好,基本功能都绿了。但咱们的需求里还有一条:除数为0的情况。

按照TDD的节奏,先写测试:

@Test
fun `should throw exception when dividing by zero`() {
    val calculator = Calculator()
    assertThrows<IllegalArgumentException> {
        calculator.divide(BigDecimal("6"), BigDecimal("0"))
    }
}

运行测试——红。因为咱们的divide方法还没处理除数为0的情况。

然后改实现:

fun divide(a: BigDecimal, b: BigDecimal): BigDecimal {
    require(b != BigDecimal.ZERO) { "除数不能为0" }
    return a.divide(b, 10, RoundingMode.HALF_UP)
}

再跑测试——绿了。

我的经验: 边界情况往往是bug的高发区。我建议你在写测试时,把正常情况、边界情况、异常情况都列出来,每个写一个测试方法。这样以后改代码,跑一遍测试就知道有没有破坏原有逻辑。

3.5 重构:让代码更干净

现在所有测试都是绿的。但你看咱们的Calculator类,每个方法都是独立的,没什么问题。

不过,如果以后要加更多功能(比如开方、幂运算),这个类会越来越臃肿。所以,咱们可以做一个简单的重构:把运算逻辑拆出来。

// 重构后的Calculator
class Calculator(private val operation: Operation) {
    fun execute(a: BigDecimal, b: BigDecimal): BigDecimal {
        return operation.apply(a, b)
    }
}

// 策略模式
interface Operation {
    fun apply(a: BigDecimal, b: BigDecimal): BigDecimal
}

class Add : Operation {
    override fun apply(a: BigDecimal, b: BigDecimal): BigDecimal = a.add(b)
}

class Subtract : Operation {
    override fun apply(a: BigDecimal, b: BigDecimal): BigDecimal = a.subtract(b)
}

class Multiply : Operation {
    override fun apply(a: BigDecimal, b: BigDecimal): BigDecimal = a.multiply(b)
}

class Divide : Operation {
    override fun apply(a: BigDecimal, b: BigDecimal): BigDecimal {
        require(b != BigDecimal.ZERO) { "除数不能为0" }
        return a.divide(b, 10, RoundingMode.HALF_UP)
    }
}

重构完,记得跑一遍测试——全部绿。完美。

核心要点: 重构的前提是测试全部通过。没有测试保护的重构,那叫“瞎改”。我见过太多人重构完代码跑不起来,就是因为没写测试。

3.6 总结:TDD的节奏感

好了,咱们走完了第一个完整的TDD流程。来,回顾一下:

步骤 做什么 状态
1. 需求分析 列出功能点、边界情况
2. 写测试 先写一个测试,编译失败
3. 写实现 用最少的代码让测试通过 绿
4. 重构 优化代码结构,保持测试通过 绿

说白了,TDD就是让你在“红-绿-重构”这个循环里反复横跳。每次只加一点点功能,每次都有测试兜底。

我刚开始练TDD时,觉得这个节奏太慢了。但后来发现,正是这种“慢”,让我少加了多少个通宵班。你想想看,如果每次改代码都有测试帮你验证,你还怕什么?

给你的建议: 这一章的内容,我建议你亲手敲一遍。别复制粘贴,一个字一个字地敲。只有亲手走过“红-绿-重构”的流程,你才能真正体会到TDD的节奏感。

下一章,咱们会把这个计算器项目放到真实项目中,看看怎么用TDD驱动一个完整的Android功能开发。到时候,你会看到TDD在复杂场景下的真正威力。