Skip to content

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 回放驱动 ​

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_cpplintC++ 风格(Google style 子集)
ament_flake8Python 风格(PEP8)
ament_cppcheckC/C++ 静态缺陷分析(未初始化、越界)
ament_uncrustifyC++ 代码格式化校验
ament_xmllintXML(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 最佳实践 ​

  1. 测试分层进 CI 编排:push 触发"单元+集成+lint"(<10 分钟),merge/夜间触发"仿真回归"(>30 分钟)。用 action-ros-ci 跑前者,仿真用自建 runner。
  2. headless 仿真回归:gz sim -s -r(-s 服务器无 GUI、-r 自动运行)配 launch_testing 断言,见 06_仿真与硬件生态.md。
  3. 回放驱动做金丝雀:把每次升级前录的 ros2 bag 作为回归输入,断言输出话题与基线一致(数值容差内)。
  4. 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-ciGitHub Actions CIros-tooling
ros2 tracing(tracetools)性能剖析ros2/ros2_tracing

7. 参考资料 ​


8. 学习路径 ​

  1. 入门:给一个纯函数写 gtest/pytest 单测,跑通 colcon test + colcon test-result。
  2. 进阶:把被测逻辑抽成无 ROS 依赖的库,Node 薄封装,体会"可测试架构"。
  3. 集成:用 launch_testing 启动 talker/listener,断言话题数据与 QoS。
  4. 仿真:gazebo headless 跑 launch,launch_testing 断言,接入 CI(action-ros-ci)。
  5. 串联:进入 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