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/ 目录,配置稍复杂一点。

我的建议:如果你的项目模块划分清晰,团队规模在 10 人以上,用内聚式。如果是小项目或者库项目,集中式更省事。

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
)
注意:千万不要把测试框架链接到生产库上。我曾经见过有人把 gtest 链接到了 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 知识体系:测试与构建系统的核心逻辑

下面这张图,是我对本章内容的一个总结。它展示了测试组织、依赖管理和构建配置三者之间的关系。

测试与构建系统核心逻辑 测试目录组织 集中式 vs 内聚式 测试分层策略 L0 / L1 / L2 依赖管理 隔离原则 / Mock 构建系统配置(CMake / Bazel / Meson) target_link_libraries / 依赖隔离 / 编译加速 测试执行与反馈 快速反馈 / CI 集成 / 增量编译 三者环环相扣:组织方式影响构建配置,构建配置决定执行效率

23.5 避坑指南:我踩过的几个雷

最后,分享几个实战中容易翻车的地方。

  • 测试代码和生产代码共用全局变量:我曾经在一个遗留项目里,测试改了全局状态,导致后续测试全挂。解决方案:每个测试用例独立初始化,或者用 SetUp / TearDown 重置状态。
  • 测试依赖了测试框架的内部头文件:比如直接 #include "gtest/internal/gtest-internal.h"。这会导致框架升级时测试编译失败。记住:只包含公开头文件。
  • 把测试数据文件放在代码目录里:测试数据应该单独管理,或者用临时文件生成。否则 git 仓库会越来越臃肿。
  • 忽略测试的编译时间:如果测试编译超过 30 秒,开发者就不愿意跑了。用 ccache 或者 ninja 的增量编译,能有效缓解这个问题。

核心总结:测试的组织和依赖管理,本质上是在「隔离」和「效率」之间找平衡。隔离得好,测试就稳定、跑得快;隔离得差,测试就脆弱、编译慢。没有银弹,但遵循本章的原则,至少能让你少踩 80% 的坑。


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