第327篇 单元测试实战——Google Test在机器人项目中的应用
上篇聊了端侧AI部署的工程实践。模型部署到嵌入式设备后,你怎么保证推理代码的正确性?换个模型版本,输出还一致吗?精度量化后数值偏差在可接受范围内吗?
这些问题的答案都指向同一个东西:测试。
很多做机器人的工程师写代码靠"跑一下看看"来验证。在仿真里跑一遍,轨迹没问题就算过了。这种做法在小项目里凑合能用,一旦代码量上去了、模块多起来了,bug藏在哪你根本不知道。
今天聊聊单元测试,重点讲Google Test在机器人项目中的实战应用。
Google Test基础:五分钟上手
Google Test(简称GTest)是C++领域最主流的测试框架,几乎是行业标准。ROS2的很多包内部就用的GTest。
#include <gtest/gtest.h>
int add(int a, int b) { return a + b; }
TEST(MathTest, AddPositive) {
EXPECT_EQ(add(2, 3), 5);
}
TEST(MathTest, AddNegative) {
EXPECT_EQ(add(-1, -2), -3);
}
EXPECT_EQ和ASSERT_EQ是最常用的两个断言宏。区别在于:EXPECT_EQ失败后继续执行后面的断言,ASSERT_EQ失败后直接退出当前测试用例。一般用EXPECT_EQ,除非后面的断言依赖前面必须成立的条件。
CMakeLists.txt里集成GTest也很简单:
find_package(GTest REQUIRED)
add_executable(math_test test_math.cpp)
target_link_libraries(math_test GTest::gtest_main)
gtest_discover_tests(math_test)
加上gtest_discover_tests之后,ctest命令就能自动发现并运行所有测试用例。CI里跑测试就一条命令的事。
机器人项目的测试策略
机器人代码和普通业务代码有个本质区别:它和硬件、时间、物理世界强耦合。电机转速依赖硬件反馈,SLAM依赖传感器数据流,控制循环依赖实时时钟。这些东西怎么做单元测试?
核心思路是隔离。把算法逻辑和硬件依赖剥离开,只测纯算法部分。
举个例子,你写了个PID控制器。PID的输入是目标值和当前反馈值,输出是控制量。这个计算过程是纯数学运算,不依赖任何硬件。直接构造输入、调用函数、检查输出,就是一个标准的单元测试。
TEST(PIDTest, ProportionalOnly) {
PIDController pid(1.0, 0.0, 0.0);
double output = pid.compute(10.0, 0.0, 0.01);
EXPECT_NEAR(output, 10.0, 1e-6);
}
TEST(PIDTest, IntegralAccumulation) {
PIDController pid(0.0, 1.0, 0.0);
pid.compute(5.0, 0.0, 0.1);
double output = pid.compute(5.0, 0.0, 0.1);
EXPECT_NEAR(output, 10.0, 1e-6);
}
对于依赖硬件的模块,用Mock对象来替代。比如你的里程计模块需要读取编码器数据,测试时用Mock编码器返回预设值,验证里程计的计算逻辑是否正确。Google Mock(GMock)是GTest配套的mock框架,用起来很方便。
class MockEncoder : public EncoderInterface {
public:
MOCK_METHOD(double, getVelocity, (), (override));
MOCK_METHOD(int64_t, getTicks, (), (override));
};
TEST(OdometryTest, StraightLineMotion) {
auto mock_enc = std::make_shared<MockEncoder>();
EXPECT_CALL(*mock_enc, getVelocity())
.WillRepeatedly(Return(1.0));
Odometry odom(mock_enc, 0.1); // wheel_radius=0.1
odom.update(0.1); // dt=0.1s
EXPECT_NEAR(odom.getX(), 0.1, 1e-6);
}
数值计算的测试技巧
机器人领域大量涉及浮点运算,测试数值计算有个特殊问题:精度。两个理论上应该相等的浮点数,实际计算结果可能差个1e-10。用EXPECT_EQ直接比较肯定挂。
EXPECT_NEAR是解决方案——它允许你指定一个误差范围。但这个误差范围(tolerance)怎么定?定太大测不出问题,定太小频繁误报。
经验法则是:根据算法的数值稳定性来定。矩阵求逆、三角函数这类操作,tolerance一般设1e-6到1e-8。涉及大量累加的操作(比如积分),tolerance要放宽到1e-4。如果不确定,先跑一遍参考实现,看实际误差分布在哪里。
还有个技巧是相对误差。当数值本身很大的时候,绝对误差可能很大但相对误差很小。比如两个值都是10000量级,差0.001,绝对误差0.001看起来不小,相对误差才1e-7。这种情况下用相对误差判断更合理。
double expected = 10000.0;
double actual = 10000.001;
EXPECT_NEAR(actual / expected, 1.0, 1e-6);
参数化测试在数值计算中特别好用。你有一组输入输出数据,不想为每组写一个TEST,用TEST_P批量跑:
class KinematicsTest : public ::testing::TestWithParam<
std::tuple<double, double, double>> {};
TEST_P(KinematicsTest, ForwardKinematics) {
auto [j1, j2, expected_x] = GetParam();
double x = forward_kin(j1, j2);
EXPECT_NEAR(x, expected_x, 1e-4);
}
INSTANTIATE_TEST_SUITE_P(
Arm2D, KinematicsTest,
::testing::Values(
std::make_tuple(0.0, 0.0, 1.0),
std::make_tuple(M_PI/2, 0.0, 0.0),
std::make_tuple(0.0, M_PI/2, 0.5)
));
测试覆盖率:多少才算够
面试经常被问:"你们项目的测试覆盖率是多少?"
先说个现实:机器人项目的测试覆盖率普遍偏低。原因很简单——很多代码和硬件耦合太紧,写测试的成本高。能做到核心算法模块80%以上覆盖率已经很不错了。
但覆盖率不是越高越好。追求100%覆盖率会陷入"为测而测"的陷阱。重点测什么?边界条件、异常路径、核心算法。PID控制器的积分饱和、卡尔曼滤波的协方差矩阵奇异、路径规划器的起点终点重合——这些边界情况才是测试的价值所在。
覆盖率工具用gcov配合lcov就行。CI里每次构建跑覆盖率,下降就报警,这是比较成熟的做法。
面试追问
"你们怎么保证测试不遗漏?"靠代码评审和测试设计。写新功能的时候,PR里必须包含对应的测试代码,reviewer检查测试是否覆盖了正常路径和异常路径。
"测试跑得太慢怎么办?"单元测试必须快——单个测试用例不超过100ms,整个测试套件不超过30秒。如果慢了,说明你把不该放在单元测试里的东西放进去了(比如网络通信、文件IO)。这些应该放到集成测试里。
"GTest和Catch2怎么选?"GTest生态更成熟,ROS2官方用的就是GTest。Catch2是单头文件库,集成更简单,语法更现代。如果项目已经用了GTest就别换了,新项目可以考虑Catch2。
单元测试是代码质量的底线。没有测试的代码就是定时炸弹——现在跑着没问题,改了一行代码三个模块同时崩。机器人系统复杂度越来越高,靠人肉验证已经不够了。
下一篇聊集成测试。单元测试保证每个模块没问题,但模块拼在一起呢?接口对得上吗?数据格式一致吗?这就是集成测试要解决的问题。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。 你的支持是我持续更新的最大动力。
「机器人软件开发面试·从入门到精通」连载系列
上一篇:第326篇 嵌入式AI推理——TensorRT/NCNN的端侧部署
下一篇预告:第328篇 集成测试——多模块联调和接口测试
有任何问题欢迎评论区留言,我会尽量回复。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)