23、测试与构建系统:大型项目中如何组织测试?测试与主代码的依赖管理。
说实话,很多团队在项目初期都不太在意测试的组织方式。大家觉得「能跑就行」。但等代码量一上来,你就会发现——测试文件散落一地,编译一次要等十分钟,跑个单元测试还得链接一堆第三方库。嗯,这时候再想重构,代价就大了。
我经历过一个项目,测试代码和主代码混在一起,结果有一次重构不小心把测试用的 mock 类编译进了生产环境。那 bug 查了我整整两天。从那以后,我对测试的目录结构和依赖管理就特别较真。
这一章,我们就来聊聊大型项目中,测试到底该怎么放、怎么编译、怎么管理依赖。
23.1 测试目录的两种主流布局
先解决最基础的问题:测试文件放哪儿?
目前业界有两种主流方案,各有拥趸。我个人更倾向于第二种,但咱们都看看。
23.1.1 方案一:集中式测试目录
所有测试代码都放在一个顶层目录下,比如 tests/ 或 test/。目录结构会镜像主代码的包路径。
project/
├── src/
│ ├── module_a/
│ │ └── calculator.cpp
│ └── module_b/
│ └── parser.cpp
└── tests/
├── module_a/
│ └── calculator_test.cpp
└── module_b/
└── parser_test.cpp
优点:干净,主代码目录不会被测试文件「污染」。构建系统可以单独配置测试的编译规则。
缺点:测试文件和被测试的源文件距离远,找起来不方便。重构时容易忘记更新对应的测试。
23.1.2 方案二:内聚式测试目录
测试文件直接放在每个模块内部,通常是一个 test/ 子目录。
project/
└── src/
├── module_a/
│ ├── calculator.cpp
│ └── test/
│ └── calculator_test.cpp
└── module_b/
├── parser.cpp
└── test/
└── parser_test.cpp
优点:高内聚。你改一个模块,测试就在旁边,不容易遗漏。我现在的项目就用这种结构,团队反馈很好。
缺点:构建脚本需要处理每个模块下的 test/ 目录,配置稍复杂一点。
23.2 测试与主代码的依赖管理
这是个大坑。很多项目测试编译慢,就是因为测试代码依赖了太多不该依赖的东西。
你想想看,测试一个 StringUtils 类,结果编译时把整个网络库、数据库驱动都链接进来了。这不是杀鸡用牛刀吗?
23.2.1 依赖隔离原则
测试代码应该只依赖它真正需要的东西。核心原则就两条:
- 测试不依赖生产环境的第三方库(除非被测代码直接使用)
- 测试不依赖其他模块的内部实现(只依赖公开接口)
举个例子,假设你的 UserService 依赖 Database 类。测试时,你不应该真的去连数据库。你应该提供一个 mock 或 stub。
// 被测代码
class UserService {
Database& db_;
public:
explicit UserService(Database& db) : db_(db) {}
std::string getUserName(int id) {
return db_.query("SELECT name FROM users WHERE id=" + std::to_string(id));
}
};
// 测试代码 - 使用 mock
class MockDatabase : public Database {
public:
MOCK_METHOD(std::string, query, (const std::string&), (override));
};
TEST(UserServiceTest, ShouldReturnUserName) {
MockDatabase mockDb;
EXPECT_CALL(mockDb, query("SELECT name FROM users WHERE id=1"))
.WillOnce(Return("Alice"));
UserService service(mockDb);
EXPECT_EQ(service.getUserName(1), "Alice");
}
这样,测试 UserService 时,根本不需要链接任何数据库驱动。编译速度能快一个数量级。
23.2.2 构建系统中的依赖配置
以 CMake 为例,我习惯把测试目标单独定义,并显式指定它的依赖。
# 主库
add_library(my_lib
src/module_a/calculator.cpp
src/module_b/parser.cpp
)
# 测试可执行文件
add_executable(module_a_test
src/module_a/test/calculator_test.cpp
)
target_link_libraries(module_a_test
PRIVATE
my_lib # 只链接被测库
GTest::GTest # 测试框架
GTest::Main
)
my_lib 里,结果生产代码里到处都是 TEST 宏。那场面,简直灾难。
23.3 大型项目中的测试分层
代码多了,测试也得分层。我一般把测试分成三层:
| 层级 | 名称 | 运行速度 | 依赖范围 | 典型工具 |
|---|---|---|---|---|
| L0 | 单元测试 | 毫秒级 | 仅被测模块 | Google Test, Catch2 |
| L1 | 集成测试 | 秒级 | 2-3 个模块 | Google Test, 自定义 fixture |
| L2 | 端到端测试 | 分钟级 | 全系统 | Python + 子进程 |
在构建系统中,我会为每一层定义不同的 target。比如:
cmake --build . --target unit_tests # 只跑 L0
cmake --build . --target integration_tests # 只跑 L1
cmake --build . --target all_tests # 全跑
这样,开发时我只跑 L0,几秒钟就出结果。提交 CI 前再跑全量。效率高很多。
23.4 知识体系:测试与构建系统的核心逻辑
下面这张图,是我对本章内容的一个总结。它展示了测试组织、依赖管理和构建配置三者之间的关系。
23.5 避坑指南:我踩过的几个雷
最后,分享几个实战中容易翻车的地方。
- 测试代码和生产代码共用全局变量:我曾经在一个遗留项目里,测试改了全局状态,导致后续测试全挂。解决方案:每个测试用例独立初始化,或者用
SetUp/TearDown重置状态。 - 测试依赖了测试框架的内部头文件:比如直接
#include "gtest/internal/gtest-internal.h"。这会导致框架升级时测试编译失败。记住:只包含公开头文件。 - 把测试数据文件放在代码目录里:测试数据应该单独管理,或者用临时文件生成。否则 git 仓库会越来越臃肿。
- 忽略测试的编译时间:如果测试编译超过 30 秒,开发者就不愿意跑了。用
ccache或者ninja的增量编译,能有效缓解这个问题。
核心总结:测试的组织和依赖管理,本质上是在「隔离」和「效率」之间找平衡。隔离得好,测试就稳定、跑得快;隔离得差,测试就脆弱、编译慢。没有银弹,但遵循本章的原则,至少能让你少踩 80% 的坑。
公众号:蓝海资料掘金营,微信deep3321