ROS教程10:从 gtest 到 rostest——Unit Test、Node 集成测试、rosbag 与 rqt 验证闭环

ROS教程10:从 gtest 到 rostest------Unit Test、Node 集成测试、rosbag 与 rqt 验证闭环

摘要:把阶段 06~09 的运行机制转成可重复验证证据:拆分纯 C++ 逻辑与 ROS 包装层,用 gtest、rostest、rqt 和 rosbag 建立最小回归闭环。

文章目录

  • [ROS教程10:从 gtest 到 rostest------Unit Test、Node 集成测试、rosbag 与 rqt 验证闭环](#ROS教程10:从 gtest 到 rostest——Unit Test、Node 集成测试、rosbag 与 rqt 验证闭环)
    • [1. 先建立测试分层:不同证据能证明什么](#1. 先建立测试分层:不同证据能证明什么)
      • [本章与后续阶段 18 的边界](#本章与后续阶段 18 的边界)
    • [2. 本章代码结构:第一次把"可测试逻辑"和"ROS 包装层"拆开](#2. 本章代码结构:第一次把“可测试逻辑”和“ROS 包装层”拆开)
    • [3. Docker 环境为什么要补测试与 rqt 工具](#3. Docker 环境为什么要补测试与 rqt 工具)
    • [4. `MessageProcessor`:为什么纯逻辑层不应该依赖 ROS](#4. MessageProcessor:为什么纯逻辑层不应该依赖 ROS)
      • [为什么中间计算使用 `uint64_t`](#为什么中间计算使用 uint64_t)
    • [5. `testable_node`:ROS 层只负责参数、Topic 和错误传播](#5. testable_node:ROS 层只负责参数、Topic 和错误传播)
    • [6. `catkin_add_gtest()` 到底做了什么](#6. catkin_add_gtest() 到底做了什么)
      • [`package.xml` 为什么只有 `test_depend`](#package.xml 为什么只有 test_depend)
    • [7. 第一组自动测试:纯逻辑 gtest](#7. 第一组自动测试:纯逻辑 gtest)
      • [7.1 正常输入](#7.1 正常输入)
      • [7.2 边界值](#7.2 边界值)
      • [7.3 非法输入](#7.3 非法输入)
      • [7.4 算术溢出](#7.4 算术溢出)
      • [为什么这组测试比"Node 跑起来了"更有价值](#为什么这组测试比“Node 跑起来了”更有价值)
    • [8. `add_rostest_gtest()`:为什么 Node 集成测试需要另一个层次](#8. add_rostest_gtest():为什么 Node 集成测试需要另一个层次)
    • [9. 集成测试为什么先等连接,而不是 publish 完马上断言](#9. 集成测试为什么先等连接,而不是 publish 完马上断言)
      • [为什么测试进程使用 `AsyncSpinner(1)`](#为什么测试进程使用 AsyncSpinner(1))
    • [10. 集成测试具体证明什么](#10. 集成测试具体证明什么)
    • [11. 在 `ros1-dev` 中运行测试](#11. 在 ros1-dev 中运行测试)
    • [12. 怎样证明测试真的能发现问题:做一次可恢复的故意失败](#12. 怎样证明测试真的能发现问题:做一次可恢复的故意失败)
    • [13. `rqt_graph`:验证测试 Node 的 ROS graph 是否符合预期](#13. rqt_graph:验证测试 Node 的 ROS graph 是否符合预期)
      • [Docker 中怎样避免每次手动执行 `xhost`](#Docker 中怎样避免每次手动执行 xhost)
    • [14. `rqt_topic`:看 Topic 当前状态,不只用 `rostopic list`](#14. rqt_topic:看 Topic 当前状态,不只用 rostopic list)
    • [15. `rqt_plot`:把输入/输出关系画出来](#15. rqt_plot:把输入/输出关系画出来)
    • [16. `rqt_console`:故障注入时观察日志证据](#16. rqt_console:故障注入时观察日志证据)
    • [17. rosbag:把一次 Topic 输入变成可重复输入](#17. rosbag:把一次 Topic 输入变成可重复输入)
      • [17.1 只录输入,不先录输出](#17.1 只录输入,不先录输出)
      • [17.2 重放同一输入](#17.2 重放同一输入)
      • [rosbag 在这里能证明什么](#rosbag 在这里能证明什么)
    • [18. 本章的三种最小故障注入](#18. 本章的三种最小故障注入)
      • [18.1 输入越界](#18.1 输入越界)
      • [18.2 改小 `max_input`](#18.2 改小 max_input)
      • [18.3 非法负参数](#18.3 非法负参数)
    • [19. fake / mock 为什么只在本章先建立概念](#19. fake / mock 为什么只在本章先建立概念)
    • [20. rqt、rosbag、gtest、rostest 应该怎样组合](#20. rqt、rosbag、gtest、rostest 应该怎样组合)
    • [21. 与真实 ROS 驱动开发的对应关系](#21. 与真实 ROS 驱动开发的对应关系)
    • [22. 本章最终模型](#22. 本章最终模型)
    • 参考源码与官方资料

第 09 章已经把 callback 执行链追到了用户函数:消息进入 SubscriptionQueue、再进入 CallbackQueue,最终由 Spinner worker 调用用户 callback。到这里,我们已经能解释"程序为什么这样运行",但还没有回答另一个工程问题:

当代码修改以后,怎样证明原有行为没有被破坏;当 Node 看起来能运行时,又怎样把"我看到了正常日志"升级成可重复、可自动执行的测试证据?

第 10 章不把 gtest、rostest、rqt、rosbag 分别写成四份工具说明,而是围绕一条测试主线展开:

text 复制代码
纯 C++ 逻辑
    ↓
gtest
    ↓
ROS Node 包装层
    ↓
rostest / Topic 集成测试
    ↓
rqt 外部观察
    ↓
rosbag 固定输入
    ↓
故障注入
    ↓
形成可重复回归证据

本章继续使用阶段 09 已经建立的:

text 复制代码
ros_ws/src/ros1_comm_lab

不再创建新的 package。


1. 先建立测试分层:不同证据能证明什么

对 ROS 驱动 Node 来说,"测试通过"必须先说明是哪一层测试。

本章先使用三层模型:

层次 本章工具 主要证明什么 不能证明什么
纯逻辑单测 gtest 算法、边界、错误输入、状态保持 ROS Topic、参数、Master、callback 是否正确
Node 集成测试 rostest + gtest roscore、Node、Topic、参数、callback 的组合行为 真实 CAN/UART 设备时序、硬件恢复
运行观察/回放 rqt + rosbag 拓扑、频率、日志、数据趋势、固定输入回放 自动断言是否完整、硬件行为是否真实

因此:

text 复制代码
gtest 通过
    ≠
ROS Node 整体正确

同样:

text 复制代码
rqt_graph 看起来正常
    ≠
已经有自动回归测试

测试工具不是互相替代,而是提供不同层面的证据。

本章与后续阶段 18 的边界

阶段 10 只建立最小测试闭环:

text 复制代码
纯逻辑
ROS Node
Topic
参数
rosbag 输入
基本故障注入

后续阶段 18 才会系统进入:

text 复制代码
fake CAN
fake serial
mock transport
协议解析回归
设备 offline/reconnect
HIL

本章不提前把完整 Driver 测试体系全部塞进来。

本节小结: 先明确证据层次,再选工具;单测、Node 集成测试、运行观察和硬件验证分别回答不同问题。


2. 本章代码结构:第一次把"可测试逻辑"和"ROS 包装层"拆开

阶段 09 的 ros1_comm_lab 主要是运行机制实验。阶段 10 在同一个 package 中增加:

text 复制代码
ros1_comm_lab/
├── CMakeLists.txt
├── package.xml
├── README.md
├── include/
│   └── ros1_comm_lab/
│       └── message_processor.h
├── src/
│   ├── custom_queue_lab.cpp
│   ├── message_processor.cpp
│   ├── spinner_lab.cpp
│   └── testable_node.cpp
├── launch/
│   ├── custom_queue_lab.launch
│   ├── spinner_lab.launch
│   └── testable_node.launch
└── test/
    ├── test_message_processor.cpp
    ├── test_testable_node.cpp
    └── testable_node.test

新的实验链路非常简单:

text 复制代码
/comm_lab/test_input
        ↓ std_msgs/UInt32
   testable_node
        ↓
 MessageProcessor
        ↓
/comm_lab/test_output

默认处理规则:

text 复制代码
output = input * multiplier + bias

默认参数:

text 复制代码
multiplier = 2
bias       = 3
max_input  = 100

因此:

text 复制代码
input = 7
output = 7 * 2 + 3 = 17

如果:

text 复制代码
input > max_input

或者计算结果超出:

text 复制代码
uint32_t

范围,则处理失败,Node 不发布输出。

这个逻辑本身并不复杂。这里故意保持简单,因为本章重点是测试边界,而不是算法。

本节小结: MessageProcessor 只承载可脱离 ROS 测试的业务规则;testable_node 只负责 ROS 参数、Topic 和日志。


3. Docker 环境为什么要补测试与 rqt 工具

现有 Dockerfile 已经有:

text 复制代码
ros-noetic-rqt-graph
ros-noetic-rqt-console

阶段 10 继续补充:

text 复制代码
libgtest-dev
ros-noetic-rostest
ros-noetic-rqt-plot
ros-noetic-rqt-topic
ros-noetic-rosbag

对应职责:

package 本章用途
libgtest-dev 给 catkin 提供 GoogleTest header/source/library 环境
ros-noetic-rostest ROS launch 级集成测试
ros-noetic-rqt-topic GUI 查看 Topic、消息和频率
ros-noetic-rqt-plot 画数值 Topic 随时间变化
ros-noetic-rosbag 录制和回放 Topic

Dockerfile 修改以后,需要重新构建镜像:

bash 复制代码
cd /home/wdfk/share/ros1-docker

docker compose build ros1-dev
docker compose up -d --force-recreate ros1-dev

进入 Container:

bash 复制代码
docker compose exec ros1-dev bash

确认:

bash 复制代码
which rostest
which rosbag
which rqt_plot
which rqt_topic

这里不要只确认命令存在。真正的验收仍然是后面的测试能执行。

本节小结: 测试工具属于开发镜像的一部分,应该写入 Dockerfile,而不是每次进入 Container 后临时安装。


4. MessageProcessor:为什么纯逻辑层不应该依赖 ROS

公开头文件:

text 复制代码
include/ros1_comm_lab/message_processor.h

核心接口:

cpp 复制代码
class MessageProcessor
{
public:
    MessageProcessor(uint32_t multiplier,
                     uint32_t bias,
                     uint32_t max_input);

    bool process(uint32_t input, uint32_t& output) const;
};

这里没有:

text 复制代码
ros::NodeHandle
ros::Publisher
ros::Subscriber
ros::Time
std_msgs

这意味着测试 MessageProcessor 时不需要:

text 复制代码
roscore
Master
TCPROS
CallbackQueue
Spinner

实现:

cpp 复制代码
bool MessageProcessor::process(uint32_t input, uint32_t& output) const
{
    if (input > max_input_)
    {
        return false;
    }

    const uint64_t value =
        static_cast<uint64_t>(input) * multiplier_ + bias_;

    if (value > std::numeric_limits<uint32_t>::max())
    {
        return false;
    }

    output = static_cast<uint32_t>(value);
    return true;
}

这里有两个明确的失败契约:

text 复制代码
输入越界
结果 uint32_t 溢出

并且失败时:

text 复制代码
output 不修改

这一点会直接进入单元测试断言。

为什么中间计算使用 uint64_t

如果直接:

cpp 复制代码
uint32_t value = input * multiplier_ + bias_;

溢出可能先在 uint32_t 中发生,之后已经无法判断原始数学结果是否超过范围。

因此先扩大到:

cpp 复制代码
uint64_t

再比较:

cpp 复制代码
std::numeric_limits<uint32_t>::max()

这是普通 C++ 行为测试,不需要 ROS runtime。

本节小结: 能从 ROS wrapper 中拆出的业务逻辑,应尽量保持普通 C++ 接口,使最重要的边界条件可以快速、稳定地单测。


5. testable_node:ROS 层只负责参数、Topic 和错误传播

Node:

text 复制代码
src/testable_node.cpp

启动时读取:

text 复制代码
~multiplier
~bias
~max_input
~input_topic
~output_topic

默认 Topic:

text 复制代码
/comm_lab/test_input
/comm_lab/test_output

正常 callback 主干只有:

cpp 复制代码
void inputCallback(const std_msgs::UInt32::ConstPtr& msg)
{
    uint32_t output = 0;
    if (!processor_->process(msg->data, output))
    {
        ROS_WARN_STREAM(
            "drop input=" << msg->data
            << " because it violates processor constraints");
        return;
    }

    std_msgs::UInt32 output_msg;
    output_msg.data = output;
    publisher_.publish(output_msg);
}

它不重新实现:

text 复制代码
范围判断
乘法
加法
溢出检查

而只是调用 MessageProcessor

这形成第一次比较明确的结构:
#mermaid-svg-o7yoX6x4jdGsyD0R{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-o7yoX6x4jdGsyD0R .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-o7yoX6x4jdGsyD0R .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-o7yoX6x4jdGsyD0R .error-icon{fill:#552222;}#mermaid-svg-o7yoX6x4jdGsyD0R .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-o7yoX6x4jdGsyD0R .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-o7yoX6x4jdGsyD0R .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-o7yoX6x4jdGsyD0R .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-o7yoX6x4jdGsyD0R .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-o7yoX6x4jdGsyD0R .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-o7yoX6x4jdGsyD0R .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-o7yoX6x4jdGsyD0R .marker{fill:#333333;stroke:#333333;}#mermaid-svg-o7yoX6x4jdGsyD0R .marker.cross{stroke:#333333;}#mermaid-svg-o7yoX6x4jdGsyD0R svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-o7yoX6x4jdGsyD0R p{margin:0;}#mermaid-svg-o7yoX6x4jdGsyD0R .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-o7yoX6x4jdGsyD0R .cluster-label text{fill:#333;}#mermaid-svg-o7yoX6x4jdGsyD0R .cluster-label span{color:#333;}#mermaid-svg-o7yoX6x4jdGsyD0R .cluster-label span p{background-color:transparent;}#mermaid-svg-o7yoX6x4jdGsyD0R .label text,#mermaid-svg-o7yoX6x4jdGsyD0R span{fill:#333;color:#333;}#mermaid-svg-o7yoX6x4jdGsyD0R .node rect,#mermaid-svg-o7yoX6x4jdGsyD0R .node circle,#mermaid-svg-o7yoX6x4jdGsyD0R .node ellipse,#mermaid-svg-o7yoX6x4jdGsyD0R .node polygon,#mermaid-svg-o7yoX6x4jdGsyD0R .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-o7yoX6x4jdGsyD0R .rough-node .label text,#mermaid-svg-o7yoX6x4jdGsyD0R .node .label text,#mermaid-svg-o7yoX6x4jdGsyD0R .image-shape .label,#mermaid-svg-o7yoX6x4jdGsyD0R .icon-shape .label{text-anchor:middle;}#mermaid-svg-o7yoX6x4jdGsyD0R .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-o7yoX6x4jdGsyD0R .rough-node .label,#mermaid-svg-o7yoX6x4jdGsyD0R .node .label,#mermaid-svg-o7yoX6x4jdGsyD0R .image-shape .label,#mermaid-svg-o7yoX6x4jdGsyD0R .icon-shape .label{text-align:center;}#mermaid-svg-o7yoX6x4jdGsyD0R .node.clickable{cursor:pointer;}#mermaid-svg-o7yoX6x4jdGsyD0R .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-o7yoX6x4jdGsyD0R .arrowheadPath{fill:#333333;}#mermaid-svg-o7yoX6x4jdGsyD0R .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-o7yoX6x4jdGsyD0R .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-o7yoX6x4jdGsyD0R .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-o7yoX6x4jdGsyD0R .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-o7yoX6x4jdGsyD0R .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-o7yoX6x4jdGsyD0R .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-o7yoX6x4jdGsyD0R .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-o7yoX6x4jdGsyD0R .cluster text{fill:#333;}#mermaid-svg-o7yoX6x4jdGsyD0R .cluster span{color:#333;}#mermaid-svg-o7yoX6x4jdGsyD0R div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-o7yoX6x4jdGsyD0R .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-o7yoX6x4jdGsyD0R rect.text{fill:none;stroke-width:0;}#mermaid-svg-o7yoX6x4jdGsyD0R .icon-shape,#mermaid-svg-o7yoX6x4jdGsyD0R .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-o7yoX6x4jdGsyD0R .icon-shape p,#mermaid-svg-o7yoX6x4jdGsyD0R .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-o7yoX6x4jdGsyD0R .icon-shape .label rect,#mermaid-svg-o7yoX6x4jdGsyD0R .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-o7yoX6x4jdGsyD0R .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-o7yoX6x4jdGsyD0R .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-o7yoX6x4jdGsyD0R :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} success
failure
ROS Subscriber
testable_node callback
MessageProcessor
ROS Publisher
ROS_WARN + no publish

以后进入真正 Driver,可以把同一个思想扩大成:

text 复制代码
ROS Interface
    ↓
Driver Core / Protocol
    ↓
Transport

本章只是先用一个极小例子建立这种测试思维。


6. catkin_add_gtest() 到底做了什么

CMake 中:

cmake 复制代码
if(CATKIN_ENABLE_TESTING)
  find_package(rostest REQUIRED)

  catkin_add_gtest(test_message_processor
    test/test_message_processor.cpp
  )

  ...
endif()

这里先看:

text 复制代码
CATKIN_ENABLE_TESTING

测试 target 只在 catkin 测试启用时建立,不应该把测试 executable 当成正常运行时 Node target。

Noetic catkingtest.cmake 中,catkin_add_gtest() 会创建一个测试 executable,并把它加入 catkin 的 test target;运行时会给 GTest 指定 XML 输出路径。

所以:

cmake 复制代码
catkin_add_gtest(...)

不是:

text 复制代码
立即执行测试

而是:

text 复制代码
声明测试 target
    ↓
加入 run_tests 体系
    ↓
后续由 catkin test target 构建/执行

当前单测还链接:

cmake 复制代码
target_link_libraries(test_message_processor
  ros1_comm_lab_core
)

它直接测试我们自己的 pure C++ library。

package.xml 为什么只有 test_depend

新增:

xml 复制代码
<test_depend>rostest</test_depend>

因为 rostest 只属于测试期依赖,不是 Node 正常运行所需的 runtime dependency。

本节小结: catkin_add_gtest() 把 C++ 单测注册进 catkin 测试体系;测试 target 与正常运行时 executable 应分开理解。


7. 第一组自动测试:纯逻辑 gtest

文件:

text 复制代码
test/test_message_processor.cpp

本章没有只测一条 happy path,而是覆盖四类行为。

7.1 正常输入

cpp 复制代码
TEST(MessageProcessorTest, ProcessesNominalInput)
{
    const ros1_comm_lab::MessageProcessor processor(2, 3, 100);

    uint32_t output = 0;
    ASSERT_TRUE(processor.process(7, output));
    EXPECT_EQ(17u, output);
}

验证:

text 复制代码
7 * 2 + 3 = 17

7.2 边界值

cpp 复制代码
processor.process(100, output)

100 正好等于:

text 复制代码
max_input

应该被接受,输出:

text 复制代码
203

这里明确测试"最大合法值",而不是只测中间值。

7.3 非法输入

text 复制代码
input = 101
max_input = 100

期望:

text 复制代码
process() == false
output 保持旧值

也就是说测试不仅检查失败,还检查失败后的状态保持。

7.4 算术溢出

构造:

text 复制代码
multiplier = 2
bias       = 1
input      = UINT32_MAX

数学结果明显超过:

text 复制代码
uint32_t

范围。

期望同样是:

text 复制代码
返回 false
output 不改变

为什么这组测试比"Node 跑起来了"更有价值

因为它精确证明:

text 复制代码
正常行为
边界行为
非法输入行为
失败后的状态保持
溢出保护

这些行为不需要等待 ROS 网络和 callback 才能验证。

本节小结: 单测应该围绕行为契约写断言,不是为了增加测试文件数量,也不是为了追求一条"PASS"日志。


8. add_rostest_gtest():为什么 Node 集成测试需要另一个层次

同一个 CMake 测试块中还有:

cmake 复制代码
add_rostest_gtest(test_testable_node
  test/testable_node.test
  test/test_testable_node.cpp
)

Noetic 的 rostest-extras.cmake.em 中,add_rostest_gtest() 会:

text 复制代码
创建 GTest executable
    +
注册对应 .test launch 文件
    +
让 rostest 启动 ROS 测试环境后执行该 GTest

这与纯:

cmake 复制代码
catkin_add_gtest(...)

最大的区别不是"换了一个宏",而是测试环境中真正出现了 ROS graph。

当前 .test 文件:

xml 复制代码
<launch>
  <node pkg="ros1_comm_lab"
        type="testable_node"
        name="testable_node"
        output="screen">
    <param name="multiplier" value="2"/>
    <param name="bias" value="3"/>
    <param name="max_input" value="100"/>
  </node>

  <test test-name="test_testable_node"
        pkg="ros1_comm_lab"
        type="test_testable_node"
        time-limit="20.0"/>
</launch>

也就是说测试启动关系已经变成:
#mermaid-svg-vFFGH14vCxfiyrtw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vFFGH14vCxfiyrtw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vFFGH14vCxfiyrtw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vFFGH14vCxfiyrtw .error-icon{fill:#552222;}#mermaid-svg-vFFGH14vCxfiyrtw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vFFGH14vCxfiyrtw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vFFGH14vCxfiyrtw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vFFGH14vCxfiyrtw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vFFGH14vCxfiyrtw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vFFGH14vCxfiyrtw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vFFGH14vCxfiyrtw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vFFGH14vCxfiyrtw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vFFGH14vCxfiyrtw .marker.cross{stroke:#333333;}#mermaid-svg-vFFGH14vCxfiyrtw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vFFGH14vCxfiyrtw p{margin:0;}#mermaid-svg-vFFGH14vCxfiyrtw .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vFFGH14vCxfiyrtw .cluster-label text{fill:#333;}#mermaid-svg-vFFGH14vCxfiyrtw .cluster-label span{color:#333;}#mermaid-svg-vFFGH14vCxfiyrtw .cluster-label span p{background-color:transparent;}#mermaid-svg-vFFGH14vCxfiyrtw .label text,#mermaid-svg-vFFGH14vCxfiyrtw span{fill:#333;color:#333;}#mermaid-svg-vFFGH14vCxfiyrtw .node rect,#mermaid-svg-vFFGH14vCxfiyrtw .node circle,#mermaid-svg-vFFGH14vCxfiyrtw .node ellipse,#mermaid-svg-vFFGH14vCxfiyrtw .node polygon,#mermaid-svg-vFFGH14vCxfiyrtw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vFFGH14vCxfiyrtw .rough-node .label text,#mermaid-svg-vFFGH14vCxfiyrtw .node .label text,#mermaid-svg-vFFGH14vCxfiyrtw .image-shape .label,#mermaid-svg-vFFGH14vCxfiyrtw .icon-shape .label{text-anchor:middle;}#mermaid-svg-vFFGH14vCxfiyrtw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vFFGH14vCxfiyrtw .rough-node .label,#mermaid-svg-vFFGH14vCxfiyrtw .node .label,#mermaid-svg-vFFGH14vCxfiyrtw .image-shape .label,#mermaid-svg-vFFGH14vCxfiyrtw .icon-shape .label{text-align:center;}#mermaid-svg-vFFGH14vCxfiyrtw .node.clickable{cursor:pointer;}#mermaid-svg-vFFGH14vCxfiyrtw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vFFGH14vCxfiyrtw .arrowheadPath{fill:#333333;}#mermaid-svg-vFFGH14vCxfiyrtw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vFFGH14vCxfiyrtw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vFFGH14vCxfiyrtw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vFFGH14vCxfiyrtw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vFFGH14vCxfiyrtw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vFFGH14vCxfiyrtw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vFFGH14vCxfiyrtw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vFFGH14vCxfiyrtw .cluster text{fill:#333;}#mermaid-svg-vFFGH14vCxfiyrtw .cluster span{color:#333;}#mermaid-svg-vFFGH14vCxfiyrtw div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vFFGH14vCxfiyrtw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vFFGH14vCxfiyrtw rect.text{fill:none;stroke-width:0;}#mermaid-svg-vFFGH14vCxfiyrtw .icon-shape,#mermaid-svg-vFFGH14vCxfiyrtw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vFFGH14vCxfiyrtw .icon-shape p,#mermaid-svg-vFFGH14vCxfiyrtw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vFFGH14vCxfiyrtw .icon-shape .label rect,#mermaid-svg-vFFGH14vCxfiyrtw .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vFFGH14vCxfiyrtw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vFFGH14vCxfiyrtw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vFFGH14vCxfiyrtw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} publish
publish
rostest
ROS Master
testable_node
test_testable_node
/comm_lab/test_input
/comm_lab/test_output

现在测试的已经不是单独一个函数,而是:

text 复制代码
Node 是否启动
参数是否生效
Subscriber 是否建立
callback 是否执行
Publisher 是否产生正确输出
非法输入是否被抑制

这就是 Node 集成测试。


9. 集成测试为什么先等连接,而不是 publish 完马上断言

test_testable_node.cpp 中,SetUp() 会创建:

text 复制代码
测试 Publisher -> /comm_lab/test_input
测试 Subscriber <- /comm_lab/test_output

随后等待:

cpp 复制代码
publisher_.getNumSubscribers() > 0
subscriber_.getNumPublishers() > 0

最长:

text 复制代码
2 s

为什么不能一创建 Publisher 就立刻:

cpp 复制代码
publisher_.publish(msg);

然后断言?

因为阶段 07 已经学过:

text 复制代码
Publisher / Subscriber
    ↓
Master 注册发现
    ↓
requestTopic
    ↓
TCPROS 建链

ROS Topic 连接建立不是"构造对象后同步瞬间全部完成"。

如果测试不先确认连接,就可能得到:

text 复制代码
测试进程发送太早
    ↓
Subscriber 尚未连接
    ↓
消息丢失
    ↓
测试偶发失败

那不是业务逻辑失败,而是测试本身存在竞态。

所以集成测试首先建立:

text 复制代码
测试前置条件已成立

再开始发送数据。

为什么测试进程使用 AsyncSpinner(1)

测试进程自己也有 Subscriber callback:

text 复制代码
/comm_lab/test_output

如果没有 Spinner,输出消息即使已经进入测试进程,也不会自动执行测试 callback。

所以每个测试 fixture 在 SetUp() 中启动自己的:

cpp 复制代码
ros::AsyncSpinner spinner_{1};
spinner_.start();

并在 TearDown() 中先 stop(),再 shutdown Publisher/Subscriber。这样 callback worker 不会跨越 fixture 生命周期继续访问已经销毁的测试对象。

这正好把阶段 09 的 Spinner 与 callback 生命周期知识用于测试代码本身。

本节小结: ROS 集成测试同样必须尊重注册发现、TCPROS 建链和 callback 调度边界,否则测试代码本身就会制造竞态。


10. 集成测试具体证明什么

第一组:

cpp 复制代码
TEST_F(TestableNodeIntegrationTest,
       PublishesTransformedNominalAndBoundaryValues)

验证:

text 复制代码
input = 7
    -> output = 17

input = 100
    -> output = 203

这里同时验证:

text 复制代码
Topic 名
参数
Node callback
MessageProcessor 调用
Publisher
Subscriber

第二组:

cpp 复制代码
TEST_F(TestableNodeIntegrationTest,
       DoesNotPublishForOutOfRangeInput)

发送:

text 复制代码
101

因为:

text 复制代码
max_input = 100

期望在短时间窗口内:

text 复制代码
没有新的 /comm_lab/test_output

这里的"等待 0.5 s 未收到消息"是一个明确的负向行为断言。

但必须知道它的边界:

这是当前本地 Node 集成测试中的超时窗口,不是实时系统时序保证,也不能证明目标机器人在所有负载下都满足 500 ms 的某种实时要求。

不要把测试等待窗口误写成产品 timing contract。


11. 在 ros1-dev 中运行测试

Docker 镜像重新构建后:

bash 复制代码
docker compose exec ros1-dev bash

进入 workspace:

bash 复制代码
cd /workspace/ros_ws

先构建 package:

bash 复制代码
catkin build ros1_comm_lab

然后运行 package 测试:

bash 复制代码
catkin run_tests ros1_comm_lab

最后汇总:

bash 复制代码
catkin_test_results

这里应该把两个层次分开看:

text 复制代码
test_message_processor
    -> pure C++ gtest

test_testable_node
    -> rostest + ROS graph + gtest

不要只看命令退出码

继续看测试结果目录:

bash 复制代码
find /workspace/ros_ws/build/ros1_comm_lab/test_results \
    -maxdepth 3 -type f -print

XML 中会记录:

text 复制代码
test case
failure
error
execution time

这样以后 CI 也能读取相同格式的结果。

当前已经取得的真实运行证据

本章代码随后已经在实际 ros1-dev Noetic Container 中完成构建与测试。catkin build ros1_comm_lab 返回:

text 复制代码
[build] Summary: All 1 packages succeeded!
[build]   Warnings:  None.
[build]   Failed:    None.

第一次保持正确实现运行:

bash 复制代码
catkin run_tests ros1_comm_lab
catkin_test_results

纯逻辑层实际执行了 4 个 GTest:

text 复制代码
ProcessesNominalInput
AcceptsMaximumConfiguredInput
RejectsInputAboveConfiguredLimitWithoutChangingOutput
RejectsArithmeticOverflowWithoutChangingOutput

4 个测试全部通过。随后 rostest 启动 /testable_node,两组 Node 集成测试也通过:

text 复制代码
PublishesTransformedNominalAndBoundaryValues
DoesNotPublishForOutOfRangeInput

最终汇总证据为:

text 复制代码
Summary: 13 tests, 0 errors, 0 failures, 0 skipped

测试结果文件实际生成在:

text 复制代码
/workspace/ros_ws/build/ros1_comm_lab/test_results/ros1_comm_lab/
├── gtest-test_message_processor.xml
├── rostest-test_testable_node.xml
└── rosunit-test_testable_node.xml

因此当前证据边界已经更新为:

text 复制代码
pure C++ core self-check       已执行
ROS CMake/catkin build         已执行并通过
GTest runtime                  已执行并通过
rostest runtime                已执行并通过
故意失败验证                   已执行并成功触发失败
rqt GUI                        已执行并取得图形证据
rosbag runtime                 尚待本章后续实验完成

这里仍然要保持证据边界:catkin、GTest、rostest 和 rqt 已经有真实运行结果,但它们仍不能证明真实 CAN/UART 设备时序、硬件恢复能力或 HIL 行为。


12. 怎样证明测试真的能发现问题:做一次可恢复的故意失败

一个测试长期只有绿色,不能自动证明它真的能捕获行为错误。

最简单的练习是在本地临时把单测:

cpp 复制代码
EXPECT_EQ(17u, output);

改成错误期望:

cpp 复制代码
EXPECT_EQ(18u, output);

然后:

bash 复制代码
catkin run_tests ros1_comm_lab
catkin_test_results

这时 ProcessesNominalInput 应失败,并明确显示:

text 复制代码
actual:   17
expected: 18

验证完成后立刻恢复:

cpp 复制代码
EXPECT_EQ(17u, output);

重新运行,确认回到 PASS。

这一步的目的不是制造一个永久失败测试,而是确认:

当前断言确实约束了我们想保护的行为。

同理,也可以临时把 .test 中:

xml 复制代码
<param name="multiplier" value="2"/>

改成:

xml 复制代码
<param name="multiplier" value="3"/>

而不修改测试期望。集成测试应该失败。

实际故障注入也已经执行过:把断言临时改成 EXPECT_EQ(18u, output) 后,测试报告实际值 17、期望值 18;随后把 multiplier 改成 7 时,Node 实际输出变成 52703,而测试仍期待 17203,因此 rostest 正确失败。这证明当前测试不是"无论代码怎样都绿"的空壳测试。

完成故障注入后应把断言和参数恢复为正确值,再运行一次完整测试确认回到全绿。


13. rqt_graph:验证测试 Node 的 ROS graph 是否符合预期

手工启动:

bash 复制代码
roslaunch ros1_comm_lab testable_node.launch

另一个终端:

bash 复制代码
rqt_graph

然后持续发送:

bash 复制代码
rostopic pub -r 1 \
    /comm_lab/test_input \
    std_msgs/UInt32 \
    "data: 7"

图中应该能看到:

text 复制代码
rostopic publisher
    ↓ /comm_lab/test_input
/testable_node
    ↓ /comm_lab/test_output
rostopic/rqt subscriber

rqt_graph 回答的是:

text 复制代码
谁连接了谁?

实际运行时已经能够看到 rostopic Publisher 经 /comm_lab/test_input 连接到 /testable_node。同一轮观察中还同时打开了 rqt_topicrqt_consolerqt_plot

这张图提供的是运行时外部观察证据:ROS graph 已建立、Topic 能被工具发现、数值 Topic 可以进入 plot。它仍然不替代 GTest/rostest 的自动断言。

Docker 中怎样避免每次手动执行 xhost

rqt_graph 在 Container 中通过 X11 显示到 Ubuntu Host 时,如果每次登录后都要手工执行:

bash 复制代码
xhost +SI:localuser:$USER

可以把这条授权做成 GNOME 登录后的自动启动项。该方式仍然只授权当前本地用户,不使用权限范围过大的 xhost +

在 Ubuntu Host 图形桌面终端执行一次:

bash 复制代码
mkdir -p ~/.config/autostart

cat > ~/.config/autostart/ros-docker-x11.desktop <<'EOF'
[Desktop Entry]
Type=Application
Name=Allow ROS Docker X11
Exec=/bin/sh -c '/usr/bin/xhost +SI:localuser:$(id -un) >/dev/null 2>&1'
X-GNOME-Autostart-enabled=true
NoDisplay=true
EOF

之后重新登录 GNOME 图形桌面,授权会在图形 Session 启动时自动执行。由于 Host 用户和 Container 中的 dev 使用相同 UID,这一授权能够覆盖当前项目通过 /tmp/.X11-unix 访问 X Server 的场景。

该自动启动项只解决 X11 访问授权,不负责修正 DISPLAYcompose.yaml 仍应把 Host 实际的 DISPLAY 传入 Container,并继续挂载:

text 复制代码
/tmp/.X11-unix:/tmp/.X11-unix

如果图形 Session 的 DISPLAY 不是 :0,应以 Ubuntu 图形桌面终端中的实际 echo "$DISPLAY" 为准。

它不回答:

text 复制代码
7 是否一定变成 17?

后者属于测试断言或数据观察。


14. rqt_topic:看 Topic 当前状态,不只用 rostopic list

执行:

bash 复制代码
rqt_topic

重点观察:

text 复制代码
/comm_lab/test_input
/comm_lab/test_output

可以查看:

text 复制代码
message type
publisher/subscriber
rate
bandwidth
当前值

如果 /comm_lab/test_input 有数据,而 /comm_lab/test_output 始终没有数据,可以继续组合:

text 复制代码
rqt_console
rostopic echo
rosnode info

缩小问题范围。

这里开始形成真正的运行排查思路:

text 复制代码
Node 在不在?
    ↓
Topic 在不在?
    ↓
连接在不在?
    ↓
输入有没有?
    ↓
callback 是否执行?
    ↓
输出有没有?

15. rqt_plot:把输入/输出关系画出来

启动:

bash 复制代码
rqt_plot

添加:

text 复制代码
/comm_lab/test_input/data
/comm_lab/test_output/data

然后发送不同输入:

bash 复制代码
rostopic pub -r 1 \
    /comm_lab/test_input \
    std_msgs/UInt32 \
    "data: 10"

也可以分别手工发送:

text 复制代码
1
5
10
20

默认参数下输出对应:

text 复制代码
5
13
23
43

因为:

text 复制代码
output = input * 2 + 3

rqt_plot 很适合以后观察:

text 复制代码
encoder
velocity
temperature
current
odom
IMU

这类连续数值。

但图形"看起来正确"仍然不是自动断言。


16. rqt_console:故障注入时观察日志证据

正常启动:

bash 复制代码
roslaunch ros1_comm_lab testable_node.launch

打开:

bash 复制代码
rqt_console

发送合法值:

bash 复制代码
rostopic pub -1 \
    /comm_lab/test_input \
    std_msgs/UInt32 \
    "data: 7"

再发送越界值:

bash 复制代码
rostopic pub -1 \
    /comm_lab/test_input \
    std_msgs/UInt32 \
    "data: 101"

Node 会进入:

cpp 复制代码
ROS_WARN_STREAM(...)

所以 rqt_console 应出现 warning。

与此同时:

bash 复制代码
rostopic echo /comm_lab/test_output

不应该因为 101 产生一条新输出。

这里第一次把:

text 复制代码
自动测试中的"invalid input 不 publish"

和:

text 复制代码
运行时日志中的 warning

对照起来。


17. rosbag:把一次 Topic 输入变成可重复输入

手工 rostopic pub 每次都要重新输入。

rosbag 可以把一段输入保存下来:

text 复制代码
一次输入序列
    ↓
record
    ↓
.bag
    ↓
反复 play
    ↓
每次代码修改后重放同一输入

17.1 只录输入,不先录输出

启动 Node:

bash 复制代码
roslaunch ros1_comm_lab testable_node.launch

另一个终端:

bash 复制代码
rosbag record \
    -O /tmp/comm_lab_input.bag \
    /comm_lab/test_input

再发送几组数据:

bash 复制代码
rostopic pub -1 /comm_lab/test_input std_msgs/UInt32 "data: 1"
rostopic pub -1 /comm_lab/test_input std_msgs/UInt32 "data: 7"
rostopic pub -1 /comm_lab/test_input std_msgs/UInt32 "data: 100"
rostopic pub -1 /comm_lab/test_input std_msgs/UInt32 "data: 101"

Ctrl+C 停止录制。

查看:

bash 复制代码
rosbag info /tmp/comm_lab_input.bag

17.2 重放同一输入

重新启动或修改 Node 后:

bash 复制代码
rosbag play /tmp/comm_lab_input.bag

同时:

bash 复制代码
rostopic echo /comm_lab/test_output

默认规则下:

text 复制代码
1   -> 5
7   -> 17
100 -> 203
101 -> no output

现在输入序列已经固定,不再依赖每次手工输入是否一致。

rosbag 在这里能证明什么

它证明:

text 复制代码
测试输入可以重复

但如果只是人工 echo 输出,它仍然没有自动断言。

以后阶段 18 可以继续把:

text 复制代码
固定 bag 输入
    +
自动比较期望输出

做成更完整的回归测试。

本节小结: rosbag 的工程价值不只是"录数据",而是把现场或实验输入冻结成后续可以重复重放的测试材料。


18. 本章的三种最小故障注入

故障注入的重点不是"把程序搞崩",而是明确:

text 复制代码
给系统一个已知异常
    ↓
观察它是否按契约失败

18.1 输入越界

默认:

text 复制代码
max_input = 100

发送:

bash 复制代码
rostopic pub -1 /comm_lab/test_input std_msgs/UInt32 "data: 101"

期望:

text 复制代码
warning
no output
Node 继续存活

18.2 改小 max_input

启动:

bash 复制代码
roslaunch ros1_comm_lab testable_node.launch max_input:=5

再发送:

text 复制代码
7

此时以前合法的 7 变成非法输入。

这能验证:

text 复制代码
launch arg
    ↓
private parameter
    ↓
Node 构造
    ↓
MessageProcessor contract

整条参数链是否生效。

18.3 非法负参数

启动:

bash 复制代码
roslaunch ros1_comm_lab testable_node.launch multiplier:=-1

testable_node 对非负参数做启动检查,应该:

text 复制代码
ROS_FATAL
不创建 Publisher / Subscriber
process return 2

这是一类"配置错误应在启动阶段失败"的实验。

不要为了让 Node 强行跑起来而把非法配置静默改成默认值。真实驱动中,默认值和回退策略必须来自明确契约。


19. fake / mock 为什么只在本章先建立概念

当前 MessageProcessor 不依赖硬件,因此不需要 fake device。

但进入真实 Driver 后,典型结构会变成:

text 复制代码
ROS Node
   ↓
Driver Core
   ↓
Transport Interface
   ↓
Serial / SocketCAN / TCP

如果 Driver Core 直接写死:

cpp 复制代码
::read(fd, ...)
::write(fd, ...)

测试很难模拟:

text 复制代码
timeout
short read
CRC error
device offline
reconnect

所以后续会引入:

text 复制代码
真实 Transport
Fake Transport
Mock Transport

本章只建立一个原则:

把可测试的业务/协议逻辑和 ROS、Linux I/O、真实设备边界分开。

阶段 12~18 再把这个原则扩展成真正的 Driver 分层。


20. rqt、rosbag、gtest、rostest 应该怎样组合

现在可以把四类工具放进一个诊断/回归闭环:
#mermaid-svg-jwm5y6odbUP1lflL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jwm5y6odbUP1lflL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jwm5y6odbUP1lflL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jwm5y6odbUP1lflL .error-icon{fill:#552222;}#mermaid-svg-jwm5y6odbUP1lflL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jwm5y6odbUP1lflL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jwm5y6odbUP1lflL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jwm5y6odbUP1lflL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jwm5y6odbUP1lflL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jwm5y6odbUP1lflL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jwm5y6odbUP1lflL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jwm5y6odbUP1lflL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jwm5y6odbUP1lflL .marker.cross{stroke:#333333;}#mermaid-svg-jwm5y6odbUP1lflL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jwm5y6odbUP1lflL p{margin:0;}#mermaid-svg-jwm5y6odbUP1lflL .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-jwm5y6odbUP1lflL .cluster-label text{fill:#333;}#mermaid-svg-jwm5y6odbUP1lflL .cluster-label span{color:#333;}#mermaid-svg-jwm5y6odbUP1lflL .cluster-label span p{background-color:transparent;}#mermaid-svg-jwm5y6odbUP1lflL .label text,#mermaid-svg-jwm5y6odbUP1lflL span{fill:#333;color:#333;}#mermaid-svg-jwm5y6odbUP1lflL .node rect,#mermaid-svg-jwm5y6odbUP1lflL .node circle,#mermaid-svg-jwm5y6odbUP1lflL .node ellipse,#mermaid-svg-jwm5y6odbUP1lflL .node polygon,#mermaid-svg-jwm5y6odbUP1lflL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jwm5y6odbUP1lflL .rough-node .label text,#mermaid-svg-jwm5y6odbUP1lflL .node .label text,#mermaid-svg-jwm5y6odbUP1lflL .image-shape .label,#mermaid-svg-jwm5y6odbUP1lflL .icon-shape .label{text-anchor:middle;}#mermaid-svg-jwm5y6odbUP1lflL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-jwm5y6odbUP1lflL .rough-node .label,#mermaid-svg-jwm5y6odbUP1lflL .node .label,#mermaid-svg-jwm5y6odbUP1lflL .image-shape .label,#mermaid-svg-jwm5y6odbUP1lflL .icon-shape .label{text-align:center;}#mermaid-svg-jwm5y6odbUP1lflL .node.clickable{cursor:pointer;}#mermaid-svg-jwm5y6odbUP1lflL .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-jwm5y6odbUP1lflL .arrowheadPath{fill:#333333;}#mermaid-svg-jwm5y6odbUP1lflL .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-jwm5y6odbUP1lflL .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-jwm5y6odbUP1lflL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jwm5y6odbUP1lflL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-jwm5y6odbUP1lflL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jwm5y6odbUP1lflL .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-jwm5y6odbUP1lflL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-jwm5y6odbUP1lflL .cluster text{fill:#333;}#mermaid-svg-jwm5y6odbUP1lflL .cluster span{color:#333;}#mermaid-svg-jwm5y6odbUP1lflL div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-jwm5y6odbUP1lflL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-jwm5y6odbUP1lflL rect.text{fill:none;stroke-width:0;}#mermaid-svg-jwm5y6odbUP1lflL .icon-shape,#mermaid-svg-jwm5y6odbUP1lflL .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jwm5y6odbUP1lflL .icon-shape p,#mermaid-svg-jwm5y6odbUP1lflL .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-jwm5y6odbUP1lflL .icon-shape .label rect,#mermaid-svg-jwm5y6odbUP1lflL .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jwm5y6odbUP1lflL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-jwm5y6odbUP1lflL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-jwm5y6odbUP1lflL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} gtest: pure logic
rostest: ROS Node integration
rqt_graph/topic: runtime topology
rqt_console/plot: logs and trends
rosbag record: freeze input
rosbag play: replay same input

一个比较实用的工作顺序是:

text 复制代码
代码修改
    ↓
先跑纯逻辑 gtest
    ↓
再跑 rostest
    ↓
需要人工定位时打开 rqt
    ↓
问题依赖一段输入时录 rosbag
    ↓
修复后重放同一 bag

而不是每次出现问题都直接:

text 复制代码
开十个终端
手工发消息
凭肉眼判断

21. 与真实 ROS 驱动开发的对应关系

本章虽然只处理一个整数,但测试思想可以直接映射到 Driver。

纯逻辑 gtest

以后可以测试:

text 复制代码
CAN frame -> protocol object
UART bytes -> packet
raw encoder -> velocity
CRC
sequence number
unit conversion
state transition

rostest

可以测试:

text 复制代码
参数加载
Topic 名
消息类型
Node 生命周期
输入 Topic -> 输出 Topic
Service 行为
Action 行为

rosbag

可以保存:

text 复制代码
IMU
LiDAR
encoder
odom
camera metadata

作为重复输入。

rqt

适合快速判断:

text 复制代码
连接有没有建立
频率是不是异常
值有没有跳变
WARN/ERROR 是否出现

但真实驱动仍然需要:

text 复制代码
fake device
mock transport
目标板
HIL

阶段 10 不把 Host/ROS 测试写成硬件正确性的证明。


22. 本章最终模型

阶段 06~09 建立的是:

text 复制代码
ROS1 Node 为什么这样运行

阶段 10 开始建立:

text 复制代码
怎样证明它仍然按预期运行

最终测试链:

text 复制代码
MessageProcessor
    ↓
纯 C++ gtest
    ↓
testable_node
    ↓
rostest
    ↓
ROS graph / Topic / callback
    ↓
rqt 外部观察
    ↓
rosbag 固定输入与回放
    ↓
故障注入

对驱动工程师而言,最重要的不是记住:

text 复制代码
gtest 命令是什么
rostest 命令是什么

而是每次遇到问题先问:

text 复制代码
这个行为能不能脱离 ROS 测?
    ↓
如果不能,需要哪个 ROS 集成边界?
    ↓
输入能不能固定下来?
    ↓
异常能不能稳定注入?
    ↓
我现在拿到的是自动断言、运行观察,还是硬件证据?

下一章进入阶段 11:Service 与 Action。届时不只学习 API,还会继续沿源码和测试思维追踪同步请求/响应、Action goal/feedback/result/cancel 以及它们在驱动、导航和机械臂中的适用边界。


参考源码与官方资料

  • catkin Noetic gtest.cmakehttps://github.com/ros/catkin/blob/noetic-devel/cmake/test/gtest.cmake
  • rostest Noetic rostest-extras.cmake.emhttps://github.com/ros/ros_comm/blob/noetic-devel/tools/rostest/cmake/rostest-extras.cmake.em
  • ROS Wiki rostesthttps://wiki.ros.org/rostest
  • ROS Wiki rosbaghttps://wiki.ros.org/rosbag
  • ROS Wiki rqt_graphhttps://wiki.ros.org/rqt_graph
  • ROS Wiki rqt_consolehttps://wiki.ros.org/rqt_console
  • ROS Wiki rqt_plothttps://wiki.ros.org/rqt_plot
  • ROS Wiki rqt_topichttps://wiki.ros.org/rqt_topic
  • GoogleTest Primer:https://google.github.io/googletest/primer.html
相关推荐
吴声子夜歌1 小时前
Shell编程实例——高级脚本编程(一)
linux·运维·网络·shell
Huangjin007_2 小时前
【Linux 系统篇(二十一)】进程(九):进程等待 wait/waitpid、status状态参数
linux·运维·服务器
cnkeysky3 小时前
docker 运行 nacos
docker·nacos
螺蛳粉 螺蛳粉3 小时前
mysql的备份与恢复
linux·运维·数据库·mysql
可乐鸡翅yeah_3 小时前
混合内容 Mixed‑Content 安全策略,HLS HTTPS 页面加载 HTTP 资源排错实战
运维·ffmpeg·音视频·媒体·m3u8
曦夜日长3 小时前
Linux系统篇,进程概念(四):内核链表的深度理解、进程优先级的底层理解
linux·运维·服务器·php
itCatface4 小时前
Linux离线部署Dify+Vllm(Qwen2.5-7b)
docker·ai·dify
安易算力5 小时前
昇腾生态开发深度实践:CANN算子库架构解析与MindSpore模型优化
网络·容器·架构·kubernetes·vllm
闲云自留地5 小时前
不用 docker!containerd 镜像 crctl 命令一把梭
容器