28、大型项目测试组织:测试分层、目录结构与依赖管理

说实话,我见过太多项目,测试代码写得一塌糊涂。要么是几百个测试文件堆在一个目录里,要么是单元测试和集成测试混在一起,跑起来谁都不敢动。嗯,今天我们就来聊聊,怎么把测试组织得像个样子。

一、测试为什么要分层?

我个人习惯把测试分成三层:单元测试、集成测试、系统测试。你想想看,这三层解决的问题完全不同。

测试层级 测试对象 典型工具 执行频率
单元测试 单个函数/类 Google Test, Catch2 每次提交
集成测试 模块间接口 CTest + 自定义脚本 每日构建
系统测试 完整功能链路 CTest + 外部工具 发布前

我在项目中遇到过最典型的坑:有人把单元测试写成集成测试。一个测试用例里调了数据库、发了网络请求、还写了文件。结果一跑就是几分钟,根本没法在开发阶段频繁执行。说白了,单元测试就该快,毫秒级完成;集成测试可以慢一点,但也要控制在秒级。

核心原则:单元测试不依赖外部资源,集成测试可以依赖但必须可控,系统测试模拟真实环境。

二、测试目录结构怎么设计?

我推荐一种结构,用了好几年,团队反馈都不错。直接看例子:

project/
├── src/                    # 源码
│   ├── module_a/
│   └── module_b/
├── tests/                  # 测试根目录
│   ├── unit/               # 单元测试
│   │   ├── module_a/
│   │   │   ├── CMakeLists.txt
│   │   │   └── test_foo.cpp
│   │   └── module_b/
│   ├── integration/        # 集成测试
│   │   ├── test_api.cpp
│   │   └── test_db.cpp
│   ├── system/             # 系统测试
│   │   └── test_full_flow.py
│   ├── fixtures/           # 测试夹具
│   │   ├── test_data/
│   │   └── mock_servers/
│   └── CMakeLists.txt      # 顶层测试配置
└── CMakeLists.txt

为什么要这么分?我解释一下:

  • unit/ 目录下的测试,每个文件只测一个类或一个模块。编译快,跑得快。
  • integration/ 目录放跨模块的测试。比如测试 A 模块调用 B 模块的接口是否正常。
  • system/ 目录放端到端测试。我一般用 Python 写,因为灵活。
  • fixtures/ 放共享的测试数据。避免每个测试文件都复制一份。

小技巧:单元测试的文件名用 test_<模块名>.cpp,集成测试用 test_<功能>.cpp。这样一眼就能看出测试范围。

三、测试依赖管理——最容易被忽视的坑

我曾经在一个项目里,因为测试依赖没管好,导致 CI 上测试结果不稳定。有时候能过,有时候不能过,查了半天发现是测试执行顺序的问题。

CMake 里管理测试依赖,主要靠这几个手段:

3.1 用 add_test 和 set_tests_properties

# 单元测试之间没有依赖
add_test(NAME test_module_a COMMAND test_module_a)
add_test(NAME test_module_b COMMAND test_module_b)

# 集成测试依赖单元测试通过
add_test(NAME test_integration COMMAND test_integration)
set_tests_properties(test_integration
  PROPERTIES DEPENDS "test_module_a;test_module_b"
)

# 系统测试依赖集成测试
add_test(NAME test_system COMMAND test_system)
set_tests_properties(test_system
  PROPERTIES DEPENDS "test_integration"
)

这样配置后,CTest 会自动按依赖顺序执行。如果单元测试失败,集成测试根本不会跑。节省时间,也避免误报。

3.2 资源锁——解决并行测试冲突

有些测试会占用同一个资源,比如数据库端口、文件锁。并行跑的时候就会打架。CMake 提供了 RESOURCE_LOCK 属性:

add_test(NAME test_db_write COMMAND test_db_write)
set_tests_properties(test_db_write
  PROPERTIES RESOURCE_LOCK "db"
)

add_test(NAME test_db_read COMMAND test_db_read)
set_tests_properties(test_db_read
  PROPERTIES RESOURCE_LOCK "db"
)

这样两个测试不会同时执行,但和其他不锁 db 的测试可以并行。嗯,这个功能很多人不知道,其实特别好用。

3.3 测试超时控制

我建议每个测试都设置超时。防止某个测试卡死,拖垮整个 CI 流程。

set_tests_properties(test_slow
  PROPERTIES TIMEOUT 30
)

注意:超时时间不要设得太紧。单元测试一般 5-10 秒,集成测试 30-60 秒,系统测试可以到 5 分钟。留点余量,避免因为机器负载波动导致误报。

四、用 SVG 图看清测试分层与依赖

下面这张图,是我自己项目里实际用的测试分层结构。你可以看到测试是怎么一层层往上构建的。

大型项目测试分层与依赖关系 系统测试 (System Test) 端到端功能验证,模拟真实用户场景 依赖 集成测试 (Integration Test) 模块间接口验证,依赖外部资源 依赖 单元测试 (Unit Test) 单个函数/类验证,不依赖外部资源 tests/ ├── system/ ├── integration/ ├── unit/ │ ├── module_a/ │ │ └── test_foo.cpp │ └── module_b/ │ └── test_bar.cpp ├── fixtures/ └── CMakeLists.txt 依赖管理 ● DEPENDS 控制执行顺序 ● RESOURCE_LOCK 避免资源冲突 ● TIMEOUT 防止测试卡死 ● FIXTURES_REQUIRED 共享测试数据

五、CMakeLists.txt 实战配置

最后,给你看一个我实际项目里用的测试 CMakeLists.txt 配置。注意看怎么把三层测试组织在一起:

# tests/CMakeLists.txt
cmake_minimum_required(VERSION 3.20)

# 启用测试
enable_testing()

# 添加子目录
add_subdirectory(unit)
add_subdirectory(integration)
add_subdirectory(system)

# 设置测试超时全局默认值
set(CTEST_TEST_TIMEOUT 60 CACHE STRING "Default test timeout")

# 配置 CTest 并行
set(CTEST_PARALLEL_LEVEL 4)

然后在每个子目录里:

# tests/unit/CMakeLists.txt
# 编译单元测试
add_executable(test_module_a test_module_a.cpp)
target_link_libraries(test_module_a PRIVATE
  gtest
  gmock
  module_a_lib
)

# 注册测试
add_test(NAME test_module_a COMMAND test_module_a)
set_tests_properties(test_module_a
  PROPERTIES
    TIMEOUT 10
    LABELS "unit"
)

个人建议:给测试打上 LABELS 标签。比如 unit、integration、system。这样在 CI 上可以按标签筛选执行。比如提交代码时只跑 unit,每日构建跑全部。

我曾经踩过一个坑:集成测试里依赖了一个外部服务,但那个服务在 CI 环境里没启动。结果测试一直失败,排查了半天。后来我加了个 REQUIRED_FILES 检查,确保前置条件满足才跑测试。

# 检查外部服务是否可用
add_test(NAME check_db_service COMMAND check_db_available.sh)
set_tests_properties(check_db_service
  PROPERTIES
    FIXTURES_SETUP "db_fixture"
    TIMEOUT 5
)

add_test(NAME test_integration_db COMMAND test_integration_db)
set_tests_properties(test_integration_db
  PROPERTIES
    FIXTURES_REQUIRED "db_fixture"
    DEPENDS "test_module_a;test_module_b"
)

嗯,这样配置后,如果数据库没启动,check_db_service 会失败,后续的集成测试根本不会执行。避免了无意义的失败和排查时间。

总结一下:测试分层不是花架子,是实实在在帮你节省时间的手段。目录结构清晰了,依赖关系理清了,CI 上跑测试才敢放心。我见过太多项目,测试代码比源码还难维护,就是因为一开始没规划好。希望今天的分享对你有帮助。