24、测试驱动开发(TDD)实战:红-绿-重构循环、CMake+CTest工作流、持续反馈

说实话,TDD 这个词在 C++ 圈子里一直有点「叫好不叫座」。很多人觉得它太理想化,写测试比写代码还慢。但我个人习惯是:越是核心的逻辑模块,越要用 TDD 来驱动。为什么呢?因为等你项目写到一半,发现底层函数有 bug,那改起来才叫真痛苦。

这一章,我们就来实打实地走一遍 TDD 的「红-绿-重构」循环。而且我会把 CMake 和 CTest 嵌进去,让你看到这套工作流在真实项目里是怎么跑的。

什么是红-绿-重构?

说白了就三步:

  • 红(Red):先写一个会失败的测试。这时候代码还没实现,测试跑不过,很正常。
  • 绿(Green):写最简代码让测试通过。别想太多,能过就行。
  • 重构(Refactor):在测试保护下,优化代码结构。测试还在,改错了它会告诉你。

这个循环有多短?我建议控制在 5 分钟以内。超过 10 分钟还没变绿,说明步子迈太大了。

核心原则:没有测试保护的代码,重构就是裸奔。

一个真实的 TDD 例子:计算器模块

假设我们要写一个简单的计算器模块,支持加法和除法。按 TDD 的套路来。

第一步:红 —— 先写测试

// test_calculator.cpp
#include <gtest/gtest.h>
#include "calculator.h"

TEST(CalculatorTest, AddPositiveNumbers) {
    Calculator calc;
    EXPECT_EQ(calc.add(2, 3), 5);
}

TEST(CalculatorTest, DivideByZero) {
    Calculator calc;
    EXPECT_THROW(calc.divide(1, 0), std::invalid_argument);
}

这时候编译肯定过不了,因为 calculator.h 还没写。但没关系,这就是「红」的状态。

我的经验:写测试时,先想好接口长什么样。测试用例本身就是最好的设计文档。

第二步:绿 —— 写最简实现

// calculator.h
#pragma once
#include <stdexcept>

class Calculator {
public:
    int add(int a, int b) { return a + b; }
    int divide(int a, int b) {
        if (b == 0) throw std::invalid_argument("divide by zero");
        return a / b;
    }
};

嗯,代码很丑,但测试通过了。这就够了。

第三步:重构 —— 优化代码

测试通过了,我就可以放心地重构了。比如把除法逻辑抽出来,或者加个日志。只要测试还是绿的,我就知道没改坏。

// calculator.h (重构后)
#pragma once
#include <stdexcept>
#include <iostream>

class Calculator {
public:
    int add(int a, int b) {
        std::cout << "add: " << a << " + " << b << std::endl;
        return a + b;
    }
    int divide(int a, int b) {
        if (b == 0) {
            std::cerr << "divide by zero!" << std::endl;
            throw std::invalid_argument("divide by zero");
        }
        return a / b;
    }
};

注意:重构时不要同时加新功能。重构只改变结构,不改变行为。新功能请从「红」开始。

CMake + CTest 工作流

光有测试还不够,你得让测试能自动跑起来。CMake 和 CTest 就是干这个的。

CMakeLists.txt 配置

cmake_minimum_required(VERSION 3.16)
project(TDDCalculator VERSION 1.0.0 LANGUAGES CXX)

# 启用测试
enable_testing()

# 添加可执行文件
add_executable(calculator_app main.cpp calculator.h)

# 添加测试目标
find_package(GTest REQUIRED)
add_executable(test_calculator test_calculator.cpp)
target_link_libraries(test_calculator GTest::GTest GTest::Main)

# 注册测试
add_test(NAME CalculatorTest COMMAND test_calculator)

运行测试

mkdir build && cd build
cmake ..
cmake --build .
ctest --output-on-failure

你会看到类似这样的输出:

Test project /path/to/build
    Start 1: CalculatorTest
1/1 Test #1: CalculatorTest ...............   Passed  0.02 sec

100% tests passed, 0 tests failed out of 1

我建议:在 CI 脚本里加上 ctest --output-on-failure,这样测试失败时能直接看到错误信息,不用再手动跑一遍。

持续反馈:让测试成为你的「第六感」

TDD 的核心不是测试本身,而是反馈速度。你想想看,如果每次改完代码要等 10 分钟才知道有没有改坏,那谁还愿意频繁重构?

所以,我一般会做这几件事:

  • ctest -j4 并行跑测试,充分利用多核 CPU。
  • 在编辑器里集成测试,比如 VS Code 的任务系统,一键跑测试。
  • 用文件监听工具(比如 inotifywaitentr),每次保存文件自动跑测试。

我曾经在一个嵌入式项目里,把测试跑在模拟器上,每次改完代码 3 秒内就能看到结果。那种感觉,就像写代码有了实时反馈,爽得很。

知识体系总览

下面这张图,把 TDD 的核心流程和 CMake+CTest 的集成关系画清楚了:

TDD 红-绿-重构循环 + CMake/CTest 工作流 🔴 红 (Red) 写一个会失败的测试 测试未通过 → 继续 🟢 绿 (Green) 写最简代码让测试通过 测试通过 → 重构 🟠 重构 (Refactor) 优化代码结构 测试保护下安全重构 回到红阶段,开始下一个功能 CMake + CTest 集成 CMakeLists.txt enable_testing() add_test() CTest 运行 ctest --output-on-failure 并行测试: ctest -j4 持续反馈 编辑器集成 文件监听自动跑

避坑指南

最后,分享几个我踩过的坑:

  • 测试写得太粗:一个测试函数测了太多东西,失败了不知道哪里出问题。我建议一个测试只测一个行为。
  • 忽略测试维护:测试代码也是代码,也要重构。我曾经见过一个项目,测试代码比业务代码还难读,最后没人敢改。
  • 过度依赖模拟对象:Mock 用多了,测试会变得脆弱。能用真实对象就用真实对象,除非真的需要隔离外部依赖。

一句话总结:TDD 不是银弹,但它能让你在重构时睡得安稳。CMake + CTest 就是那个帮你守夜的保安。


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