CppCon 2014 学习: C++ Test-driven Development
“Elephant in the Room”这个比喻常用来形容那些大家都知道但没人愿意讨论的重大问题。
这段内容讲的是软件质量管理的经典做法和潜在的问题:
- 经典做法:开发完成后才进行人工测试(manual testing after creation)。
- 隐喻“Cape of Good Hope or bury your head in the sand?”:面对质量问题,团队要么积极面对(Cape of Good Hope,象征希望和解决之道),要么选择回避(埋头于沙,忽视问题)。
- “Small Cute Things grow to become larger Problems!”:初期小的、看似无害的问题,如果不及时处理,最终会演变成更大的麻烦。
整体意思是在软件开发中,依赖事后人工测试来保证质量存在很大风险,早期的小缺陷如果不及时发现和修正,最终会带来严重后果。
这段内容用幽默的方式描述了软件“测试”的不同层次,强调了“编译成功”、“程序能启动”、“不崩溃”这些都不是充分的测试: - “It compiles!” —— 只说明代码语法没错,但并不保证功能正确。
- “It runs!” —— 程序能启动了,但未必符合预期。
- “It doesn’t crash!” —— 程序不会立刻崩溃,但可能还是有隐藏问题。
- “It runs even with random input!” —— 程序对随机输入没崩溃,说明鲁棒性好些,但仍不足够。
- “It creates a correct result!” —— 程序在某一个合理的输入下得到了正确结果,但这只是单一用例。
- “Automated (unit) testing gives you much more!” —— 自动化单元测试能系统化地验证代码在各种输入和边界条件下的行为,提高质量保证。
这强调了自动化测试的重要性——不只是“跑起来”这么简单,而是要确保程序逻辑和功能全面正确。
这个“Vicious Circle”(恶性循环)图示强调了软件开发中的一个常见困境:
- 没有测试(no Tests) →
- 导致更多压力(more Stress) →
- 没有时间写测试(no Time for Tests) →
- 更多错误(more Errors) →
- 压力更大(STRESS) →
- 测试更少(Less Testing) → 又回到“没有测试”。
解决方法是:自动化测试,并且频繁运行它们,打破这个恶性循环,减少错误,提高代码质量,降低压力。
自动化测试不但能节省时间,还能让开发过程更稳定和可控。
你给出的 Eclipse CDT 生成的 C++ “Hello World” 程序示例,其代码和问题点如下:
代码问题分析
#include <iostream>
using namespace std; // **坏习惯**:在全局作用域引入整个std命名空间,容易引起命名冲突
int main() { // 注释提到“使用全局变量”,其实这里是指using namespace std;造成全局污染cout << "!!!Hello World!!!" << endl; // 输出语句正常,但“冗余”和“低效”的评论没有太大实际意义// “ridiculous comment”指代码注释太多且无意义return 0; // return 0是标准的,但在C++11及以上可以省略
}
- using namespace std; 在全局作用域会把整个
std
名字空间导入,容易引发名字冲突,不推荐这样用。更好的做法是写成std::cout
。 - 代码中有过多无意义的注释,不利于代码整洁。
return 0;
虽然冗余,但标准main
函数里写明返回值是个好习惯。- 代码没什么性能问题,毕竟只是个输出字符串的简单程序。
如何测试这段代码?
- 编译测试
- 先用编译器(比如 g++, clang++)编译程序。
- 如果编译成功且无警告,说明基本没语法错误。
- 示例命令:
g++ helloworld.cpp -o helloworld
- 功能测试
- 运行程序,看输出是否符合预期:
./helloworld
- 预期输出:
!!!Hello World!!!
- 运行程序,看输出是否符合预期:
- 自动化测试(单元测试)
- 虽然简单程序不常写单元测试,但可以用工具捕获标准输出,检查是否含有预期字符串。
- 使用 C++ 测试框架(如 Google Test)结合捕获输出流测试。
- 静态代码分析
- 使用工具(clang-tidy、cppcheck)分析代码质量。
- 这些工具会提示
using namespace std;
的风险,以及不必要的注释或潜在问题。
- 代码审查
- 让其他开发者阅读代码,发现不良实践和改进空间。
改进示例代码
#include <iostream>
int main() {std::cout << "!!!Hello World!!!" << std::endl;return 0;
}
这样避免了全局 using namespace std;
,代码更清晰。
让我帮你总结并给出一个更好、更可测试的“Hello World”示例:
更好(Hello)World的写法原则
- 不要在全局作用域用
using namespace std;
,改用using std::cout;
或直接写std::cout
。 - 把核心功能从
main()
中抽离成函数或类,方便单元测试。 - 保持
main()
代码简单,只负责调用接口。 - 避免全局非const变量,保证函数纯粹和独立。
- 尽量少用
std::endl
,用'\n'
代替,减少不必要的刷新开销。
示例代码
// hello.h
#ifndef HELLO_H
#define HELLO_H
#include <string>
// 返回要打印的问候语字符串
std::string getGreeting();
#endif // HELLO_H
// hello.cpp
#include "hello.h"
std::string getGreeting() {return "!!!Hello World!!!";
}
// main.cpp
#include <iostream>
#include "hello.h"
int main() {std::cout << getGreeting() << '\n'; // 使用换行符,避免多余刷新return 0;
}
单元测试示例(用 Catch2)
// test_hello.cpp
#define CATCH_CONFIG_MAIN
#include "catch.hpp"
#include "hello.h"
TEST_CASE("Greeting message is correct") {REQUIRE(getGreeting() == "!!!Hello World!!!");
}
优点总结
- 分离逻辑和界面:
getGreeting()
专注生成字符串,main()
专注输出。 - 易测试:可以单独测试
getGreeting()
,不依赖输入输出。 - 避免全局命名污染:不使用全局
using namespace std;
。 - 效率更高:避免不必要的
std::endl
刷新。
如何测试 Hello World?
1. 提取功能函数
把打印功能从 main()
中抽取出来,比如叫 printGreeting()
。
2. 依赖注入(参数化 std::ostream&
)
不要直接写死用 std::cout
,而是通过传入一个 std::ostream&
参数来控制输出目标。这样可以在测试时传入 std::stringstream
,捕获输出内容。
3. 独立库模块
把这个函数放到一个独立的编译单元(库或单独 .cpp
文件)中,使得它独立于 main()
,便于测试。
4. 编写单元测试
用 std::stringstream
作为“假”输出流,捕获函数输出,再用断言检查是否与期望字符串一致。
5. 运行测试
用测试框架(如 Catch2、GoogleTest)跑测试,确保输出正确。
具体示例
// hello.h
#ifndef HELLO_H
#define HELLO_H
#include <ostream>
// 打印问候信息到给定输出流
void printGreeting(std::ostream& os);
#endif
// hello.cpp
#include "hello.h"
void printGreeting(std::ostream& os) {os << "!!!Hello World!!!\n"; // 使用换行符代替 std::endl
}
// main.cpp
#include "hello.h"
#include <iostream>
int main() {printGreeting(std::cout);return 0;
}
// test_hello.cpp
#define CATCH_CONFIG_MAIN
#include "catch.hpp"
#include "hello.h"
#include <sstream>
TEST_CASE("printGreeting outputs correct string") {std::stringstream ss;printGreeting(ss);REQUIRE(ss.str() == "!!!Hello World!!!\n");
}
这样写的好处是:
- 函数无副作用,不依赖全局状态,只通过参数控制输出。
- 易于捕获输出,用
stringstream
测试输出是否正确。 - 复用性好,
printGreeting()
可以在不同上下文调用,比如真实运行用std::cout
,测试时用stringstream
。 - 测试覆盖简单且可靠。
这里是一个完整的可测试的 Hello World C++ 示例,结合 CUTE 测试框架:
sayHello.h
#ifndef SAYHELLO_H_
#define SAYHELLO_H_
#include <iosfwd>
void sayHello(std::ostream &out);
#endif /* SAYHELLO_H_ */
sayHello.cpp
#include "sayHello.h"
#include <ostream>
void sayHello(std::ostream &out) {out << "Hello, world!\n";
}
test_sayHello.cpp (测试代码)
// 引入 CUTE 单元测试框架的核心组件
#include "cute/cute.h" // CUTE 的断言和测试用例宏
#include "cute/ide_listener.h" // 用于在 IDE 中友好输出测试结果
#include "cute/xml_listener.h" // 用于生成 XML 格式的测试报告(例如给 CI 系统用)
#include "cute/cute_runner.h" // 用于运行测试用例
#include <sstream> // 用于创建可测试的输出流(std::ostringstream)
#include "sayHello.h" // 被测试的函数声明
// 测试用例函数:测试 sayHello 函数是否输出了期望的字符串
void testSayHelloSaysHelloWorld() {std::ostringstream out; // 创建一个字符串输出流,模拟 std::coutsayHello(out); // 调用被测试函数,输出重定向到 outASSERT_EQUAL("Hello, world!\n", out.str()); // 检查输出是否符合预期
}
// 封装所有测试的运行逻辑
void runAllTests(int argc, char const *argv[]) {cute::suite s; // 创建一个测试套件(可以理解为测试集合)s.push_back(CUTE(testSayHelloSaysHelloWorld)); // 将测试用例添加到测试套件中cute::xml_file_opener xmlfile(argc, argv); // 打开 XML 输出文件(用于测试报告)cute::xml_listener<cute::ide_listener<>> lis(xmlfile.out); // 包装监听器,记录测试结果并输出cute::makeRunner(lis, argc, argv)(s, "AllTests"); // 运行所有测试用例,名称为 "AllTests"
}
// 主函数,程序入口点
int main(int argc, char const *argv[]) {runAllTests(argc, argv); // 启动测试执行return 0; // 返回退出码
}
main.cpp (正式运行时的程序入口)
#include "sayHello.h"
#include <iostream>
int main() {sayHello(std::cout);return 0;
}
说明:
sayHello
函数通过传入的输出流参数写入数据,方便测试捕获。- 测试用例用
std::ostringstream
捕获输出,断言结果正确。 - 测试框架用的是 CUTE,集成了运行和监听器,支持 xml 输出和 IDE 友好的格式。
main.cpp
只负责调用sayHello
,保持简单且容易维护。
如果你想,我还可以帮你写一个简单的 CMake 配置文件,方便你编译这个项目!
或者解释如何用其他单元测试框架(如 GoogleTest)实现类似测试。你觉得呢?
CUTE(C++ Unit Testing framework)是一个轻量级、简单易用的 C++ 单元测试框架,常用于教学和快速原型开发。它最初由 Peter Sommerlad 教授创建。
下载 CUTE 的方式如下:
方式一:从 GitHub 下载(推荐)
Peter Sommerlad 教授和 HSR(瑞士应用科技大学)维护了一个官方 CUTE 仓库:
GitHub 仓库地址
https://github.com/PeterSommerlad/CUTE
下载方法
git clone https://github.com/PeterSommerlad/CUTE.git
方式二:下载 ZIP 压缩包
- 打开 GitHub 仓库链接:https://github.com/PeterSommerlad/CUTE
- 点击右上角绿色的 “Code” 按钮
- 选择 “Download ZIP”
- 解压缩后即可在本地使用
使用说明(简单编译)
CUTE 是纯头文件的库,不需要编译安装。你只需要包含头文件目录即可使用。
假设你下载并解压到了 CUTE/
目录:
project/
│
├── CUTE/ ← 克隆/解压的 CUTE 源码目录
│ ├── cute.h
│ ├── cute_runner.h
│ ├── ide_listener.h
│ └── ...
│
├── sayHello.h
├── sayHello.cpp
├── test_sayHello.cpp ← 使用 CUTE 测试的文件
└── main.cpp
cmake_minimum_required(VERSION 3.10)
project(HelloTestableWorld)
set(CMAKE_CXX_STANDARD 17)
# 添加你的源文件
set(SOURCESsayHello.cppmain.cpp
)
# 添加你的测试文件
set(TEST_SOURCEStest_sayHello.cpp
)
# 添加可执行文件(主程序)
add_executable(hello_world ${SOURCES})
# 添加测试可执行文件
add_executable(run_tests ${TEST_SOURCES} sayHello.cpp)
# 添加 CUTE 头文件路径
target_include_directories(run_tests PRIVATE CUTE)
Hello, world!
#beginning AllTests 1
#starting testSayHelloSaysHelloWorld
#success testSayHelloSaysHelloWorld OK
#ending AllTests
“System Under Test”(SUT,待测系统)
这张图展示了一个软件测试框架,主题是“System Under Test”(SUT,待测系统)。以下是对框架的理解:
1. 框架概述
- 结构:分为测试层(左侧)和 SUT 层(右侧),两者通过“Exercise”(执行)关系连接。
- 组件:
- 左侧:测试模块(Unit1 Test、Unit2 Test、Comp1 Test、Comp2 Test、App1 Test)。
- 右侧:SUT 模块(Unit1 SUT、Unit2 SUT、Comp1 SUT、Comp2 SUT、App1 SUT)。
2. 测试模块和 SUT 模块的关系
- 测试模块(左侧):
- Unit1 Test、Unit2 Test:针对单个单元(Unit1 和 Unit2)的测试。
- Comp1 Test、Comp2 Test:针对组件(Comp1 和 Comp2)的测试。
- App1 Test:针对应用(App1)的测试。
- SUT 模块(右侧):
- Unit1 SUT、Unit2 SUT:待测的单元。
- Comp1 SUT、Comp2 SUT:待测的组件。
- App1 SUT:待测的应用。
3. 交互和依赖
- Exercise 箭头:表示测试模块通过“Exercise”操作调用对应的 SUT 模块。例如:
- Unit1 Test 直接测试 Unit1 SUT。
- Comp1 Test 直接测试 Comp1 SUT。
- uses 箭头:表示 SUT 模块之间的依赖关系。例如:
- Comp1 SUT 使用 Unit1 SUT 和 Unit2 SUT。
- Comp2 SUT 使用 Unit2 SUT。
- App1 SUT 使用 Comp1 SUT 和 Comp2 SUT。
4. 层级结构
- 单元层(Unit):最底层,Unit1 和 Unit2 是独立功能模块。
- 组件层(Comp):中层,Comp1 和 Comp2 依赖单元模块。
- 应用层(App):最高层,App1 依赖组件模块。
- 测试层级:每个层级都有对应的测试模块,确保从单元到应用的全覆盖测试。
5. 总体理解
- 这是一个分层测试框架,遵循自底向上的测试策略:
- 先测试最小的单元(Unit1 和 Unit2)。
- 然后测试依赖单元的组件(Comp1 和 Comp2)。
- 最后测试整合组件的应用(App1)。
- 目的:通过模块化测试,隔离问题,确保系统的每个部分都经过验证。
- 优势:清晰的依赖关系和测试覆盖,适合复杂系统。
- 组件依赖:组件类可能通过组合或依赖注入使用单元。
class Comp1 {Unit1 unit1;Unit2 unit2; public:bool execute() { return unit1.function() && unit2.function(); } };
这张图展示了“四阶段测试”(Four Phase Test)框架,用于测试系统(SUT,System Under Test)。以下是理解:
1. 框架概述
- 阶段:分为四个阶段,从上到下依次为:
- Setup(设置)
- Exercise(执行)
- Verify(验证)
- Teardown(清理)
2. 各阶段详解
- Setup(设置):
- 操作:
Initialize
(初始化)。 - 输入:
Direct Inputs (CONTROL POINTS)
(直接输入,控制点)。 - 意义:为测试准备环境,设置 SUT 的初始状态。
- 操作:
- Exercise(执行):
- 操作:
Direct Input (Control Point)
(直接输入,控制点)和Do Something (with return value)
(执行操作,返回值)。 - 交互:SUT 内部通过控制点(A、B、C)执行操作。
- 输出:
Direct OUTPUTS (Observation Points)
(直接输出,观察点)。 - 意义:对 SUT 进行测试操作,触发行为并产生结果。
- 操作:
- Verify(验证):
- 操作:
Get State 2 State
(获取状态)。 - 输出:
Indirect Output (Observation Point)
(间接输出,观察点)和Do Something (with return value)
(执行操作,返回值)。 - 意义:检查 SUT 的状态,验证输出是否符合预期。
- 操作:
- Teardown(清理):
- 操作:未详细说明,但通常是清理资源、恢复初始状态。
- 意义:结束测试,释放资源。
3. SUT 和 DOC 的关系
- SUT(System Under Test):待测系统,包含内部组件(A、B、C),通过控制点和观察点与外部交互。
- DOC:未明确定义,但可能指文档(Documentation)或测试上下文,用于记录或验证结果。
4. 总体理解
- 流程:
- Setup:初始化 SUT,设置控制点。
- Exercise:通过控制点触发 SUT 操作,生成观察点。
- Verify:获取状态,检查观察点,验证结果。
- Teardown:清理环境。
- 目的:确保 SUT 在不同阶段的行为正确,适用于软件测试。
- 特点:结构化测试方法,强调输入(控制点)和输出(观察点)的分离,便于自动化测试。
5. 可能的 C++ 实现(参考前文)
- 测试框架:可以用 C++ 实现四阶段测试。例如:
class TestFramework {SUT sut; public:void setup() { sut.initialize(); }void exercise() { sut.doSomething(); }bool verify() { return sut.getState() == expectedState; }void teardown() { sut.cleanup(); } };
Four Phase Test(四阶段测试)结构 在 C++ 单元测试中的实际运用有更深刻的把握。
Four Phase Test Structure(四阶段测试结构)详解
1. Setup(设置阶段)
创建测试所需的本地对象和资源。
- 实例化被测对象(SUT)
- 配置依赖(例如:Mock、Stub)
- 初始化输入数据或状态
🔹示例:
MyClass obj;
int input = 5;
2. Exercise(执行阶段)
调用你想要验证的函数或行为。
- 通常就是测试的主行为调用
- 一般只包含一次调用,避免测试变复杂
🔹示例:
int result = obj.compute(input);
3. Verify(验证阶段)
使用断言检查结果是否符合预期。
- 使用如
ASSERT_EQ
,EXPECT_TRUE
等断言 - 验证返回值或检查状态/副作用
🔹示例:
ASSERT_EQ(10, result);
4. Teardown(清理阶段)
释放资源,恢复环境状态(在 C++ 中通常很简单)。
- 对于大多数栈对象,无需显式清理
- 如果你用了文件、网络、动态内存、数据库等外部资源,需要手动清理
🔹示例:
// usually nothing if using RAII
file.close();
小结
阶段 | 目的 | C++ 中的实现方式 |
---|---|---|
Setup | 创建对象、准备依赖和输入 | 使用局部变量、构造函数、mock |
Exercise | 执行被测试的行为 | 调用方法、函数 |
Verify | 检查行为是否符合预期 | 断言、检查状态、mock验证 |
Teardown | 清理资源,恢复干净测试环境 | 析构函数自动完成;手动释放外部资源 |
这套结构是写出可读性高、可维护性强的单元测试的核心模式。 |
GUTs(Good Unit Tests,优质单元测试)是由Alistair Cockburn等人提出的单元测试质量标准。你列出的一些关键特征可以归纳为可维护、清晰、独立、可重复并有效覆盖代码逻辑的测试。以下是对这些特征的逐条解释与理解:
GUTs 的关键特征理解:
- GOOD, DRY and Simple
- GOOD:测试是“好”的,意味着它们清晰、正确、能捕捉回归错误。
- DRY (Don’t Repeat Yourself):不重复 setup 或断言逻辑,避免冗余代码。
- Simple:测试逻辑应尽量简单直接,不引入复杂结构。
- No control structures, tests run linear
- 测试中不应该有条件语句(如 if/else)或循环(如 for)。测试应该是线性的、顺序执行的,这样才容易读懂和排查错误。
- 示例坏例子:
好例子应该直接 assert 明确行为。if some_condition:assert something # 不推荐
- Have the test assertion(s) in the end
- 测试的断言应写在测试函数的末尾,确保 setup 和 action 部分在前,验证部分在后。
- 有助于清晰表达测试结构:Arrange → Act → Assert(AAA 模式)。
- Test one thing at a time
- 每个测试聚焦于一个行为或情况。如果失败,可以立即知道是什么坏了。
- 避免在一个测试里验证多个概念或逻辑分支。
- Not a test per function/method, but a test per function call
- 测试关注调用行为的结果而非代码实现细节。
- 不等于每个函数一个测试,而是根据使用方式(调用方式)来写测试。
- A test per equivalence class of input values
- 每个输入**等价类(Equivalence Class)**写一个测试。测试代表了一类输入,不必穷举每种输入。
- 例如,对于一个整数函数:负数、零、正数可作为三个等价类。
- Have no (order) dependency
- 测试应独立运行,无论运行顺序如何,都不会影响结果。
- 不应依赖其他测试的状态或副作用。
- Leave no traces for others to depend on
- 测试不应在运行后污染系统状态,例如数据库、文件系统等。
- 测试应能重复运行且每次都得到相同结果。
- All run successfully if you deliver
- 所有测试在提交代码前都应通过;测试失败代表产品不应交付。
- Have a good coverage of production code
- 测试应尽量覆盖生产代码中的路径和逻辑,尤其是核心功能。
- 代码覆盖率不是唯一目标,但能反映测试覆盖的广度。
- Are often created Test-First → Test-Driven Development (TDD)
- 优质单元测试常常来自**测试驱动开发(TDD)**流程:
- 写测试 → 写实现 → 重构 → 重复。
- 这样写出的测试更贴近需求、更易维护。
- 优质单元测试常常来自**测试驱动开发(TDD)**流程:
总结(核心理念):
- GUTs 是可读性好、清晰表达行为、低耦合、高覆盖率的测试。
- 目标是帮助团队快速定位问题、避免回归错误并支持安全重构。
- 写 GUTs 的 mindset 更关注行为驱动而非代码驱动。
关于 TDD(测试驱动开发)循环 的简明图示,其核心是 “Red → Green → Refactor” 的开发节奏。这是敏捷开发中的一个重要实践,强调通过小步迭代来推动高质量设计与实现。
以下是对这个 TDD 循环的详细解释和理解:
TDD 开发循环理解(Red → Green → Refactor)
1. Red(红)— 写一个失败的测试
- 目标:捕捉需求或行为。
- 动作:
- 写一个最小的、能表达某种期望行为的单元测试。
- 这个测试一开始必须失败(因为还没实现功能)。
- 原因:
- 确保测试对需求敏感,不是伪测试。
- 验证测试本身是有效的。
2. Green(绿)— 编码使测试通过
- 目标:让刚才失败的测试通过。
- 动作:
- 编写实现代码,使测试成功。
- 不追求优雅或完美,只需“尽快让测试通过”。
- 原因:
- 快速获得反馈,验证实现可行。
- 保证持续前进,不陷入设计推演。
3. Refactor(重构)— 改进设计,不改行为
- 目标:提升代码质量,清理重复或糟糕设计。
- 动作:
- 重命名、提取方法、合并重复逻辑、调整结构。
- 所有改动应不改变行为(所有测试仍需通过)。
- 原因:
- 保持代码整洁、易维护,防止技术债堆积。
TDD 循环的后续步骤(Peter Sommerlad 补充)
你引用的图表还包括一些额外步骤:
4. Integrate(集成)
- 与团队代码库进行集成,保证本地通过的变更在整体项目中也工作良好。
- 通常在 CI/CD 流水线中执行所有测试。
5. Make Test and Change Permanent – Check In
- 在确保一切正常后,提交变更(check in)。
- 这一步表示当前这一小步已完成,可以作为一个“微版本”。
理解重点总结
阶段 | 目标 | 行为 |
---|---|---|
Red | 明确需求,驱动设计 | 写一个失败的测试 |
Green | 实现最小可工作代码 | 编码使测试通过 |
Refactor | 改善设计,保持行为不变 | 清理重复、重命名、提取逻辑 |
Integrate | 确保团队代码一致性 | 拉代码、合并、验证 |
Check In | 持久化成果 | 提交代码,记录改动历史 |
TDD 的好处在于: |
- 推动高内聚、低耦合的设计。
- 降低回归风险。
- 鼓励持续重构和良好编码习惯。
- 将开发过程转化为“对行为的承诺”。
TDD 工作流
- 在测试中先写调用(Red 阶段)。
- 创建函数/类型/变量(快速跳转 Green)。
- 使用插件功能快速创建实现框架。
- 编写通过测试的实现代码(Green)。
- 使用 Refactor 工具整理代码(Refactor)。
- 插件集成测试运行、输出报告。
- 提交代码(Check-in)或集成(Integrate)。
TDD(测试驱动开发)中编写测试的关键习惯与模式,这套习惯可以帮助开发者高效地、系统性地进行测试驱动的开发。下面是逐条的详细解释与理解:
TDD 编写测试的习惯 / 模式 理解
1. Isolated Tests(隔离的测试)
- 含义:每个测试都是完全独立的,不会依赖其他测试的运行顺序、结果或副作用。
- 目的:避免“幽灵问题”,提升测试的可复现性与可维护性。
- 实践方式:
- 每个测试自己准备测试数据(Arrange)。
- 不共享状态、不用全局变量。
- 不在一个测试中设置另一个测试需要的状态。
2. Test List(测试列表)
- 含义:维护一个待编写的测试用例列表,作为开发导航和提醒。
- 目的:
- 避免遗漏测试。
- 明确开发目标,逐步推进。
- 实践方式:
- 在笔记、注释、卡片或测试文件中列出:
// TODO: // - test adding two numbers // - test subtracting // - test division by zero
- 一次只处理一个测试,按优先级推进。
- 在笔记、注释、卡片或测试文件中列出:
3. Only Implement One Failing Test at a Time(一次只处理一个失败测试)
- 含义:在编写代码前,只创建一个新的失败测试,解决它之后再处理下一个。
- 目的:
- 保持焦点,降低认知负担。
- 明确每个步骤的目标。
- 避免同时处理多个问题引入混乱。
- 反面例子:写 5 个测试,只有最后一个失败,导致你不知道哪里出错。
4. Test First(先写测试)
- 含义:先写测试,再写实现代码(即 TDD 的本质)。
- 目的:
- 以“使用方式”驱动设计。
- 确保代码是可测试、可验证的。
- 实践方式:
- 从调用角度出发:我要怎么用它?
- 再根据调用写出最小实现代码。
5. Assert First(先写断言)
- 含义:写测试时,先写断言(Assert),再补齐测试动作(Act)和准备数据(Arrange)。
- 目的:
- 聚焦在“你想要什么结果”,即行为期望。
- 避免写过多无效代码。
- 实践方式:
- 开始直接写:
ASSERT_EQUAL(4, add(2, 2));
- 然后再去定义
add()
,传参,做准备工作。
- 开始直接写:
- 这个做法能确保测试有价值,避免“没有断言的测试”。
小结:写测试的 TDD 习惯一览
习惯/模式 | 核心价值 |
---|---|
Isolated Tests | 保证测试稳定、可靠 |
Test List | 明确目标,防止遗漏 |
One Test at a Time | 降低复杂度,便于定位问题 |
Test First | 以行为驱动开发,避免过度设计 |
Assert First | 聚焦预期结果,避免无效测试 |
如果你正在学习 TDD,可以从 Test List 和 Assert First 开始练习,这两个习惯非常容易上手,也最能提高你写测试的质量。 |
TDD(测试驱动开发)循环的简明概括,来自 Peter Sommerlad 对 TDD 流程的图示与讲解。以下是对每一阶段的详细解释与背后的思维方式,帮助你深刻理解并实践 TDD 的节奏和习惯。
TDD Cycle 理解(Peter Sommerlad)
🔴 RED — 写一个失败的测试(Make a failing test)
- 目标:确认你正在实现的功能还不存在。
- 行动:
- 写一个测试用例,它会失败,因为功能尚未实现。
- 这个失败是预期中的失败,说明你写的测试是有效的。
- 为什么重要:
- 测试驱动的是需求:你是在根据功能如何被使用来驱动设计。
- 如果测试一开始就通过,说明你的测试可能不正确或冗余。
🟢 GREEN — 使测试通过(Make a change to pass the test)
- 目标:以最简单的方式让测试通过。
- 行动:
- 编写实现代码,刚好满足测试断言的预期。
- 这一步可以是最粗糙的实现,重点在于通过测试。
- 为什么重要:
- 推动开发向前,避免过度设计。
- 提供一个可工作的基础,后续可逐步改进。
🟡 REFACTOR — 重构设计(Make the design simpler)
- 目标:改进代码结构,但不改变行为。
- 行动:
- 清理重复、命名优化、职责分离等。
- 测试仍应全部通过,确保行为未变。
- 为什么重要:
- 保持代码健康、清晰,便于未来扩展。
- 避免技术债累积。
补充阶段(Peter Sommerlad 版本)
Integrate — 集成(Make the test and change permanent)
- 目标:把你的测试和实现变更合并到主代码库。
- 行动:
- 运行所有测试,确保无破坏。
- 通过版本控制系统(如 Git)进行提交。
- 为什么重要:
- 保证所有人都能获得功能和测试的最新版本。
- 保持主干代码稳定可靠。
自动支持(This is the part I’d like to show automatic support today)
这句话指的是:
在 RED → GREEN → REFACTOR 流程中,IDE/工具可以支持你自动生成函数、类型、移动代码、运行测试等,大大提升开发效率。
总结:TDD 的节奏(Red → Green → Refactor → Integrate)
阶段 | 关键行为 | 工具支持示例 |
---|---|---|
RED | 写一个失败的测试 | 测试模板生成、测试运行器 |
GREEN | 编码让测试通过 | 快速生成函数/类型/变量定义 |
REFACTOR | 清理代码,不改变行为 | 自动重构、函数移动、命名助手 |
INTEGRATE | 所有测试通过后提交代码 | Git 提交、CI 检查、持续集成 |
这种开发方式确保每一行生产代码都是因一个测试存在而被写出来的,而每个测试又验证了一个真实的需求或行为。 |
以下是你提供的内容(关于 Refactoring(重构))的中文解释和理解,结合了最佳实践与实际意义,适合学习和实践 TDD(测试驱动开发)过程中使用。
什么是 重构(Refactoring)?
重构是指在不改变代码行为的前提下,对代码的内部结构进行优化和改进的过程。
简单说就是: “让代码变得更好,但不改变它的功能。”
逐条理解你列出的要点:
持续的清理(Ongoing Cleaning)
- 重构不是一次性的行为,而是开发中的持续习惯。
- 就像每天整理桌子,而不是堆积几个月才大扫除。
- 保持代码清爽整洁,方便长期演进。
“整洁代码”(Clean Code)
- 重构让代码逐步靠近 整洁代码 的标准:
- 好的命名
- 简单清晰的函数
- 单一职责的类
- 没有重复
- 可读性强
- 最终目的是让代码对人类友好,而不只是对机器有效。
保障长期质量(Assure Long-term Quality)
- 软件常常被使用得比最初计划更久。
- 重构让代码在几年后仍然容易读、改、测。
- 避免“写起来简单、维护起来地狱”的局面。
找到更好的设计(Find Better Design)
- 随着对项目理解加深,会看到更合适的设计思路。
- 重构就是将这种更优的设计逐步落地。
- 不断优化:从“能跑”到“能扩展”。
可理解性 — 可居住的代码(Understandability — Habitable Code)
- 好代码应该像“家”一样,让开发者感觉舒服、容易理解。
- “可居住”意味着:
- 新人容易入门
- 你过几个月回来看也不会头疼
- 团队协作更高效
消除重复(Remove Duplication)
- 重复是代码中的“毒瘤”,会让修改变得痛苦且容易出错。
- 重构的一个核心目标就是识别并抽取重复:
- 重复逻辑 → 提取为函数
- 重复结构 → 提炼为类或模板
可维护性(Maintainability)
- 重构后的代码更容易修改,也更不容易出错。
- 维护成本降低,扩展更轻松。
- 在 TDD 中,重构后的代码依然由测试覆盖,所以更安全。
重构 ≠ 改功能!
重构是 | 重构不是 |
---|---|
改善内部结构 | 加功能 |
保持功能不变 | 优化性能(不是目标) |
提升可读性与可维护性 | 写新业务逻辑 |
小步、可验证、低风险的变化 | 重写或大改 |
常见的重构技巧(以 C++ 或通用语言为例)
技巧 | 作用 |
---|---|
提取函数(Extract Function) | 将重复或复杂的代码提取为函数 |
重命名(Rename Variable) | 改为更具意义的变量名 |
内联变量(Inline Variable) | 去掉多余的中间变量 |
移动方法(Move Method) | 把方法放到更合适的类中 |
替换魔法数字 | 用具名常量替代裸数字 |
用多态替代条件分支 | 减少 if/else 的复杂度 |
在 TDD 中的作用:
TDD 强调 Red → Green → Refactor,其中:
- Red:写一个失败的测试
- Green:写实现让测试通过
- Refactor:清理结构,提升代码质量
因为测试保护了行为,所以你可以放心大胆地重构,保证功能不会意外被破坏。
TDD 中遇到“红条”失败测试时的一些写测试的习惯和模式(red bar patterns),以及如何有策略地选择和编写测试。以下是详细中文理解和解释:
TDD “红条”习惯/模式(Red Bar Patterns)理解
1. 如何找到需要写的测试?
这其实是 TDD 里一个很重要的问题:
我接下来要写哪个测试?
有了合适的策略,可以让你高效且有条理地推进测试编写,避免写一堆没用或者难以维护的测试。
2. One Step Test(一步步测试)
- 目标:用测试一步步解决开发任务。
- 解释:
- 不要积攒一个大堆“待写测试”代码。
- 维护一个简单的“测试清单”(test list)。
- 每次只选择清单里最简单、最容易实现的测试写。
- 好处:
- 保持专注。
- 避免被复杂任务吓倒。
- 让开发节奏稳健且可控。
3. Starter Test(起步测试)
- 目标:从最小的测试开始,比如“空列表”测试。
- 解释:
- 先写覆盖最简单场景的测试。
- 随着代码增长,逐步重构和完善。
- 好处:
- 给代码一个干净的起点。
- 逐步扩展功能,避免一开始写复杂的边界情况。
4. Explanation Test(解释性测试)
- 目标:通过写测试来讨论设计。
- 解释:
- 用测试表达你对功能和设计的理解。
- 这个测试不仅验证代码,也像“设计文档”。
- 好处:
- 测试文档化设计意图。
- 让团队成员通过测试理解设计。
5. Learning Test(学习测试)
- 目标:通过写测试来理解已有代码或第三方 API。
- 解释:
- 当面对遗留代码或不熟悉的库时,写测试探索其行为。
- 好处:
- 发现代码的边界和异常情况。
- 提高代码使用安全感。
6. 特别重要点
当你面对没有良好单元测试(没有 GUTs)的遗留代码库时,上述测试模式非常关键。
总结
模式名称 | 作用与目标 |
---|---|
One Step Test | 一步步写测试,稳步完成开发任务 |
Starter Test | 从最简单的测试起步,逐步增长代码 |
Explanation Test | 用测试表达设计想法,辅助团队沟通 |
Learning Test | 通过测试了解和摸透已有代码和API |
这些模式帮你在 TDD 过程中更有条理地选择和书写测试,尤其是当面对复杂或遗留代码时,能让你循序渐进地掌握代码质量。 |
TDD 中的 Green Bar Patterns,也就是当测试变成绿色(通过)时,让测试通过的各种实用技巧和习惯。以下是它们的中文理解和详细说明:
TDD Green Bar Patterns 理解(让测试通过的习惯/模式)
1. Fake It ('Til You Make It) — “先假装,后完善”
- 解释:
当遇到实现困难时,可以先用“假代码”或“临时方案”让测试通过。 - 关键:
这种“hack”只是临时的,必须尽快重构为真正合理的解决方案。 - 目的:
快速推动开发进度,避免卡壳。
2. Triangulate — “三角测量”
- 解释:
为了找到一个合适的抽象(设计),先针对两个不同的例子写代码。 - 步骤:
- 实现第一个例子
- 实现第二个例子
- 观察两者的异同,提炼出更好的共同抽象(重构)
- 目的:
通过对比实例,避免过早做出不合适的设计。
3. Obvious Implementation — “简单直观实现”
- 解释:
当问题简单时,不要过度设计,直接写出最明显、直接的代码让测试通过。 - 目的:
保持代码简单,不做无谓的复杂化。
4. One to Many (or zero, one, many) — “先处理单个元素”
- 解释:
如果函数需要处理多个元素,先确保它能正确处理一个元素或空情况。 - 目的:
逐步扩展功能,降低复杂度,减少潜在的bug。
5. Regression Test — “回归测试”
- 解释:
每当遇到一个 bug,先写一个测试重现该 bug。 - 目的:
通过测试确保 bug 被修复且不会再出现。
6. Break — “适当休息”
- 解释:
编码过程中要有合理的休息,比如喝水,放松,避免疲劳。 - 目的:
保持头脑清醒,提升效率和代码质量。
7. Do Over — “重新开始”
- 解释:
如果卡住了,别怕删掉代码,重写一遍。 - 目的:
有时候重来一次比一直改错代码更有效。
总结:
模式名称 | 作用/建议 |
---|---|
Fake It | 先用简单“假”代码让测试通过,后重构 |
Triangulate | 通过两个例子寻找合适抽象 |
Obvious Implementation | 简单问题写最直接实现 |
One to Many | 先保证处理单个或零个元素正确 |
Regression Test | 每个 bug 都写对应的回归测试 |
Break | 编码中要合理休息 |
Do Over | 卡住时勇于删代码重写 |
这些模式帮助你快速且稳健地推进测试通过,保证 TDD 循环有效进行。 |
你这段内容是一个用 TDD(测试驱动开发)演示简单表达式求值器的例子,结合了测试先行和需求逐步发现的思想。让我帮你梳理并理解它的核心点:
TDD Demo 简单算数表达式求值器理解
1. 表达式示例:
计算 (3+4)*6
得到结果 42
。
2. 目标:
- 实现一个简单的表达式求值器(Expression Evaluator),支持基础算术表达式。
- 用 测试优先开发(Test-First Development)方法,在写代码前先写测试。
- 用 CUTE(一个 C++ 单元测试框架)做测试支持。
3. 增量式需求发现(Incremental Requirements Discovery)
- 需求不是一开始就完整确定,而是在开发过程中通过测试一步步发现和明确。
- 你会从简单的输入开始,逐步增加复杂度。
4. 测试列表(The List for Eval V0)
输入字符串 | 期望结果 / 备注 |
---|---|
"" | 报错(error) |
"0" | 0 |
"2" | 2 |
"1+1" | 2 |
5. 理解的过程:
- 先写测试:
先写针对空字符串的测试,期望报错。 - 代码实现使空字符串报错,测试通过。
- 写针对单数字
"0"
,"2"
的测试,代码实现能正确返回数字值。 - 写针对简单加法
"1+1"
的测试,代码实现解析加法。 - 持续通过测试推动功能增长,直到支持
(3+4)*6
这样更复杂的表达式。
6. 总结:
- 这是一个典型的 测试驱动开发演示案例。
- 通过小步快跑的测试驱动,一点一点确认需求和功能。
- 依赖测试保证每次代码改动后功能依然正确。
经典的 测试用例四阶段模式(Four Phase Test Case),这是写单元测试时的标准结构,帮助测试更清晰、规范。下面给你详细中文理解:
四阶段测试用例 (Four Phase Test Case)
- Setup(准备阶段)
- 准备测试环境,初始化数据和对象。
- 通常包括创建 SUT(被测试系统)和依赖组件(DOC,Depended On Component)。
- Exercise(执行阶段)
- 调用 SUT 的功能,触发待测试的行为。
- Verify(验证阶段)
- 断言结果,检查输出是否符合预期。
- 确认系统状态是否正确。
- Teardown(清理阶段)
- 释放资源,清理环境,确保不影响后续测试。
术语解释:
- SUT (System Under Test)
- 当前正在测试的“主体”,即功能模块或代码单元。
- DOC (Depended On Component)
- 被 SUT 依赖的组件,如数据库、外部服务、子模块等。
- 测试时有时需要对 DOC 做模拟(mock),确保测试聚焦在 SUT。
为什么要用四阶段?
- 结构清晰:让测试步骤分明,易于理解和维护。
- 保证隔离:每个测试独立,防止相互影响。
- 便于调试:出现问题时,能快速定位是哪一步出错。
Seams(缝隙)和 Mocks(模拟对象),这是在对遗留代码进行单元测试时非常重要的概念和技术。下面帮你梳理和理解这些关键点:
Seams(缝隙)
- 定义:缝隙是代码中可以用来插入测试替代品(如模拟对象mock)的位置,或改变代码行为而无需修改原始代码的点。
- 在 Legacy Code(遗留代码) 测试中,利用缝隙是关键技巧,因为遗留代码往往设计不易直接测试。
C++ 中常见的缝隙类型
- Object Seam(对象缝隙)
- 通过替换对象实例(如依赖注入)来替换依赖。
- 例如用 mock 对象替代真实依赖。
- Compile Seam(编译缝隙)
- 利用条件编译、宏等预处理器指令切换不同实现。
- 编译时替换依赖。
- Link Seam(链接缝隙)
- 通过链接不同的实现文件达到替换代码的目的。
- 在链接阶段替换函数实现。
- Preprocessor Seam(预处理器缝隙)
- 通过宏定义和条件编译插入或屏蔽代码。
Mockator
- Mockator 是一个辅助工具(或框架),帮助生成 Mock Objects(模拟对象)。
- 它简化了在 C++ 中创建测试替身(test doubles)的工作,方便单元测试。
相关技术与步骤
- Refactoring enabling Unit Testing(通过重构启用单元测试)
- 通过重构代码,增加缝隙,方便插入测试替身,从而使遗留代码可以单元测试。
- Test Double generation(生成测试替身)
- 包括 Mock Objects、Stub、Fake 等,替代真实依赖,控制测试环境。
- Mock Object generation(模拟对象生成)
- 自动或半自动生成模拟类,方便测试断言调用次数、参数等。
- Function Tracer generation(函数跟踪生成)
- 生成可以记录函数调用信息的代码,帮助调试和验证测试行为。
总结
- 在测试遗留 C++ 代码时,利用 Seams 是关键。
- 通过工具(如 Mockator)自动生成模拟对象,可以大幅简化测试准备。
- 重构也是不可或缺的辅助手段,用来创建缝隙和提升测试可行性。
Mock 对象的原则和使用原因,非常重要,尤其是在单元测试和 TDD 里,帮助我们隔离测试焦点。下面帮你详细梳理理解:
Mock对象的原则(Principle of Mock Objects)
1. SUT(被测系统)依赖 DOC(依赖组件)
- SUT(System Under Test) 是你当前测试的代码单元。
- DOC(Depended On Component) 是 SUT 依赖的外部组件或模块。
2. 为什么要用 Mock 对象替代 DOC?
- 真实 DOC 可能还不存在
- 比如开发早期,某个模块还没写完,但你需要先测试依赖它的代码。
- 真实 DOC 行为不可控
- 例如网络请求、数据库连接,这些行为受外部环境影响,难以预测。
- 需要测试 DOC 的异常行为,难以触发
- 比如异常抛出、错误返回码,真实环境中不容易或无法模拟这些情况。
- 真实 DOC 使用成本高,耗时长
- 调用真实服务可能很慢,影响测试效率。
- 关注点在于 SUT,排除 DOC 的问题
- 想定位 SUT 代码本身的问题,不希望被依赖组件的异常或错误干扰。
- 验证 SUT 对 DOC 的使用是否正确
- 确认 SUT 按预期调用了 DOC 的接口,参数正确,调用次数合适等。
总结
Mock 对象的核心就是用“假”替代“真”,让测试更快、更稳定、更专注于被测代码。
为什么需要使用 Mock 对象(Mock Objects)的原因,这些理由在单元测试设计和实践中非常关键。下面帮你用中文总结理解:
为什么需要 Mock 对象?
1. 简化测试和设计(Simpler Tests and Design)
- 特别是针对外部依赖(数据库、网络服务等),用 Mock 让测试更简单。
- 鼓励接口导向设计(Interface-Oriented Design),让模块间耦合更松散,设计更灵活。
2. 单元的独立测试(Independent Testing of Single Units)
- 测试时只关注一个单元(Unit),避免依赖其他模块。
- 适用于依赖模块还没实现的情况,也能提前开始测试。
3. 提高测试速度(Speed of Tests)
- 避免真实调用外部系统(如数据库、网络),测试运行更快更稳定。
4. 检查第三方组件的使用(Check Usage of Third Component)
- 验证单元是否正确调用依赖组件的接口,参数和调用顺序是否符合预期。
5. 测试异常行为(Test Exceptional Behavior)
- 模拟难以触发的异常或边界情况,提高测试覆盖率和鲁棒性。
总结
Mock 对象帮助你写出简单、快速、独立且健壮的单元测试,是实现高质量代码的重要工具。
需要我帮你举个具体的 Mock 对象使用案例或者讲讲怎么写 Mock 吗?
Dave Astels 对 Mock 对象的分类,这个分类在理解测试替身(Test Doubles)时非常经典。帮你详细中文理解:
Mock 对象的类型(Types of Mock Objects)
1. Stub(桩)
- 作用:替代“昂贵的”或“不可预测”的类。
- 特点:返回固定的、硬编码的值,不关心调用细节。
- 用途:比如数据库查询返回固定数据,避免每次都访问真实数据库。
2. Fake(假件)
- 作用:替代尚未实现的类。
- 特点:通常是一个简化版的真实实现,能够正常工作但不完整或性能较差。
- 用途:比如内存中的简易数据库实现,替代真实数据库。
3. Mock(模拟对象)
- 作用:替代真实对象,具有额外功能。
- 特点:记录函数调用(如调用次数、参数),根据不同调用返回不同结果。
- 用途:不仅模拟行为,还能验证被测试代码是否正确调用了依赖。
总结:
类型 | 主要功能 | 特点 | 使用场景 |
---|---|---|---|
Stub | 返回固定值 | 简单,固定结果,不验证调用细节 | 代替复杂/慢的组件,提供稳定数据 |
Fake | 简化的真实实现 | 部分功能可用,性能较低 | 尚未实现的组件替代,便于开发测试 |
Mock | 记录调用并控制返回值 | 可以验证调用情况和顺序 | 验证交互和调用细节,行为驱动测试 |
Seams(缝隙) 来实现被测系统(SUT)与依赖组件(DOC)的解耦,这对于单元测试非常重要。帮你详细中文理解:
Seams(缝隙)——解耦 SUT 与 DOC 的方法
为什么要引入 Seam?
- 目的:让依赖组件(DOC)可以替换,方便用测试替身(Mock、Stub)替换真实组件。
- 这样测试时能控制依赖行为,保证测试独立和可控。
C++ 中常用的 Seam 类型
1. Object Seam(对象缝隙,经典面向对象方式)
- 做法:引入接口(Interface),让 SUT 不直接依赖具体实现的 DOC。
- SUT 通过接口调用 DOC,实现多态。
- 测试时传入测试替身(Test Double)实现该接口。
- 缺点:引入虚函数调用(virtual function)开销。
- 示例:构造函数或方法参数传入接口指针。
2. Compile Seam(编译时缝隙,模板方式)
- 做法:利用 C++ 模板,将 DOC 作为模板参数。
- 默认模板参数可以是实际实现,测试时传入 Mock 类型。
- 这样编译时就决定依赖,避免运行时开销。
- 优点:无虚函数开销,灵活切换实现。
- 缺点:模板代码可能更复杂,编译时间变长。
3. 传递依赖作为参数(Dependency Injection)
- 做法:通过构造函数或方法参数传入依赖对象。
- 测试时传入 Mock 对象,生产环境传入真实对象。
- 是 Object Seam 的一种实现形式。
总结:
Seam 类型 | 实现方式 | 优缺点 | 适用场景 |
---|---|---|---|
Object Seam | 接口 + 虚函数调用 | 简单明了,有运行时开销 | 经典 OO 设计 |
Compile Seam | 模板参数替换 | 无虚函数开销,灵活但编译复杂 | 高性能场景,模板熟悉者 |
参数传递(DI) | 构造函数或方法注入依赖对象 | 灵活,易于测试 | 推荐常用的依赖注入方式 |
这样设计后,测试代码可以方便地替换依赖,进行隔离和控制。 |
如何用 Object Seam(对象缝隙) 来实现经典的基于继承的 Mock 机制,下面帮你总结一下核心内容:
引入 Object Seam(对象缝隙)
核心步骤:
- 提取接口
- 将被依赖组件(DOC)提取为接口,比如叫
IDOC
。 IDOC
定义 DOC 的公共行为(虚函数声明)。
- 将被依赖组件(DOC)提取为接口,比如叫
- 修改 SUT
- 让 SUT 不直接依赖具体的 DOC,而是依赖
IDOC
接口。 - SUT 中对 DOC 的使用通过接口指针或引用完成。
- 让 SUT 不直接依赖具体的 DOC,而是依赖
- 创建 Mock 实现
- 编写一个 Mock 类,实现
IDOC
接口,模拟 DOC 行为。 - Mock 类可记录调用情况、返回测试所需的结果。
- 编写一个 Mock 类,实现
- 单元测试(UT)中使用 Mock
- 在测试中用 Mock 替代真实的 DOC。
- 通过注入 Mock 对象,控制测试行为和断言调用细节。
结构关系图(文字版)
SUT ---依赖--> IDOC (接口)↑实现:/ \DOC MOCK
关键点:
- 面向接口编程,解耦依赖关系。
- 使用虚函数实现多态,使得 Mock 对象能替代真实对象。
- C++ 中引入虚函数会有一定性能开销(虚函数调用开销)。
用 Compile Seam(编译时缝隙),通过 C++ 模板参数实现依赖注入和 Mock。帮你整理下:
引入 Compile Seam(编译时缝隙)
核心思想
- 使用 C++ 模板参数 来替代传统的虚函数接口依赖。
- 将依赖组件(DOC)作为模板参数传入 SUT,SUT 中直接调用 DOC 的接口。
- 默认模板参数是生产环境的真实 DOC,实现默认行为。
- 测试时,用 Mock 类型替换模板参数,达到替换依赖的目的。
优点
- 无虚函数开销,调用直接静态绑定,性能更好。
- 灵活切换依赖,通过编译时决定使用哪个实现。
- 代码更简洁,避免了虚函数和继承的复杂性。
缺点
- 模板代码可能增加编译时间和复杂度。
- 对 C++ 模板语法要求较高。
结构示意(文字版)
template<typename DOC = RealDOC>
class SUT {DOC doc; // 依赖对象void doSomething() {doc.action(); // 调用依赖}
};
// 测试时用 MockDOC 替代
SUT<MockDOC> sut_with_mock;
Refactoring
- 把原来硬编码的 DOC 替换为模板参数。
- 调整 SUT 代码适应模板调用。
C/C++ 里的两种特别的 Seams(缝隙):Link Seam 和 Preprocessor Seam,它们的最大优点是不改动被测代码(SUT),方便测试替代依赖。帮你总结一下:
C/C++ 特有的 Seams
1. Link Seam(链接缝隙)
- 特点:不修改 SUT 代码,直接通过链接阶段替换依赖实现。
- 原理:在链接时,将真实依赖函数替换成测试用的替代实现。
- 用法:
- 提供一个替代实现(Mock 函数),放在链接顺序靠前。
- 利用 GNU 链接器
--wrap
选项,可以包装原函数,还能调用原始实现。 - 动态库中通过
dlopen
、dlsym
以及设置LD_PRELOAD
环境变量实现替换。
- 适用场景:替换系统函数(
rand()
,time()
),耗时调用,非确定性函数等。 - 挑战:C++ 的名字修饰(name mangling)复杂,手工替换麻烦。
- 解决方案:使用工具如 Mockator 自动生成封装代码。
2. Preprocessor Seam(预处理器缝隙)
- 特点:通过
#define
宏替换函数调用,来替换依赖。 - 优点:不修改原函数调用代码,替换快速直接。
- 缺点:
- 代码可读性差,调试困难。
- 容易引入隐藏错误。
- 宏替换范围难以控制,带来潜在风险。
- 建议:仅作为最后手段,其他方式不行时使用。
总结
Seam 类型 | 优点 | 缺点/挑战 | 适用场景 |
---|---|---|---|
Link Seam | 不改动 SUT,链接时替换,支持包装 | C++ 名字修饰复杂,需工具辅助 | 替换系统库函数、不可控依赖 |
Preprocessor Seam | 快速替换,操作简单 | 宏引发难查错误,影响代码质量 | 最后手段,难以重构代码时使用 |
- 当代码依赖无法使用或难以控制的组件时,使用 Test Double(比如 Mock 或 Stub)替代依赖,可以独立验证被测系统(SUT)的逻辑。
- 通过配置测试替身模拟依赖的预期行为,实现测试隔离。
- 为避免测试过慢,优化 Setup 和 Teardown 阶段,减少耗时操作,复用测试夹具(fixtures),并行运行测试。
- Test Double 也能减少对慢速外部系统的依赖,提升测试速度。
两个问题是测试设计中非常关键的,帮你总结清楚:
1. 如何独立验证逻辑,当依赖的代码不可用时?
- 使用测试替身(Test Doubles):
- 替换不可用或难以控制的依赖组件(如数据库、网络服务、硬件接口)
- 常用的测试替身有 Stub(提供固定返回)、Mock(记录调用并可验证交互)、Fake(简化实现)
- 设计可替换的接口(Seam):
- 通过接口抽象依赖,使被测系统(SUT)可以接受替代实现
- 通过依赖注入(构造函数参数、模板参数等)传入测试替身
- 控制测试替身行为:
- 模拟各种依赖响应,包含正常与异常情况,确保测试覆盖各种逻辑分支
2. 如何避免测试变慢?
- 优化 Setup 和 Teardown 阶段:
- 减少资源消耗重的初始化与清理操作
- 共享和复用测试夹具(Fixtures),避免重复构建和销毁环境
- 使用测试替身替代慢速依赖:
- 代替真实数据库、网络服务等,减少 IO 等耗时
- 并行执行测试用例:
- 充分利用多核 CPU,缩短测试总耗时
- 拆分测试用例:
- 保持单元测试简单、快速,避免一个测试覆盖太多内容
使用 Mock Object 来实现对 SUT 间接输出的行为验证,这个是单元测试中重要的概念,尤其是在行为驱动测试(Behavior Verification)中。
行为验证(Behavior Verification)是什么?
- 关注 SUT 是否正确调用了依赖对象(DOC)的方法,而非仅仅关注最终状态或返回值。
- 通过 Mock 对象记录调用情况(方法调用次数、参数、调用顺序等),然后在测试断言中验证这些调用。
如何用 Mock Object 实现行为验证?
- 创建 Mock 对象,实现依赖接口(比如 IDOC),并加入调用记录功能。
- 在测试中注入 Mock 代替真实依赖。
- 执行 SUT 操作,此时 Mock 会记录所有被调用的方法和参数。
- 断言 Mock 的调用行为,例如:
- 某个方法被调用一次
- 某个方法被调用时参数符合预期
- 调用顺序符合设计
- 测试通过 表示 SUT 正确使用了依赖,间接验证其逻辑行为。
举个简单例子(伪代码)
class MockIDOC : public IDOC {
public:bool called = false;void performAction() override {called = true; // 记录调用}
};
void testSUTCallsDOC() {MockIDOC mock;SUT sut(&mock);sut.doWork();assert(mock.called == true); // 验证行为
}
总结
- 行为验证用 Mock 记录并检查 SUT 与依赖的交互。
- 这对于验证间接输出、外部交互尤为重要。
- 通过行为验证,测试不仅保证结果正确,还保证过程正确。
理解!Test Stub 和 Mock Object 都是测试替身(Test Doubles)的一种,但它们用途和功能有明显区别。帮你总结清楚:
Test Stub(测试桩)
- 作用:提供固定的、预定义的响应,替代不可用或昂贵的依赖组件。
- 主要目的是:让测试能运行下去,不关注依赖的调用情况。
- 特点:
- 返回硬编码的数据或固定行为
- 不记录调用,也不验证交互
- 通常用来模拟简单依赖,例如返回固定值的数据库查询结果
- 示例:调用
getUserName()
,Stub 总是返回"TestUser"
。
Mock Object(模拟对象)
- 作用:模拟依赖,并且记录对它的调用,用于行为验证。
- 主要目的是:验证 SUT 是否正确调用了依赖(方法调用次数、参数、顺序等)。
- 特点:
- 记录所有被调用的方法和参数
- 允许测试断言这些调用是否符合预期
- 可模拟复杂的交互行为
- 示例:验证
sendEmail()
是否被调用一次,且参数正确。
简单对比
方面 | Test Stub | Mock Object |
---|---|---|
关注点 | 提供数据(状态驱动) | 记录行为(行为驱动) |
是否验证调用 | 否 | 是 |
是否记录调用 | 否 | 是 |
复杂度 | 简单 | 复杂 |
使用场景 | 替代依赖提供数据 | 验证依赖调用是否正确 |
面向接口的 Mock,经典的基于继承和多态的模拟方式,帮你再整理总结一下:
Interface-oriented Mock(面向接口的 Mock)
核心流程:
- 提取接口
- 把被依赖的具体类(DOC)抽象成接口(
IDOC
),只定义虚函数。
- 把被依赖的具体类(DOC)抽象成接口(
- 修改 SUT
- SUT 不再直接依赖具体的 DOC,而是依赖接口
IDOC
。
- SUT 不再直接依赖具体的 DOC,而是依赖接口
- 创建 Mock
- 编写 Mock 类,继承
IDOC
,实现虚函数,模拟 DOC 行为。 - Mock 可以记录方法调用、参数、返回测试用的结果。
- 编写 Mock 类,继承
- 单元测试中使用 Mock
- 在测试代码(UT)里,注入 Mock 替代真实 DOC,实现行为验证。
代码结构(示意):
class IDOC {
public:virtual void operation() = 0;virtual ~IDOC() {}
};
class DOC : public IDOC {
public:void operation() override {// 真实实现}
};
class MockDOC : public IDOC {
public:bool called = false;void operation() override {called = true; // 记录调用}
};
class SUT {IDOC& doc;
public:SUT(IDOC& d) : doc(d) {}void doSomething() {doc.operation();}
};
关键点:
- 优点
- 明确接口,解耦合
- 方便用 Mock 替换依赖,实现行为验证
- 缺点
- C++ 中使用虚函数带来运行时开销
- 增加设计复杂度,需要定义接口
你这个例子是经典的 TDD + Mock 演示流程,帮你总结并理清关键点:
例子背景
- 有个简单的游戏
GameFourWins
,掷骰子,掷出4就赢,否则输。 - 现在想对
GameFourWins
进行测试,但它依赖Die
,而Die::roll()
使用了rand()
,不确定且难测。
现状问题
Die::roll()
不可控,测试结果随机,不好断言输出。- 直接用
cout
打印也不方便测试输出内容。
解决方案步骤
1. 把输出重定向到可控流(如 std::ostringstream
)
void play(std::ostream &os = std::cout);
- 这样能捕获输出内容,方便在测试中验证。
2. 测试示例
void testGame() {GameFourWins game;std::ostringstream os;game.play(os);ASSERT_EQUAL("You lost!\n", os.str()); // 断言输出
}
- 但这仍然不理想,因为
die.roll()
结果随机,测试不稳定。
3. 引入 Mock(模拟)接口
struct DieInterface {virtual ~DieInterface() {}virtual int roll() = 0;
};
struct Die : DieInterface {int roll() override { return rand() % 6 + 1; }
};
DieInterface
让我们能传入模拟的骰子实现。
4. 修改 GameFourWins
使用接口依赖注入
class GameFourWins {DieInterface ¨
public:GameFourWins(DieInterface &d) : die(d) {}void play(std::ostream &os = std::cout) {if (die.roll() == 4)os << "You won!\n";elseos << "You lost!\n";}
};
5. 编写 Mock Die,用于控制测试行为
struct MockDie : DieInterface {int fixedRoll;MockDie(int roll) : fixedRoll(roll) {}int roll() override { return fixedRoll; }
};
6. 单元测试
void testWin() {MockDie die(4); // 模拟掷出4点GameFourWins game(die);std::ostringstream os;game.play(os);ASSERT_EQUAL("You won!\n", os.str());
}
void testLose() {MockDie die(2); // 模拟掷出2点GameFourWins game(die);std::ostringstream os;game.play(os);ASSERT_EQUAL("You lost!\n", os.str());
}
总结
- 通过接口抽象和依赖注入,实现对不可控代码(
Die::roll()
)的替换。 - 通过 Mock 让测试变得可预测、可验证。
- 输出重定向到流,方便断言输出内容。
- 这是 TDD 与 Mock 结合的经典案例。
1. Simulation Mock with Reference
class GameFourWins {DieInterface ¨
public:GameFourWins(DieInterface &theDie) : die(theDie) {}void play(std::ostream &os = std::cout);
};
- 必须保证
theDie
对象的生命周期长于GameFourWins
对象,否则引用会悬空。 - 测试时用具体的 Mock 实现替代
DieInterface
,例如:
struct MockWinningDice : DieInterface {int roll() override { return 4; }
};
void testWinningGame() {MockWinningDice d;GameFourWins game(d);std::ostringstream os;game.play(os);ASSERT_EQUAL("You won!\n", os.str());
}
2. 使用指针代替引用的区别
- 引用:必须在构造时初始化且不可变,语义更明确,生命周期管理更严谨。
- 指针:可以为空(nullptr),可以后期赋值,生命周期管理更灵活但风险也更大。
- 如果用指针,
GameFourWins
构造时接收DieInterface*
,需要注意指针非空及生命周期。
示例:
class GameFourWins {DieInterface *die;
public:GameFourWins(DieInterface *theDie) : die(theDie) {}void play(std::ostream &os = std::cout) {if(die && die->roll() == 4) {os << "You won!\n";} else {os << "You lost!\n";}}
};
3. 模板版实现(Template-based Mocking)
template <typename Dice = Die>
class GameFourWinsT {Dice die;
public:void play(std::ostream &os = std::cout) {if (die.roll() == 4) {os << "You won!\n";} else {os << "You lost!\n";}}
};
typedef GameFourWinsT<Die> GameFourWins;
- 优势:
- 无需虚函数(无虚调用开销)
- 无需额外的接口抽象,代码更简洁
- 模板在编译期决定具体类型,性能优
- 缺点:
- 可能出现编译/链接复杂度(inline/export问题)
- 编译时间稍长,代码膨胀
总结
特点 | 引用(Reference) | 指针(Pointer) | 模板(Template) |
---|---|---|---|
生命周期管理 | 必须保证引用对象存活 | 需手动保证指针有效 | 成员对象自动管理 |
可变性 | 不可重新赋值 | 可重新赋值、可为空 | 编译时确定类型 |
性能 | 有虚调用开销 | 有虚调用开销 | 无虚调用,编译时静态绑定 |
设计复杂度 | 中 | 中 | 相对简单,但模板相关复杂 |
适合场景 | 明确依赖且生命周期管理好 | 需要更灵活生命周期管理 | 性能敏感且模板可接受场景 |
介绍了用模板参数实现 Mock,优势和进阶用法,我帮你整理和补充一下:
1. 模板参数 Mock 基本用法
struct MockWinningDice {int roll() { return 4; }
};
void testWinningGame() {GameFourWins<MockWinningDice> game;std::ostringstream os;game.play(os);ASSERT_EQUAL("You won!\n", os.str());
}
- 直接用模板参数替代依赖,无虚函数开销,测试简单直接。
- 不用传 Mock 对象,
GameFourWins
内部直接构造MockWinningDice
。
2. 是否也要对 ostream
做类似 Mock?
std::ostringstream
本身就是 Mock 类型的输出流,用于捕获输出内容,很方便用来断言。- 不用特意再写一个
ostream
的 Mock,除非你需要更复杂的行为验证(比如验证输出的调用顺序、次数等)。 - 通常直接用
std::ostringstream
就足够了。
3. 调用计数(Call Tracing Mock)
你想知道骰子 roll()
被调用了多少次,可以这样写:
struct MockWinningDice {int rollCounter;MockWinningDice() : rollCounter(0) {}int roll() {++rollCounter;return 4;}
};
void testWinningGame() {MockWinningDice d;GameFourWins<MockWinningDice> game;std::ostringstream os;game.play(os);ASSERT_EQUAL("You won!\n", os.str());ASSERT_EQUAL(1, d.rollCounter);game.play(os);ASSERT_EQUAL(2, d.rollCounter);
}
- 这里的难点是
GameFourWins<MockWinningDice>
内部构造了自己的MockWinningDice
,和d
不是同一个对象。 - 解决方案是改成成员注入或模板传对象实例,或者修改设计让 Mock 对象可共享。
4. 模板 Mock 的优势
- 无需虚函数,性能好
- 无需抽象接口,减少代码
- 静态类型检查,安全
- 编译期确定类型,无运行时开销
5. 缺点和限制
- 不能给模板成员函数指定默认模板参数(目前)
- 可能遇到内联/导出(inline/export)相关链接问题
- 调用计数等复杂 Mock 功能有设计难度,需要调整架构支持注入 Mock 实例
6. Mockator 插件支持
- 可以自动帮你把代码重构成模板参数形式
- 自动生成 Mock 类,支持依赖注入和调用次数校验(C++11 特性)
- 方便实现模板 Mock 和接口 Mock 混合使用
总结
方案 | 优势 | 注意点 |
---|---|---|
模板参数 Mock | 无虚函数开销,简单高效 | 调用计数共享对象需架构支持 |
std::ostringstream 用作 Mock ostream | 简单,断言输出方便 | 不适合复杂调用行为验证 |
传统接口 + Mock 对象 | 灵活,支持复杂 Mock 行为 | 有虚函数开销,写代码较多 |
Mockator 使用预处理器 Seam和链接 Seam技术来实现对系统函数的 Mock 和跟踪,做法和关键点我帮你总结如下:
Mockator - 预处理器 Seam(Preprocessor Seam)
- 目标:通过宏替换系统函数(如 malloc)调用,实现对调用的拦截和跟踪。
- 做法:
- 写一个头文件(如
mockator_malloc.h
),里面用#define malloc(size) mockator_malloc(size, __FILE__, __LINE__)
替换原有malloc
。 - 在替换后的函数
mockator_malloc
里可以添加自己的跟踪逻辑,比如记录调用栈、文件名和行号,做调用次数统计等。 mockator_malloc.h
被 Mockator 通过 GCC 的-include
选项自动包含进编译过程,确保所有代码里的malloc
调用都被宏替换。- 在实现文件里用
#undef malloc
解除宏定义,调用真实的malloc
,保证正常分配内存。
示例:
- 写一个头文件(如
#ifndef MOCKATOR_MALLOC_H_
#define MOCKATOR_MALLOC_H_
#include <cstdlib>
int mockator_malloc(size_t size, const char *fileName, int lineNumber);
#define malloc(size) mockator_malloc((size), __FILE__, __LINE__)
#endif
#include "mockator_malloc.h"
#undef malloc
int mockator_malloc(size_t size, const char *fileName, int lineNumber) {// 这里写跟踪代码,比如日志打印、调用计数等return malloc(size);
}
Mockator - 链接 Seam(Link Seam)
- 目标:通过链接器功能,在运行时替换系统或库函数,方便插入 Mock 逻辑或监控代码。
- 实现原理:
- Linux 使用 GNU 链接器的
--wrap
选项,允许在链接时用自定义函数替换库函数调用。 - 还可以利用环境变量
LD_PRELOAD
(Linux)、DYLD_INSERT_LIBRARIES
(MacOS) 来预先加载自定义共享库,达到替换系统函数的目的。 - 这种方法不需要改动 SUT 代码,完全通过链接和运行环境替换函数。
- Linux 使用 GNU 链接器的
- 示例代码:
int foo(int i) {static void *gptr = nullptr;if (!gptr)gptr = dlsym(RTLD_NEXT, "_Z3fooi"); // 获取下一个同名函数地址(C++符号名)typedef int (*fptr)(int);fptr my_fptr = reinterpret_cast<fptr>(gptr);// 这里写你的跟踪或 Mock 代码return my_fptr(i); // 调用原始 foo
}
- Mockator 会帮你自动生成这个封装函数,创建共享库项目,设置运行环境,添加对
dl
库的依赖,极大简化了使用难度。
总结
Seam 类型 | 实现机制 | 优缺点 | 典型用途 |
---|---|---|---|
预处理器 Seam | 用宏 #define 替换函数调用 | 简单灵活,能传递调用文件行号,缺点是可能影响代码可读性 | 跟踪内存分配,替换小函数调用 |
链接 Seam | 利用链接器 wrap,或 LD_PRELOAD加载 | 无需改代码,运行时替换,跨语言更友好,但复杂度较高 | 替换库函数,监控系统调用,调试工具 |