《代码整洁之道》第9章 单元测试 - 笔记

测试驱动开发 (TDD) 是一种编写整洁代码的"规程"或"方法论",而不仅仅是测试技术。

JaCoCo 在运行测试后生成详细的覆盖率报告的工具, maven 引用。

测试驱动开发

测试驱动开发(TDD)是什么?

TDD 不是说写完代码再写测试,而是先写测试,再写代码。它是一种开发流程,一个不断循环的"节奏":

  1. 红灯 (Red): 写一个针对某个新功能的自动化测试 。运行这个测试,它应该失败,因为你还没写对应的功能代码。这个失败告诉你,"我想要的功能还不存在"。
  2. 绿灯 (Green):最少量 的程序代码,让刚才失败的测试通过。你的目标只是让测试变绿,代码可能写得不好看、效率不高都没关系。然后运行所有的测试(包括之前写过的),确保没有破坏已有的功能。
  3. 重构 (Refactor): 现在所有测试都通过了,功能是正确的。这时,你可以放心地改进和优化 你的程序代码和测试代码,让它们更整洁、更高效、结构更好。重构过程中,要持续运行所有测试,确保改进没有引入新的 Bug。

这个 红 -> 绿 -> 重构 的循环非常短,可能只需要几分钟到十几分钟。你不断地重复这个循环,逐步完善你的功能。

TDD 的三定律

  • 在你编写一个失败的测试之前,不能编写任何生产代码。
  • 在一个失败的测试中,你不能编写多于恰好能够暴露失败的测试代码。
  • 在你编写一个失败的测试之外,你不能编写多于恰好能够使得当前失败测试通过的生产代码。

举例说明:构建一个简单的字符串计算器

假设我们要写一个函数,能够接收一个包含数字和逗号的字符串,并计算所有数字的和。例如 "1,2,3" 应该返回 6。

我们使用 TDD 的流程来开发这个功能。

我们要测试的函数: int add(String numbers)

步骤 1:红灯 (Red) - 测试空字符串

  • 需求: 空字符串应该返回 0。

写测试:

复制代码
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

// 我们要测试的类,也就是生产类
class StringCalculator {
    // 我们还没写这个 add 方法
    // public int add(String numbers) { ... }
}

class StringCalculatorTest {

    @Test
    void shouldReturnZeroForEmptyString() {
        StringCalculator calculator = new StringCalculator();
        // 断言:调用 add("") 应该返回 0
        assertEquals(0, calculator.add(""));
    }
}
  • 运行测试: 测试会编译失败(因为 StringCalculator 类可能不存在,或者 add 方法不存在),或者运行时失败(比如 add 方法抛异常)。测试失败了,红灯!

步骤 2:绿灯 (Green) - 让测试通过

  • 目标:shouldReturnZeroForEmptyString 测试通过。

写最少量代码:

复制代码
class StringCalculator {
    public int add(String numbers) {
        // 最简单让上面测试通过的代码就是判断是不是空字符串
        if (numbers == null || numbers.isEmpty()) {
            return 0;
        }
        // 现在还不能处理非空字符串,先随便返回个东西或抛异常 (这里暂不处理非空)
        // 实际上,为了让测试尽快通过,我们可能直接返回 0
        return 0; // !!! 让 shouldReturnZeroForEmptyString 通过的最少量代码 !!!
    }
}
  • 运行测试: shouldReturnZeroForEmptyString 测试通过了。如果之前有其他测试,也运行一下。所有测试都通过了,绿灯!

步骤 3:重构 (Refactor) - 改进代码

  • 代码量很少,暂时不需要大的重构。可能把 if 判断写得更清晰一点,或者给类和方法加注释。这里跳过大的重构。

步骤 4:红灯 (Red) - 测试单个数字

  • 需求: 输入 "1" 应该返回 1。

写测试:

复制代码
class StringCalculatorTest {
    // ... shouldReturnZeroForEmptyString 测试 ...

    @Test
    void shouldReturnNumberForSingleNumberString() {
        StringCalculator calculator = new StringCalculator();
        // 断言:调用 add("1") 应该返回 1
        assertEquals(1, calculator.add("1"));
        // 断言:调用 add("5") 应该返回 5
        assertEquals(5, calculator.add("5"));
    }
}
  • 运行测试: shouldReturnNumberForSingleNumberString 测试会失败(因为 add("1") 仍然返回 0)。shouldReturnZeroForEmptyString 应该仍然通过。测试失败,红灯!

步骤 5:绿灯 (Green) - 让测试通过

  • 目标:shouldReturnNumberForSingleNumberString 通过。

写最少量代码:

复制代码
class StringCalculator {
    public int add(String numbers) {
        if (numbers == null || numbers.isEmpty()) {
            return 0;
        }
        // !!! 添加处理单个数字的代码 !!!
        // 尝试将字符串转换为整数
        return Integer.parseInt(numbers); // !!! 让测试通过的最少量代码 !!!
    }
}
  • 运行测试: shouldReturnZeroForEmptyStringshouldReturnNumberForSingleNumberString 都通过了。所有测试都通过了,绿灯!

步骤 6:重构 (Refactor) - 改进代码

  • Integer.parseInt 可能会抛出 NumberFormatException,虽然当前测试没有覆盖到无效数字字符串,但为了健壮性,可以在这里考虑异常处理(或者等写了相关测试后再处理)。这里暂时不展开。

步骤 7:红灯 (Red) - 测试两个数字

  • 需求: 输入 "1,2" 应该返回 3。

写测试:

复制代码
class StringCalculatorTest {
    // ... shouldReturnZeroForEmptyString 测试 ...
    // ... shouldReturnNumberForSingleNumberString 测试 ...

    @Test
    void shouldReturnSumForTwoNumbersSeparatedByComma() {
        StringCalculator calculator = new StringCalculator();
        // 断言:调用 add("1,2") 应该返回 3
        assertEquals(3, calculator.add("1,2"));
        // 断言:调用 add("5,7") 应该返回 12
        assertEquals(12, calculator.add("5,7"));
    }
}
  • 运行测试: shouldReturnSumForTwoNumbersSeparatedByComma 测试会失败(因为 add("1,2") 会因为无法直接解析 "1,2" 而抛出 NumberFormatException)。测试失败,红灯!

步骤 8:绿灯 (Green) - 让测试通过

  • 目标:shouldReturnSumForTwoNumbersSeparatedByComma 通过。

写最少量代码:

复制代码
class StringCalculator {
    public int add(String numbers) {
        if (numbers == null || numbers.isEmpty()) {
            return 0;
        }

        // !!! 添加处理逗号分隔的代码 !!!
        String[] numberArray = numbers.split(","); // 按逗号分割
        if (numberArray.length == 1) {
            // 如果分割后只有一个元素 (处理单个数字的情况)
            return Integer.parseInt(numberArray[0]);
        } else {
            // 如果分割后有两个元素 (处理两个数字的情况)
            int num1 = Integer.parseInt(numberArray[0]);
            int num2 = Integer.parseInt(numberArray[1]);
            return num1 + num2; // 求和
        }
        // 注意:这段代码现在还不能处理三个或更多数字,甚至无效数字字符串
        // 但它让当前的测试通过了
    }
}
  • 运行测试: 所有三个测试都应该通过。所有测试都通过了,绿灯!

步骤 9:重构 (Refactor) - 改进代码

现在的代码有点简陋,只能处理空字符串、一个数字或两个数字。我们可以重构它,让它能处理任意数量的数字(注意这里违反了第三条定律):

(需求就是实现测试,目前已经完成了,这里是重构,不过重构不建议加新功能哈,这里不加新功能没啥好重构的了hhhh)

复制代码
class StringCalculator {
    public int add(String numbers) {
        if (numbers == null || numbers.isEmpty()) {
            return 0;
        }

        // 重构:处理任意数量的数字
        String[] numberArray = numbers.split(","); // 按逗号分割

        int sum = 0;
        for (String numberStr : numberArray) {
            // 这里应该加上 NumberFormatException 的处理,但为了例子简洁暂不加
            sum += Integer.parseInt(numberStr); // 累加每个数字
        }

        return sum;
    }
}
  • 运行测试: 再次运行所有测试,确保重构没有破坏功能。它们都应该通过。

这个过程会一直进行下去,每次只添加一点点功能(比如处理换行符分隔、处理负数、忽略大于 1000 的数字等等),为每个新功能写一个测试,让测试通过,然后重构。

这就是 TDD 的基本流程。它通过小步快跑、频繁测试和重构,确保你构建的功能是正确的,并且代码保持整洁。

测试的整洁

整洁测试三要素:可读性、可读性和可读性。

核心思想: 好的测试和好的生产代码一样重要,它们必须是整洁且易于维护的。

整洁测试的五大原则 (F.I.R.S.T.):

F - Fast (快速):

  • 什么意思: 你的测试应该运行得非常快。
  • 为什么重要: 如果测试运行得慢,开发者就不会频繁地运行它们(比如在每次修改代码后)。不频繁运行测试,测试的价值就大打折扣,无法及时发现问题。快速的测试才能融入到小步快跑的 TDD 循环中。

I - Independent (独立):

  • 什么意思: 每个测试用例都应该是独立的,它们不应该相互依赖。一个测试的通过或失败不应该影响到其他测试的运行结果。
  • 为什么重要: 如果测试相互依赖,当一个测试失败时,可能会导致一系列其他测试也跟着失败(级联失败),让你很难判断是哪个测试真正发现了问题,调试会非常困难。独立性也意味着你可以随意调整测试的运行顺序,或者只运行某个特定的测试,而不用担心遗漏依赖项。

R - Repeatable (可重复):

  • 什么意思: 在任何环境(你的开发机、测试服务器、CI/CD 环境)下,无论何时运行,测试都应该给出相同的结果。
  • 为什么重要: 如果测试的结果不可重复(有时通过,有时失败),你就无法信任你的测试套件。它可能是因为外部因素(如网络、时间、文件状态)或测试本身的设计问题导致的不稳定(Flaky Test)。不可重复的测试是最大的障碍,会让人失去对测试的信心。

S - Self-validating (自我验证): 就是用断言,控制台通过或报错,而不是看控制台输出

  • 什么意思: 测试的输出必须是明确的"通过"或"失败"。它应该通过自动化断言(Assert)来判断结果,而不是需要人工去查看日志、比较文件或观察程序行为来判断是否正确。
  • 为什么重要: 自动化测试的目的就是减少人工干预。测试运行完毕后,你只需要看一个简单的报告(比如绿条或红条)就知道代码是否工作正常,不需要花费时间去分析结果。

T - Timely (及时):

  • 什么意思: 测试应该在正确的时间 编写。在 TDD 中,正确的时间就是恰好在需要实现对应功能之前
  • 为什么重要: 及时编写测试(先于代码)是 TDD 方法论的核心,它驱动你思考代码如何使用,促进更好的设计,并确保不会遗漏测试。
相关推荐
8RTHT1 小时前
数据结构(七)---链式栈
数据结构
2501_906314323 小时前
优化无头浏览器流量:使用Puppeteer进行高效数据抓取的成本降低策略
开发语言·数据结构·数据仓库
C182981825753 小时前
项目中数据结构为什么用数组,不用List
数据结构
几点才到啊6 小时前
使用 malloc 函数模拟开辟一个 3x5 的整型二维数组
数据结构·算法
<但凡.9 小时前
C++修炼:list模拟实现
开发语言·数据结构·c++
敲代码的瓦龙10 小时前
C++?动态内存管理!!!
c语言·开发语言·数据结构·c++·后端
Ronin30510 小时前
【C++】13.list的模拟实现
开发语言·数据结构·c++·list
序属秋秋秋10 小时前
《数据结构初阶》【顺序表 + 单链表 + 双向链表】
c语言·数据结构·笔记·链表
无敌的牛11 小时前
AVL树的介绍与学习
数据结构·学习