第十四章:测试集成——让代码质量不再靠“感觉”

说实话,我见过太多嵌入式项目,测试全靠开发人员拍胸脯说“我觉得没问题”。结果呢?硬件联调时 bug 一个接一个冒出来,定位问题比写代码还痛苦。我自己早期做 STM32 项目时也踩过这个坑,后来痛定思痛,把测试集成到 CMake 构建体系里,才算是真正解脱了。

这一章,我们就来聊聊怎么用 CMake 把测试这件事系统化。说白了,就是让机器替我们做重复劳动,而不是靠人肉测试。

14.1 测试框架的基石:enable_testing() 和 add_test()

CMake 内置了一套轻量级的测试机制。你不需要装任何第三方工具,就能跑起来最基本的单元测试。怎么用?两步走。

第一步:在顶层 CMakeLists.txt 中开启测试

cmake_minimum_required(VERSION 3.10)
project(MyEmbeddedProject C CXX)

# 开启测试支持
enable_testing()

add_subdirectory(src)
add_subdirectory(test)

注意,enable_testing() 必须在顶层 CMakeLists.txt 中调用。子目录里调了也没用,这是 CMake 的规则。

第二步:在 test 目录下添加测试用例

# test/CMakeLists.txt
add_executable(test_math test_math.c)
target_link_libraries(test_math PRIVATE mylib)

# 注册测试
add_test(NAME test_math COMMAND test_math)

这里 add_test 的第一个参数是测试名称,第二个参数是可执行文件。你可以加多个测试,比如:

add_test(NAME test_math COMMAND test_math)
add_test(NAME test_gpio COMMAND test_gpio)
add_test(NAME test_uart COMMAND test_uart)

然后怎么跑?用 ctest 命令:

mkdir build && cd build
cmake ..
cmake --build .
ctest

输出会告诉你每个测试是 PASS 还是 FAIL。简单粗暴,但够用。

我的习惯: 我会给每个测试加一个 WORKING_DIRECTORY 属性,确保测试在正确的目录下运行。比如:
set_tests_properties(test_math PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
这样就不会因为路径问题导致测试失败。

14.2 CTest:不只是跑测试那么简单

CTest 是 CMake 自带的测试驱动工具。它不仅能跑测试,还能做很多事情。我挑几个常用的功能说说。

并行测试

嵌入式项目测试多了,一个个跑很慢。CTest 支持并行:

ctest -j4  # 同时跑4个测试

测试超时控制

嵌入式测试有时会卡死,比如某个外设没响应。设置超时很有必要:

set_tests_properties(test_uart PROPERTIES TIMEOUT 10)

超过10秒,CTest 会自动杀掉测试进程并标记为 FAIL。

测试标签分类

你可以给测试打标签,然后按标签筛选运行:

set_tests_properties(test_math PROPERTIES LABELS "unit")
set_tests_properties(test_gpio PROPERTIES LABELS "integration")

# 只跑单元测试
ctest -L unit

这个功能我特别喜欢。在 CI 流水线里,我可以让单元测试每次提交都跑,集成测试只在合并前跑一次,节省大量时间。

CTest 命令 作用 我常用的场景
ctest -jN 并行运行 N 个测试 本地开发时加速
ctest -L tag 按标签筛选测试 CI 中区分单元/集成测试
ctest -R regex 按名称正则匹配 只跑某个模块的测试
ctest --output-on-failure 失败时打印详细输出 调试测试失败原因
我曾经踩过的坑: 在嵌入式项目中,测试可执行文件可能依赖硬件。如果你在纯主机环境下跑测试,记得用 ctest -E 排除那些需要硬件的测试。否则测试会一直挂在那,直到超时。

14.3 Google Test 集成:让测试更专业

CMake 自带的测试机制虽然简单,但功能有限。比如没有断言宏、没有测试夹具、没有参数化测试。这时候,Google Test 就派上用场了。

集成步骤

第一步:下载 Google Test 源码。我习惯用 FetchContent 自动拉取:

# 顶层 CMakeLists.txt
include(FetchContent)
FetchContent_Declare(
  googletest
  URL https://github.com/google/googletest/archive/release-1.12.1.zip
)
FetchContent_MakeAvailable(googletest)

第二步:编写测试用例

// test/test_math.cpp
#include <gtest/gtest.h>
#include "math_utils.h"

TEST(MathTest, Add) {
  EXPECT_EQ(add(2, 3), 5);
  EXPECT_EQ(add(-1, 1), 0);
}

TEST(MathTest, Divide) {
  EXPECT_EQ(divide(10, 2), 5);
  EXPECT_THROW(divide(1, 0), std::invalid_argument);
}

第三步:在 test/CMakeLists.txt 中链接

add_executable(test_math test_math.cpp)
target_link_libraries(test_math PRIVATE mylib gtest gtest_main)

add_test(NAME test_math COMMAND test_math)

注意,我链接了 gtest_main,它会自动生成 main 函数。如果你有自定义的 main,就只链接 gtest

测试夹具(Test Fixture)

嵌入式开发中,经常需要初始化硬件、分配内存。用夹具可以避免重复代码:

class GpioTest : public ::testing::Test {
protected:
  void SetUp() override {
    // 模拟 GPIO 初始化
    gpio_init();
  }

  void TearDown() override {
    // 清理
    gpio_deinit();
  }
};

TEST_F(GpioTest, SetHigh) {
  gpio_set(GPIO_PIN_0, HIGH);
  EXPECT_EQ(gpio_read(GPIO_PIN_0), HIGH);
}

你想想看,如果没有夹具,每个测试都要写一遍初始化代码,多烦人。

14.4 嵌入式测试的特殊考量

嵌入式测试和纯软件测试不太一样。我总结了几点经验:

  • 模拟硬件层: 用 C++ 的多态或 C 的函数指针,把硬件操作替换成模拟实现。比如 GPIO 读写可以替换成内存操作。
  • 交叉编译测试: 如果测试必须在目标板上跑,可以用 add_testCOMMAND 指定远程执行脚本,比如通过 SSH 上传并运行。
  • 内存泄漏检测: 嵌入式系统内存有限,我习惯在测试中集成 AddressSanitizer 或 Valgrind。
核心思路: 测试不是为了证明代码没错,而是为了快速发现哪里错了。在嵌入式项目中,测试集成越早做,后期联调越轻松。

14.5 知识体系总览

下面这张图,是我对本章知识点的梳理。你可以把它当作一个快速参考。

CMake 测试集成知识体系 内置测试机制 enable_testing() + add_test() CTest 工具 并行/超时/标签/筛选 Google Test 断言/夹具/参数化 子功能 • 注册测试用例 • 设置工作目录 • 设置环境变量 子功能 • 并行执行 (-j) • 超时控制 (TIMEOUT) • 标签筛选 (-L) 子功能 • TEST / TEST_F • EXPECT_EQ / ASSERT • SetUp / TearDown 嵌入式特殊考量 硬件模拟 · 交叉编译 · 内存检测 · 远程执行 目标:自动化测试 → 快速反馈 → 高质量代码

嗯,这张图基本把本章的核心内容串起来了。你从底层内置机制开始,往上到 CTest 工具,再到 Google Test 的高级特性,最后落到嵌入式特有的考量。每一层都有它的用途。

我的建议: 刚开始不要追求完美。先给项目加一个简单的 add_test,跑通再说。等团队习惯了测试流程,再逐步引入 Google Test 和 CI 集成。步子迈太大,容易扯着蛋。

好了,测试集成这部分就聊到这。记住一句话:测试不是负担,是投资。你花在测试上的每一分钟,都会在后期调试时加倍还给你。

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