工业级C++怎么做测试?3层测试体系打样

📅 2026/8/25 14:55:44
工业级C++怎么做测试?3层测试体系打样
单元测试 集成测试 手动测试一个不能少“C 项目写测试太费时间了上线要紧。”这句话的后半句通常是“这个 bug 改完又引入了 3 个新的。”没有测试的项目就像没有安全绳的攀岩。能爬但不敢松手。每次改动都在赌改完跑一遍主程序点几个界面“看起来没问题”就提交了。然后线上崩了。Aether 这个项目从 0 到 1 经历了三轮测试体系的重构。第一轮没有测试。第二轮有测试但跑不通。第三轮分层测试CI 自动跑合代码之前心里有底。这篇就把这个演变过程拆开给你看。一、Aether 的测试金字塔先看一个概念。软件测试里有个经典模型叫测试金字塔长这样╱╲ ╱ ╲ E2E / 手动测试 ╱ ╲ 少贵 ╱──────╲ ╱ ╲ 集成测试 ╱ ╲ 中量中等成本 ╱────────────╲ ╱ ╲ 单元测试 ╱ ╲ 多快便宜 ╱──────────────────╲越往下测试越轻量、执行越快、维护成本越低。越往上测试越接近真实用户场景但每次跑起来也更费时。Aether 的测试分三层第一层单元测试针对独立的类或函数不依赖外部环境。比如测试 IoC 容器的 bind/make 逻辑是否正确或者测试一个字符串工具函数。这种测试毫秒级跑完CI 里每次提交都跑。第二层集成测试验证插件与插件之间、模块与模块之间的交互。比如测试加载插件 A 后能否从对象池拿到插件 B 注册的服务。这种测试需要框架环境但不启动完整的 UI。第三层冒烟测试覆盖最关键的业务路径。启动完整应用加载所有插件依次执行加载配方→开始检测→生成报告→导出数据这条链路。这种测试最慢但最能发现真实问题。二、单元测试从 0 到 1Aether 的单元测试用了两个框架Qt Test 和 Google Test。Qt Test 用来测框架核心IoC 容器、ServiceProvider、Facade因为它的 QCOMPARE/QVERIFY 和 Qt 类型天然契合。业务插件的测试则用 Google Test更通用。先看一个实际的测试用例这是测试 IoC 容器的瞬态绑定// 验证 bind make 的瞬态语义// 每次 make() 应返回全新实例互不干扰voidbind_transient_returns_new_instance_each_time(){CContainer c;// 注册一个打招呼服务每次调用工厂函数创建新实例c.bindIGreeter([]()-std::shared_ptrIGreeter{returnstd::make_sharedHelloGreeter();});autoac.makeIGreeter();autobc.makeIGreeter();QVERIFY(a!nullptr);QVERIFY(b!nullptr);QVERIFY(a.get()!b.get());// 地址不同证明是不同实例QCOMPARE(QString::fromStdString(a-greet(World)),QStringLiteral(Hello, World!));}关键看第 4 个断言a.get() ! b.get()。如果这个测试失败说明 bind 被错误实现成了单例或者工厂函数返回了同一个对象。一个测试抓住了生命周期语义是否正确的核心问题。再看 singleton 的测试。它应该返回同一实例// 验证 singleton 语义两次 make() 拿到同一指针voidsingleton_returns_same_instance_every_time(){CContainer c;c.singletonILogger([]()-std::shared_ptrILogger{returnstd::make_sharedMemoryLogger();});autoac.makeILogger();autobc.makeILogger();QCOMPARE(a.get(),b.get());// 地址相同证明是同一实例// 通过 a 写入从 b 读到证明状态共享a-log(ping);QCOMPARE(static_castint(b-messages().size()),1);QCOMPARE(QString::fromStdString(b-messages()[0]),QStringLiteral(ping));}这两个测试加起来不到 40 行覆盖了容器最核心的两种绑定模式。每次 CI 运行时它们都会被执行数千次确保没人不小心改坏了容器逻辑。三、Google Test CMake 集成Aether 用 CMake 管理测试构建。测试代码和源码在同一个仓库但不是同一个 target。先看顶层 CMakeLists.txt 怎么开启测试# 测试开关默认关闭cmake 时加 -DBUILD_TESTINGON 开启 option(BUILD_TESTING Build unit/integration tests OFF) if(BUILD_TESTING) enable_testing() include(CTest) endif()加上-DBUILD_TESTINGON就能编译测试。不加就不编不影响正常构建。然后各个测试子目录有自己的 CMakeLists。以aoi/tests/CMakeLists.txt为例# 1. 找到 GTest 库通过 FetchContent 或系统安装 find_package(GTest REQUIRED) # 2. 编译测试可执行文件 add_executable(aoi_plugin_tests test_recipe.cpp test_repository.cpp ) # 3. 链接被测库 GTest target_link_libraries(aoi_plugin_tests PRIVATE Aoi # 被测的业务库 GTest::gtest # Google Test 主库 GTest::gtest_main # 自动生成 main() Qt${QT_VERSION_MAJOR}::Core # 插件头文件依赖 ) # 4. 注册 CTest 测试 add_test( NAME AoiPluginTest COMMAND aoi_plugin_tests )集成测试也一样只是目标文件变成了aoi_integration_tests链接的依赖相同测试内容更重。运行方式很简单# 配置时开启测试cmake..-DBUILD_TESTINGON# 编译测试cmake--build.--targetaoi_plugin_tests# 直接跑./bin/Debug/aoi_plugin_tests.exe# 或者用 CTest 批量跑ctest-CDebug --output-on-failure--output-on-failure这个参数是救命用的。测试失败时直接打印详细输出不用再 rerun 一遍。四、插件测试的特殊性单元测试写起来最顺手但 C 插件项目有个特有的难题插件无法独立运行。一个插件在被 PluginManager 加载之前连初始化都完成不了。没有对象池、没有 IoC 容器、没有其他插件注册的服务。这些东西都在主框架里。直接跑插件代码一启动就崩。怎么办呢Aether 的做法是mock 插件框架让被测代码以为自己在真实的插件环境里运行。看一个具体例子。Aether 的AoiPlugin在初始化时会向对象池注册自己的服务boolAoiPlugin::initialize(QString*errorString){// 插件初始化在对象池中注册核心服务addAutoReleasedObject(newRecipeService());addAutoReleasedObject(newInspectionService());// 从对象池获取其他插件注册的服务auto*cameraPluginManager::getObjectICameraService();if(!camera){*errorStringCameraService not available;returnfalse;// 依赖缺失初始化失败}camera-registerEventHandler(this);returntrue;}测试这个函数需要让PluginManager::getObjectICameraService()返回一个可控制的 mock 对象。否则测试就像写小说全靠想象。解决方案是用继承 覆写的方式构造 mock// 伪造的相机服务让 AoiPlugin 以为自己接上了相机classMockCameraService:publicICameraService{public:// 记录调用次数验证是否被正确调用了intregisterCallCount0;voidregisterEventHandler(QObject*handler)override{registerCallCount;}// 其他纯虚函数直接返回默认值不执行实际硬件操作boolconnect()override{returntrue;}voiddisconnect()override{}};// 测试 AoiPlugin 在正常环境下能否成功初始化TEST(AoiPluginTest,initialize_succeeds_when_deps_met){MockCameraService mockCam;PluginManager::addObject(mockCam);// 把 mock 塞进对象池AoiPlugin plugin;QString error;boolokplugin.initialize(error);EXPECT_TRUE(ok);EXPECT_EQ(mockCam.registerCallCount,1);// 验证插件注册了自己}挂在PluginManager::addObject这一步。真实的插件框架在运行时会自动管理对象池但测试时需要手动把 mock 对象注入进去。这样做的价值你不用启动整个桌面应用就能验证插件的初始化逻辑是否正确、依赖缺失时能不能正确报错。五、三种测试的投入比例测试写多了容易走极端。有人觉得100% 覆盖率才安心有人觉得写测试不如多写业务代码。Aether 的经验是不追求覆盖率追求安全感。怎么衡量问自己一个问题改完代码提交之前你敢不敢一键跑测试如果你的回答是敢跑完我就合那测试体系就是合格的。如果回答是算了太慢先合吧那就出了问题。Aether 目前的测试比例层级数量执行时间覆盖目标单元测试100 10 秒容器、工具函数、核心算法集成测试30 1 分钟插件交互、数据流冒烟测试5 5 分钟完整业务链路三层加起来不到 10 分钟。每次改完代码开发机上跑一遍全部绿色就提交。10 分钟的安全感比上线后花 2 小时排查 bug 划算太多。六、写在最后如果你的 C 项目现在还没有测试或者有测试但没人跑我建议你做三件事今天给最核心的一个模块写 3 个单元测试确保数据进来了能出去这周把它加到 CMake 构建里确保cmake --build能编过下个月在 CI 里跑起来红灯亮的时候别忽略它测试不是银弹。写了一堆烂测试比如只测 getter/setter 那种比没有测试更可怕。它给你虚假的安全感。但好的测试体系真的是那个安全绳。有了它你敢重构、敢升级依赖、敢换实现方式而不用担心改了这个那里会不会崩。工业级 C 项目的分水岭不是用什么框架、不是代码写得多花哨而是你敢不敢一键跑测试然后说没问题合。 评论区聊聊你的 C 项目测试覆盖率多少怎么保证的有没有上线前信心满满上线后秒崩的经历 觉得有用转发给团队里正在赶工期、不写测试的小伙伴。这篇能帮他少踩几个坑。 系列进度21 篇系列到今天已经完成了 18 篇。从插件架构到 IoC 容器从 MVVM 到 CMake从信号槽到测试策略我们把 Aether 这个工业级框架的核心模块都过了一遍。下一期是 Phase 5 的开篇。项目复盘从架构演进到成长路线一个 C 项目从能跑到能维护中间经历了什么哪些架构决策做对了哪些做错了下篇见分晓。