Appearance
ROS 2 测试体系
在知识图谱中的位置:模块四 · 04_工程实践 · 第 2 节 难度:⭐⭐⭐ | 前置知识:模块一·基础概念(话题/服务/节点)、01_ROS2应用架构设计模式.md(接口契约)、
colcon基本用法
1. 概述
机器人软件的 bug 往往是物理性的:路径规划错了会撞墙、控制环抖动会烧电机、感知误检会伤人。因此量产团队的测试重点不是"代码对不对",而是"在真实时间、真实数据、真实 QoS 下,整条链路的行为是否符合契约"。ROS 2 提供了从单元到仿真回归的完整测试工具链:ament 测试宏、launch_testing 集成测试、/use_sim_time 仿真时钟、ros2 bag 回放驱动,以及 ament_lint 静态检查。
本节按"测试金字塔"组织:底部是便宜大量的单元测试,中部是带话题断言的集成测试,顶部是少量贵重的仿真/HIL 回归。
2. 核心概念
2.1 测试金字塔
▲ 少而贵
仿真/HIL 回归 gazebo headless + launch + 断言
集成测试(launch_testing) 启动真实节点,断言话题/服务
单元测试(gtest/pytest) 纯函数/类,无 ROS 运行时
▼ 多而便宜原则:能单元测就不集成测,能集成测就不仿真测。越往上越接近真实但越慢、越脆(flaky)。量产 CI 里,单元+集成必须在每次 push 跑完(几分钟内),仿真回归按需/夜间跑。
2.2 ament 测试体系:BUILD_TESTING 与 add_test
ROS 2 用 CMake 的 BUILD_TESTING 开关统一控制测试编译,用 ament 宏把测试注册进 CTest。colcon test 触发全部已注册测试,colcon test-result --verbose 看结果。
cmake
# package.xml 声明测试依赖
<test_depend>ament_cmake_gtest</test_depend>
<test_depend>ament_lint_auto</test_depend>
# CMakeLists.txt
if(BUILD_TESTING)
find_package(ament_cmake_gtest REQUIRED)
ament_add_gtest(test_foo test/test_foo.cpp)
ament_target_dependencies(test_foo rclcpp) # 链接被测库
# 或 ament_cmake_auto 的简写:ament_auto_add_gtest(...)
endif()2.3 仿真时钟 /use_sim_time
ROS 2 节点的时间来源有两种:系统墙钟,或 /clock 话题驱动的仿真时钟。节点设置参数 use_sim_time=true 后,会订阅 /clock,用它替代系统时间——这让"加速/暂停/单步"时间成为可能,是确定性测试的基础。官方设计文档:https://design.ros2.org/articles/clock_and_time.html。
2.4 覆盖率与 Lint
- 覆盖率:用
lcov/gcov生成 HTML 报告,量化"哪些分支没测到"。 - Lint:
ament_lint是一套独立的静态检查器(flake8/cppcheck/uncrustify/xmllint 等),在编译期之后、测试期统一跑,保证代码风格与常见缺陷(未初始化、内存泄漏嫌疑)被拦截。
2.5 仿真回归(headless)
用 Gazebo/GZ Sim 以 headless 模式(不渲染 GUI)跑完整 launch,配合 launch_testing 断言系统行为——这是"仿真→真机"之间最接近真实的一层。
3. 技术原理/方法
3.1 单元测试:gtest(C++)与 pytest(Python)
C++(gtest)——测纯逻辑类、消息处理函数、坐标变换,尽量不启动 Node:
cpp
#include <gtest/gtest.h>
TEST(MyMathTest, AddsTwoNumbers) {
EXPECT_EQ(3, add(1, 2));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}Python(pytest)——ament 会自动发现 test/ 下以 test_ 开头的 pytest 文件:
python
# test/test_math.py
def test_add():
assert add(1, 2) == 3关键技巧:把业务逻辑抽成无 ROS 依赖的纯库(lib 层),Node 只是薄封装,这样单元测试不依赖 ROS 运行时、跑得快、可并行。这是"可测试架构"的核心——与 01_ROS2应用架构设计模式.md 的"接口契约"思想一脉相承。
3.2 集成测试:launch_testing + 话题断言
launch_testing 启动真实节点(真实进程、真实 DDS),然后用断言检查话题/服务行为。官方教程:https://docs.ros.org/en/jazzy/Tutorials/Intermediate/Testing/Integration.html,包:https://index.ros.org/p/launch_testing/。
python
# test/test_integration.py —— 启动发布者,断言订阅者收到数据
from launch import LaunchDescription
from launch.actions import ExecuteProcess
import launch_testing
import launch_testing.actions
import unittest
def generate_test_description():
return LaunchDescription([
ExecuteProcess(cmd=['ros2', 'run', 'my_pkg', 'talker'], output='screen'),
launch_testing.actions.ReadyToTest(),
])
class TestTalker(unittest.TestCase):
def test_topic_published(self, proc_output):
proc_output.assertWaitFor('Publishing', timeout=10)关键点:
- 断言方式三选一:
proc_output匹配日志(简单但有脆弱性)、订阅话题后assertWaitFor检查消息(更贴近契约)、调用 service/action 验证返回。 - QoS 在集成测试里必须被验证——用
ros2 topic info --verbose的等价 API 断言发布/订阅 QoS 一致,这是单测覆盖不到的坑。 - 集成测试要可重复、可并行:避免依赖固定端口、固定命名空间,测试间用隔离的命名空间。
3.3 测试时间:rclcpp::TimerBase 与仿真时钟
ROS 2 的时间相关测试难点在"等定时器触发"。rclcpp::TimerBase 配合 /use_sim_time 可以确定性驱动时间:
python
# 测试用 launch 里注入仿真时钟参数
Node(package='my_pkg', executable='my_node',
parameters=[{'use_sim_time': True}])cpp
// 被测代码用 node 的时钟创建定时器,时间可控
auto timer = node->create_wall_timer(100ms, callback);原理:开启 use_sim_time 后,节点的 now() 来自 /clock,timer 到期由仿真时间驱动。测试里发布 /clock(或让 gazebo 发布),即可加速/暂停/单步——避免真实 sleep 导致测试慢且 flaky。
3.4 性能测试:rclcpp::Time 计时 + ros2 bag 回放驱动
- 微基准:用
rclcpp::Clock::now()(rclcpp::Time)给关键路径(一次回调、一次规划迭代)打点,断言 P99 延迟低于预算。 - 回放驱动:
ros2 bag record录下真实数据,ros2 bag play --loop回放喂给被测节点,让"真实数据+确定性输入"同时成立。官方教程:https://docs.ros.org/en/jazzy/Tutorials/Beginner-CLI-Tools/Recording-And-Playing-Back-Data/Recording-And-Playing-Back-Data.html。
bash
ros2 bag record -o my_test_bag /scan /odom /tf
ros2 bag play my_test_bag --loop --rate 1.0 # 回放驱动被测系统3.5 覆盖率与 Lint:ament_lint 全家桶
ament_lint 是多个独立 lint 包的聚合仓库(https://github.com/ament/ament_lint,https://index.ros.org/r/ament_lint/),常用成员:
| 包 | 作用 |
|---|---|
ament_cpplint | C++ 风格(Google style 子集) |
ament_flake8 | Python 风格(PEP8) |
ament_cppcheck | C/C++ 静态缺陷分析(未初始化、越界) |
ament_uncrustify | C++ 代码格式化校验 |
ament_xmllint | XML(launch/urdf)校验 |
ament_lint_auto | 一键聚合所有启用的 lint |
启用方式(推荐 ament_lint_auto,一条依赖自动注册全部 lint):
cmake
if(BUILD_TESTING)
find_package(ament_lint_auto REQUIRED)
ament_lint_auto_find_test_dependencies() # 自动发现 package.xml 里的 lint test_depend
endif()xml
<!-- package.xml -->
<test_depend>ament_lint_auto</test_depend>
<test_depend>ament_cpplint</test_depend>
<test_depend>ament_flake8</test_depend>跑单个 lint:colcon test --packages-select my_pkg --ctest-args -R cpplint。
3.6 CI 触发结构:ros-tooling/action-ros-ci 与 ros2/ci
官方 ROS 2 仓库用 ros2/ci(https://github.com/ros2/ci)做大规模 CI;社区/团队普遍用 ros-tooling/action-ros-ci(GitHub Action,https://github.com/ros-tooling/action-ros-ci)在 PR 上跑 colcon build + colcon test,支持指定 distro、依赖自动安装(rosdep)。最小工作流:
yaml
# .github/workflows/ci.yml
name: ROS2 CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-24.04
container: ros:jazzy
steps:
- uses: actions/checkout@v4
- uses: ros-tooling/setup-ros@v0.7
- uses: ros-tooling/action-ros-ci@v0.3
with:
package-name: my_pkg
target-ros2-distro: jazzy上例中
setup-ros@v0.7、action-ros-ci@v0.3的版本号为示例,请以 ros-tooling 仓库当前 release 为准。
4. 实践指南
4.1 可执行命令
bash
# 全量构建 + 全量测试
colcon build
colcon test
colcon test-result --verbose # 查看失败详情
colcon test-result --test-result-base build/my_pkg
# 单包测试 + 单测过滤
colcon test --packages-select my_pkg --ctest-args -R test_foo
colcon test --packages-select my_pkg --ctest-args -V # 输出全部日志
# 覆盖率
colcon build --cmake-args -DCMAKE_CXX_FLAGS="--coverage"
colcon test --packages-select my_pkg
# 用 lcov 收集 + 生成报告4.2 最佳实践
- 测试分层进 CI 编排:push 触发"单元+集成+lint"(<10 分钟),merge/夜间触发"仿真回归"(>30 分钟)。用
action-ros-ci跑前者,仿真用自建 runner。 - headless 仿真回归:
gz sim -s -r(-s服务器无 GUI、-r自动运行)配launch_testing断言,见 06_仿真与硬件生态.md。 - 回放驱动做金丝雀:把每次升级前录的
ros2 bag作为回归输入,断言输出话题与基线一致(数值容差内)。 - lint 前置:lint 失败 = 构建失败,别让风格问题留到 review。
4.3 陷阱
BUILD_TESTING默认开着:发布 deb 时会跑测试,测试里写死路径/网络会导致发布失败——测试必须自包含。- 仿真时钟忘开:节点在
use_sim_time=true下等/clock,但测试没发/clock,所有定时器永不到期,测试挂死直到超时。 - 集成测试的时序脆弱性:靠
sleep等节点就绪是 flaky 之源,用ReadyToTest+proc_output.assertWaitFor显式等待。 - QoS 不匹配在测试里"假装正常":如果测试自己造话题没校验 QoS,真机联调才会炸——把 QoS 校验写进集成测试。
- gtest 与 rclcpp 的
init冲突:多个测试用例共享一个rclcpp::init会崩溃,用SetUp/TearDown或独立进程隔离。
4.4 调优
- 测试并行度:
colcon test --executor sequential(默认并行)可避免共享资源冲突;--parallel-workers N控制并行数。 - flaky 治理:失败自动重试一次再判红(
--retest-until-pass 1),但重试不能掩盖真实竞态,要追踪 flaky 率。 - 仿真回归加速:
use_sim_time+ros2 bag play --rate 2让仿真跑快于真实时间,缩短回归耗时。
5. 方案对比
| 测试层级 | 工具 | 速度 | 真实性 | 成本 | 定位能力 |
|---|---|---|---|---|---|
| 单元(纯逻辑) | gtest/pytest | 毫秒级 | 低 | 极低 | 最强(精确到函数) |
| 单元(带 Node) | gtest + rclcpp init | 秒级 | 低 | 低 | 强 |
| 集成(launch_testing) | launch + 话题断言 | 秒~分钟 | 中 | 中 | 中(到话题/服务) |
| 仿真回归(headless) | gz sim + launch_testing | 分钟级 | 高 | 高 | 中(到系统行为) |
| HIL/真机 | 台架 + bag 回放 | 分钟~小时 | 最高 | 最高 | 弱(难定位) |
绝对不适用场景:当被测逻辑是纯数学/纯算法、无任何 ROS 依赖(如代价地图膨胀、IK 求解、轨迹插值)时,不要用 launch_testing 或仿真——这些层只会引入 DDS/调度噪声和 flaky,直接用 gtest/pytest 单元测,成本和价值严重倒挂。
6. 工具链
| 工具/包 | 用途 | 来源 |
|---|---|---|
ament_cmake_gtest / ament_cmake_pytest | 单元测试注册宏 | 官方(ament) |
ament_cmake_auto | 简化 CMake(ament_auto_add_gtest) | 官方 |
launch_testing / launch_testing_ros | 集成测试 | 官方 |
ament_lint(cpplint/flake8/cppcheck/uncrustify) | 静态检查 | ament/ament_lint |
ros2 bag(rosbag2) | 录制/回放驱动 | 官方 |
lcov/gcov | 覆盖率 | 开源通用 |
ros-tooling/action-ros-ci | GitHub Actions CI | ros-tooling |
ros2 tracing(tracetools) | 性能剖析 | ros2/ros2_tracing |
7. 参考资料
- ROS 2 集成测试教程(launch_testing):https://docs.ros.org/en/jazzy/Tutorials/Intermediate/Testing/Integration.html ✅
launch_testing包:https://index.ros.org/p/launch_testing/ ✅- 时钟与仿真时间设计:https://design.ros2.org/articles/clock_and_time.html ✅
- ros2 bag 录制/回放:https://docs.ros.org/en/jazzy/Tutorials/Beginner-CLI-Tools/Recording-And-Playing-Back-Data/Recording-And-Playing-Back-Data.html ✅
- ament_lint 仓库:https://github.com/ament/ament_lint ✅;包索引:https://index.ros.org/r/ament_lint/ ✅
- ament_cmake_lint_cmake 文档:https://docs.ros.org/en/jazzy/p/ament_cmake_lint_cmake/doc/ ✅
- ros2 tracing 教程:https://docs.ros.org/en/jazzy/Tutorials/Advanced/ROS2-Tracing-Trace-and-Analyze.html ✅
- ROS 构建农场测试:https://docs.ros.org/en/rolling/Tutorials/Intermediate/Testing/BuildFarmTesting.html ✅
- ROS 2 CI 仓库:https://github.com/ros2/ci ✅;社区 Action:https://github.com/ros-tooling/action-ros-ci ✅
8. 学习路径
- 入门:给一个纯函数写 gtest/pytest 单测,跑通
colcon test+colcon test-result。 - 进阶:把被测逻辑抽成无 ROS 依赖的库,Node 薄封装,体会"可测试架构"。
- 集成:用
launch_testing启动 talker/listener,断言话题数据与 QoS。 - 仿真:gazebo headless 跑 launch,
launch_testing断言,接入 CI(action-ros-ci)。 - 串联:进入 03_诊断监控与故障定位.md 学会"测试挂了之后怎么定位",再到 04_持续构建发布与部署.md 把测试接入发布流水线。
9. 核心面试三问
Q1:为什么说"单元测试要尽量不启动 Node"?你的测试架构怎么设计?
答:启动 Node 意味着引入 ROS 运行时(init、executor、DDS、调度),测试变慢、变 flaky、难并行,定位粒度也变粗。正确做法是把业务逻辑抽成无 ROS 依赖的纯库(算法、状态机、坐标变换),Node 只做"订阅→调库→发布"的薄封装。这样单元测纯库(快、稳、精确),集成测(launch_testing)只验证"话题契约 + QoS + 数据流",各司其职。
Q2:集成测试里,一个话题明明发布者发了数据,订阅者断言却超时收不到,你排查顺序是什么?
答:① 先确认发布者真的发——
proc_output日志 +ros2 topic hz;② 核对 QoS:BEST_EFFORT vs RELIABLE、depth 不匹配会静默丢;③ 核对话题名与命名空间是否重映射正确(ros2 topic list);④ 若开了use_sim_time,确认测试是否发布了/clock,否则定时器/订阅回调可能被仿真时钟卡住;⑤ 检查节点是否用同一个 executor 且回调没被长任务阻塞。
Q3:仿真回归(gazebo headless)和单元/集成测试,在 CI 里怎么分层编排?为什么不能全塞进每次 push?
答:按"金字塔+成本"分层:push/PR 跑单元+集成+lint(快、稳定、便宜,用 action-ros-ci 或自建 runner,10 分钟内);仿真回归(gazebo headless、bag 回放、HIL)跑得慢、依赖 GPU/仿真环境、易受环境 flaky 影响,放 merge 后或夜间定时任务。核心逻辑是让高频、低成本的测试尽快失败(fast feedback),把昂贵的仿真留给低频但高价值的整体回归,避免"每次 commit 等一小时仿真"拖垮开发节奏。
下一步:03_诊断监控与故障定位.md · 模块导读:README.md