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 的任务系统,一键跑测试。
- 用文件监听工具(比如
inotifywait或entr),每次保存文件自动跑测试。
我曾经在一个嵌入式项目里,把测试跑在模拟器上,每次改完代码 3 秒内就能看到结果。那种感觉,就像写代码有了实时反馈,爽得很。
知识体系总览
下面这张图,把 TDD 的核心流程和 CMake+CTest 的集成关系画清楚了:
避坑指南
最后,分享几个我踩过的坑:
- 测试写得太粗:一个测试函数测了太多东西,失败了不知道哪里出问题。我建议一个测试只测一个行为。
- 忽略测试维护:测试代码也是代码,也要重构。我曾经见过一个项目,测试代码比业务代码还难读,最后没人敢改。
- 过度依赖模拟对象:Mock 用多了,测试会变得脆弱。能用真实对象就用真实对象,除非真的需要隔离外部依赖。
一句话总结:TDD 不是银弹,但它能让你在重构时睡得安稳。CMake + CTest 就是那个帮你守夜的保安。