init: ROS2 learning suite
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
# 90 · 测试策略(完全指南)
|
||||
|
||||
> **目标**:理解 ROS2 三层测试金字塔,会用 gtest / pytest / colcon test,能把本仓库的测试策略用到自己的项目。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [1. 测试金字塔](#1-测试金字塔)
|
||||
- [2. C++ gtest 完整指南](#2-c-gtest-完整指南)
|
||||
- [3. Python pytest 完整指南](#3-python-pytest-完整指南)
|
||||
- [4. launch_testing(集成测试 launch 文件)](#4-launch_testing集成测试-launch-文件)
|
||||
- [5. 本仓库测试策略详解](#5-本仓库测试策略详解)
|
||||
- [6. 端到端自动化(进阶)](#6-端到端自动化进阶)
|
||||
- [7. 标记 / 跳过 / 覆盖率](#7-标记--跳过--覆盖率)
|
||||
- [8. CI 集成(GitHub Actions)](#8-ci-集成github-actions)
|
||||
- [9. 在本仓库里跑](#9-在本仓库里跑)
|
||||
- [10. 进阶:Mock DDS / 时间注入 / Fault Injection](#10-进阶mock-dds--时间注入--fault-injection)
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试金字塔
|
||||
|
||||
```
|
||||
┌────────────────┐
|
||||
│ 端到端 E2E │ ← launch_testing + 真实 launch
|
||||
│ (慢 / 1-2 个) │
|
||||
├────────────────┤
|
||||
│ 集成 in-process │ ← 同进程 spin + DDS 单跑
|
||||
│ (中等 / 4-6) │
|
||||
├────────────────┤
|
||||
│ 单元 Unit │ ← gtest / pytest 单函数
|
||||
│ (快 / 大量) │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
| 层 | 跑在哪 | 速度 | 数量 |
|
||||
|---|---|---|---|
|
||||
| 单元 | 容器内 / 本机 venv | 秒级 | 几十 ~ 几百 |
|
||||
| 集成 in-process | 容器内(colcon test) | 秒 ~ 分钟 | 几个 ~ 几十 |
|
||||
| 端到端 E2E | launch + 真实 DDS | 分钟级 | 1-5 个核心场景 |
|
||||
|
||||
**本仓库**:
|
||||
- 单元 + 集成:`colcon test` 一键跑(10 用例,秒级)
|
||||
- 端到端:固化 5 个 `docker/*_e2e.log` 手动验证
|
||||
|
||||
---
|
||||
|
||||
## 2. C++ gtest 完整指南
|
||||
|
||||
### 2.1 测试文件
|
||||
|
||||
```cpp
|
||||
#include "gtest/gtest.h"
|
||||
#include "rclcpp/rclcpp.hpp"
|
||||
#include "std_msgs/msg/string.hpp"
|
||||
|
||||
class PubsubTest : public ::testing::Test {
|
||||
protected:
|
||||
static void SetUpTestSuite() {
|
||||
rclcpp::init(0, nullptr); // 同进程共享一个 context
|
||||
}
|
||||
static void TearDownTestSuite() {
|
||||
rclcpp::shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(PubsubTest, MessageDataFieldIsNonEmpty) {
|
||||
std_msgs::msg::String msg;
|
||||
msg.data = "hi";
|
||||
EXPECT_FALSE(msg.data.empty());
|
||||
EXPECT_EQ(msg.data, "hi");
|
||||
}
|
||||
|
||||
TEST_F(PubsubTest, PublisherOnce) {
|
||||
auto node = std::make_shared<rclcpp::Node>("test_node");
|
||||
auto pub = node->create_publisher<std_msgs::msg::String>("chatter", 10);
|
||||
auto msg = std_msgs::msg::String();
|
||||
msg.data = "unit-test";
|
||||
pub->publish(msg);
|
||||
|
||||
auto exec = std::make_shared<rclcpp::executors::SingleThreadedExecutor>();
|
||||
exec->add_node(node);
|
||||
end = std::chrono::steady_clock::now() + std::chrono::seconds(1);
|
||||
while (std::chrono::steady_clock::now() < end) {
|
||||
exec->spin_some(50ms);
|
||||
}
|
||||
SUCCEED();
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 CMakeLists.txt 集成
|
||||
|
||||
```cmake
|
||||
if(BUILD_TESTING)
|
||||
find_package(ament_cmake_gtest REQUIRED)
|
||||
ament_add_gtest(test_pub_sub test/test_pub_sub.cpp)
|
||||
ament_target_dependencies(test_pub_sub rclcpp std_msgs)
|
||||
endif()
|
||||
```
|
||||
|
||||
### 2.3 跑测试
|
||||
|
||||
```bash
|
||||
colcon build --packages-select cpp_pubsub
|
||||
colcon test --packages-select cpp_pubsub
|
||||
|
||||
# 看详细
|
||||
cat build/cpp_pubsub/test_results/cpp_pubsub/test_pub_sub.gtest.xml
|
||||
```
|
||||
|
||||
### 2.4 关键 API
|
||||
|
||||
| API | 用途 |
|
||||
|---|---|
|
||||
| `TEST(suite, name)` | 测试用例 |
|
||||
| `TEST_F(FixtureName, name)` | 用 fixture |
|
||||
| `EXPECT_*`(非致命) / `ASSERT_*`(致命) | 断言 |
|
||||
| `SetUp() / TearDown()` | 每个用例前后 |
|
||||
| `SetUpTestSuite() / TearDownTestSuite()` | 所有用例前后 |
|
||||
|
||||
---
|
||||
|
||||
## 3. Python pytest 完整指南
|
||||
|
||||
### 3.1 测试文件
|
||||
|
||||
```python
|
||||
import rclpy
|
||||
import pytest
|
||||
|
||||
from py_pubsub.publisher_member_function import Talker
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def ros_context():
|
||||
"""rclpy 是进程级单例,模块前 init,完成后 shutdown。"""
|
||||
rclpy.init()
|
||||
yield
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
def test_talker_init(ros_context):
|
||||
node = Talker()
|
||||
assert node.get_name() == 'talker_py'
|
||||
|
||||
|
||||
def test_inproc_roundtrip(ros_context):
|
||||
"""同进程 spin Talker + 自建 subscriber,验证消息流。"""
|
||||
import time
|
||||
from sensor_msgs.msg import String
|
||||
|
||||
talker = Talker()
|
||||
received = []
|
||||
sub_node = rclpy.node.Node('test_subscriber')
|
||||
sub_node.create_subscription(
|
||||
String, 'chatter',
|
||||
lambda msg: received.append(msg.data), 10)
|
||||
|
||||
exec_ = rclpy.executors.SingleThreadedExecutor()
|
||||
exec_.add_node(talker)
|
||||
exec_.add_node(sub_node)
|
||||
end = time.time() + 1.5
|
||||
while time.time() < end:
|
||||
exec_.spin_once(timeout_sec=0.05)
|
||||
|
||||
assert any('Hello from PY' in s for s in received)
|
||||
```
|
||||
|
||||
### 3.2 同进程 spin(关键模式)
|
||||
|
||||
```python
|
||||
exec_ = rclpy.executors.SingleThreadedExecutor()
|
||||
exec_.add_node(talker)
|
||||
exec_.add_node(listener)
|
||||
|
||||
end = time.time() + timeout_sec
|
||||
while time.time() < end:
|
||||
exec_.spin_once(timeout_sec=0.05)
|
||||
```
|
||||
|
||||
**`spin_once(timeout)`** 在主循环里手动驱动事件循环,**比 `rclpy.spin()` 易控制超时**。
|
||||
|
||||
### 3.3 pytest 标记(marks)
|
||||
|
||||
```python
|
||||
@pytest.mark.ros # 需要 ROS2 环境
|
||||
@pytest.mark.inproc # 同进程可跑
|
||||
@pytest.mark.slow # 跑得慢
|
||||
```
|
||||
|
||||
CLI:
|
||||
```bash
|
||||
pytest -m "not slow"
|
||||
pytest -m ros and not inproc
|
||||
```
|
||||
|
||||
### 3.4 pytest 配置(pyproject.toml)
|
||||
|
||||
本仓库已在 `pyproject.toml` 配:
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"ros: 需要 ROS2 运行环境",
|
||||
"inproc: 同进程内 spin",
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. launch_testing(集成测试 launch 文件)
|
||||
|
||||
```python
|
||||
import launch
|
||||
import launch_ros
|
||||
import launch_testing
|
||||
import launch_testing.actions
|
||||
import pytest
|
||||
|
||||
@pytest.mark.launch_test
|
||||
def generate_test_description():
|
||||
return launch.LaunchDescription([
|
||||
launch_ros.actions.Node(
|
||||
package='py_pubsub',
|
||||
executable='talker',
|
||||
parameters=[{'period_ms': 100, 'topic': 'chatter_test'}]),
|
||||
launch_ros.actions.Node(
|
||||
package='py_pubsub',
|
||||
executable='listener',
|
||||
parameters=[{'topic': 'chatter_test'}]),
|
||||
launch_testing.actions.ReadyToTest(),
|
||||
])
|
||||
|
||||
class TestPubSub(unittest.TestCase):
|
||||
def test_message_received(self, proc_output):
|
||||
assert proc_output.assertWaitFor(
|
||||
'recv:', timeout=5, stream='stdout'
|
||||
)
|
||||
```
|
||||
|
||||
**注意**: launch_testing 在 colcon test 环境下与节点 rclpy.shutdown() 二次调用可能冲突,本仓库改用更稳的 in-process spin。
|
||||
|
||||
---
|
||||
|
||||
## 5. 本仓库测试策略详解
|
||||
|
||||
### 5.1 单元 + 集成 in-process(主测试)
|
||||
|
||||
**Python 包** 用 pytest + in-process spin:
|
||||
```python
|
||||
@pytest.fixture(scope='module')
|
||||
def ros_context():
|
||||
rclpy.init()
|
||||
yield
|
||||
rclpy.shutdown()
|
||||
|
||||
def test_xxx(ros_context):
|
||||
server = SomeServer()
|
||||
client = SomeClient()
|
||||
exec_ = rclpy.executors.SingleThreadedExecutor()
|
||||
exec_.add_node(server); exec_.add_node(client)
|
||||
# ... 触发 + 验证
|
||||
```
|
||||
|
||||
**C++ 包** 用 gtest + ament_add_gtest:
|
||||
```cpp
|
||||
TEST_F(MyFixture, SpinSomeWorks) {
|
||||
auto node = std::make_shared<MyNode>();
|
||||
auto exec = std::make_shared<rclcpp::executors::SingleThreadedExecutor>();
|
||||
exec->add_node(node);
|
||||
exec->spin_some(50ms);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 端到端(固化日志,手工验证)
|
||||
|
||||
5 个核心场景,跑一遍固化到 `docker/`:
|
||||
- `bringup_e2e.log`:Topic 4 节点跨包跨语言
|
||||
- `srv_e2e.log`:Service 12+30=42
|
||||
- `robot_e2e.log`:URDF + TF 实时打印
|
||||
- `vision_e2e.log`:Image 流
|
||||
- `full_demo_e2e.log`:11 节点一起
|
||||
|
||||
每次大改动后重跑 + 替换日志。
|
||||
|
||||
### 5.3 测试覆盖现状
|
||||
|
||||
| 包 | 测试 | 用例数 | 通过率 |
|
||||
|---|---|---|---|
|
||||
| py_pubsub | pytest + in-process | 4/4 | 100% |
|
||||
| cpp_pubsub | gtest | 2/2 | 100% |
|
||||
| py_srv | pytest + Service | 1/1 | 100% |
|
||||
| py_action_demo | pytest + Action | 1/1 | 100% |
|
||||
| cpp_robot_tf2 | gtest + TF2 | 2/2 | 100% |
|
||||
| py_vision_demo | pytest + Image | 2/2 | 100% |
|
||||
| bringup | launch 6 文件就绪 | OK | 100% |
|
||||
|
||||
**合计: 10/10 测试 100% PASSED**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 端到端自动化(进阶)
|
||||
|
||||
```python
|
||||
@pytest.mark.launch_test
|
||||
def generate_test_description():
|
||||
return launch.LaunchDescription([
|
||||
IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(<bringup>/launch/full_demo_launch.py)),
|
||||
launch_testing.actions.ReadyToTest(),
|
||||
])
|
||||
|
||||
class TestFullDemo(unittest.TestCase):
|
||||
def test_all_nodes_running(self):
|
||||
# 跑 ros2 node list 子进程,看 11 个节点
|
||||
...
|
||||
```
|
||||
|
||||
详见 [`20-topics.md`](20-topics.md) §11.3 录包回放。
|
||||
|
||||
---
|
||||
|
||||
## 7. 标记 / 跳过 / 覆盖率
|
||||
|
||||
### 7.1 标记
|
||||
|
||||
```python
|
||||
@pytest.mark.skip(reason="硬件没到位")
|
||||
def test_xxx():
|
||||
...
|
||||
|
||||
@pytest.mark.skipif(sys.platform == 'win32', reason="Windows 跑不动")
|
||||
def test_yyy():
|
||||
...
|
||||
|
||||
@pytest.mark.xfail(reason="已知 bug") # 标记预期失败
|
||||
def test_zzz():
|
||||
...
|
||||
```
|
||||
|
||||
### 7.2 覆盖率
|
||||
|
||||
```bash
|
||||
# Python
|
||||
pytest --cov=py_pubsub --cov-report=html src/py_pubsub/test
|
||||
# 输出 build/htmlcov/index.html
|
||||
```
|
||||
|
||||
```bash
|
||||
# C++(用 lcov)
|
||||
sudo apt install lcov
|
||||
cd build/cpp_pubsub && lcov --capture --directory . --output-file coverage.info
|
||||
genhtml coverage.info --output-directory coverage_html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. CI 集成(GitHub Actions)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: test
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build image & test
|
||||
run: |
|
||||
docker compose -f docker/docker-compose.yml build
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
docker exec ros2_dev bash -lc "cd /root/ros2_ws && colcon build --packages-select py_pubsub cpp_pubsub py_srv py_action_demo cpp_robot_tf2 py_vision_demo bringup"
|
||||
docker exec ros2_dev bash -lc "cd /root/ros2_ws && colcon test --packages-select py_pubsub cpp_pubsub py_srv py_action_demo cpp_robot_tf2 py_vision_demo bringup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 在本仓库里跑
|
||||
|
||||
### 9.1 跑全部测试
|
||||
|
||||
```bash
|
||||
docker exec ros2_dev bash -lc "source /opt/ros/humble/setup.bash && cd /root/ros2_ws && colcon test --packages-select py_pubsub cpp_pubsub py_srv py_action_demo cpp_robot_tf2 py_vision_demo bringup"
|
||||
```
|
||||
|
||||
### 9.2 单包测试
|
||||
|
||||
```bash
|
||||
docker exec ros2_dev bash -lc "cd /root/ros2_ws && colcon test --packages-select cpp_pubsub"
|
||||
```
|
||||
|
||||
### 9.3 看测试日志
|
||||
|
||||
```bash
|
||||
# gtest
|
||||
cat build/cpp_pubsub/test_results/cpp_pubsub/test_pub_sub.gtest.xml
|
||||
|
||||
# pytest
|
||||
cat build/py_pubsub/pytest.xml
|
||||
```
|
||||
|
||||
### 9.4 当前结果(固化)
|
||||
|
||||
```
|
||||
✅ cpp_pubsub: 2/2 PASSED
|
||||
✅ py_pubsub: 4/4 PASSED
|
||||
✅ py_srv: 1/1 PASSED
|
||||
✅ py_action_demo: 1/1 PASSED
|
||||
✅ cpp_robot_tf2: 2/2 PASSED
|
||||
✅ py_vision_demo: 2/2 PASSED
|
||||
✅ bringup: OK (no tests)
|
||||
合计: 10/10 PASSED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 进阶:Mock DDS / 时间注入 / Fault Injection
|
||||
|
||||
### 10.1 Mock DDS(测试纯逻辑)
|
||||
|
||||
```python
|
||||
# 不起 ROS2 上下文,直接测算法
|
||||
def test_message_parser():
|
||||
raw = b'\x00\x01hello'
|
||||
parsed = MyParser.parse(raw)
|
||||
assert parsed == 'hello'
|
||||
```
|
||||
|
||||
### 10.2 时间注入
|
||||
|
||||
```cpp
|
||||
// C++: 用 Clock 抽象 + RosTime mock
|
||||
node->set_parameter(rclcpp::Parameter("use_sim_time", true));
|
||||
// 然后通过 /clock topic 推时间
|
||||
```
|
||||
|
||||
### 10.3 Fault Injection
|
||||
|
||||
```python
|
||||
# 杀掉 server 模拟断网
|
||||
subprocess.run(['pkill', '-9', '-f', 'add_two_ints_server'])
|
||||
# 看 client 怎么处理
|
||||
|
||||
# 慢响应模拟
|
||||
# 修改 server callback sleep 时间
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 接下来读
|
||||
|
||||
| 主题 | 文档 |
|
||||
|---|---|
|
||||
| 三机部署 | [`100-embedded-deployment.md`](100-embedded-deployment.md) |
|
||||
| 具身智能路径 | [`99-embodied-ai.md`](99-embodied-ai.md) |
|
||||
| Docker 开发 | [`85-docker.md`](85-docker.md) |
|
||||
Reference in New Issue
Block a user