Skip to content

Commit ea95fea

Browse files
authored
Add tests for launch_utils of controller manager (#2768) (#3509)
1 parent e65ddd7 commit ea95fea

7 files changed

Lines changed: 501 additions & 0 deletions

controller_manager/CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,10 @@ if(BUILD_TESTING)
229229
DESTINATION test)
230230
ament_add_pytest_test(test_ros2_control_node test/test_ros2_control_node_launch.py)
231231
ament_add_pytest_test(test_test_utils test/test_test_utils.py)
232+
233+
# Now include the launch_utils subfolder
234+
add_subdirectory(test/test_launch_utils)
235+
232236
endif()
233237

234238
install(
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# This subdirectory handles pytest-based launch tests for controller_manager
2+
3+
find_package(ament_cmake_pytest REQUIRED)
4+
find_package(launch_testing_ament_cmake REQUIRED)
5+
find_package(rclpy REQUIRED)
6+
7+
# Install YAML test files
8+
#
9+
install(
10+
FILES
11+
test_ros2_control_node_combined.yaml
12+
DESTINATION share/${PROJECT_NAME}/test/test_launch_utils
13+
)
14+
15+
# Register each test with ament
16+
ament_add_pytest_test(test_launch_utils_unit
17+
test_launch_utils_unit.py
18+
)
19+
ament_add_pytest_test(test_launch_utils_integration_list
20+
test_launch_utils_integration_list.py
21+
APPEND_ENV AMENT_PREFIX_PATH=${ament_index_build_path}_$<CONFIG>
22+
)
23+
ament_add_pytest_test(test_launch_utils_integration_dict
24+
test_launch_utils_integration_dict.py
25+
APPEND_ENV AMENT_PREFIX_PATH=${ament_index_build_path}_$<CONFIG>
26+
)
27+
ament_add_pytest_test(test_launch_utils_integration_load
28+
test_launch_utils_integration_load.py
29+
APPEND_ENV AMENT_PREFIX_PATH=${ament_index_build_path}_$<CONFIG>
30+
)
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2025 Robert Kwan
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import pytest
17+
import unittest
18+
from launch import LaunchDescription
19+
import launch_testing
20+
from launch_testing.actions import ReadyToTest
21+
import launch_ros.actions
22+
from launch.substitutions import FileContent, PathSubstitution
23+
from launch_ros.substitutions import FindPackageShare
24+
25+
import rclpy
26+
27+
from controller_manager.test_utils import check_controllers_running
28+
29+
from controller_manager.launch_utils import (
30+
generate_controllers_spawner_launch_description_from_dict,
31+
)
32+
33+
34+
@pytest.mark.launch_test
35+
def generate_test_description():
36+
"""
37+
Generate launch description for testing the dict-based spawner helper.
38+
39+
Uses the combined controller YAML installed with the package.
40+
"""
41+
42+
urdf = FileContent(
43+
PathSubstitution(FindPackageShare("ros2_control_test_assets"))
44+
/ "urdf"
45+
/ "test_hardware_components.urdf"
46+
)
47+
robot_description = {"robot_description": urdf}
48+
49+
# The dictionary keys are the controller names to be spawned/started.
50+
# Values can be empty lists since config is provided via the main YAML.
51+
ctrl_dict = {
52+
"test_broadcaster": [],
53+
"controller1": [],
54+
"controller2": [],
55+
}
56+
controller_list = list(ctrl_dict.keys())
57+
58+
# ===== CREATE LAUNCH DESCRIPTION =====
59+
return LaunchDescription(
60+
[
61+
launch_ros.actions.Node(
62+
package="robot_state_publisher",
63+
executable="robot_state_publisher",
64+
namespace="",
65+
output="both",
66+
parameters=[robot_description],
67+
),
68+
launch_ros.actions.Node(
69+
package="controller_manager",
70+
executable="ros2_control_node",
71+
namespace="",
72+
parameters=[
73+
robot_description,
74+
PathSubstitution(FindPackageShare("controller_manager"))
75+
/ "test"
76+
/ "test_launch_utils"
77+
/ "test_ros2_control_node_combined.yaml",
78+
],
79+
output="both",
80+
),
81+
generate_controllers_spawner_launch_description_from_dict(
82+
controller_info_dict=ctrl_dict,
83+
extra_spawner_args=["--inactive"],
84+
),
85+
ReadyToTest(),
86+
]
87+
), {"controller_list": controller_list}
88+
89+
90+
# Active tests
91+
class TestControllerSpawnerList(unittest.TestCase):
92+
"""Active tests that run while the launch is active."""
93+
94+
@classmethod
95+
def setUpClass(cls):
96+
rclpy.init()
97+
98+
@classmethod
99+
def tearDownClass(cls):
100+
rclpy.shutdown()
101+
102+
def setUp(self):
103+
self.node = rclpy.create_node("test_controller_spawner")
104+
105+
def tearDown(self):
106+
self.node.destroy_node()
107+
108+
def test_controllers_start(self, proc_info, controller_list):
109+
cnames = controller_list.copy()
110+
check_controllers_running(self.node, cnames, state="inactive")
111+
112+
# Wait for controller_spawner to finish and verify successful exit.
113+
proc_info.assertWaitForShutdown(process="spawner", timeout=30)
114+
launch_testing.asserts.assertExitCodes(proc_info, process="spawner")
115+
116+
# Re-check controllers after spawner has exited.
117+
check_controllers_running(self.node, cnames, state="inactive")
118+
119+
120+
@launch_testing.post_shutdown_test()
121+
class TestShutdown(unittest.TestCase):
122+
"""Post-shutdown tests."""
123+
124+
def test_exit_codes(self, proc_info):
125+
"""Verify all processes exited successfully."""
126+
launch_testing.asserts.assertExitCodes(proc_info, allowable_exit_codes=[0])
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2025 Robert Kwan
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import pytest
17+
import unittest
18+
from launch import LaunchDescription
19+
import launch_testing
20+
from launch_testing.actions import ReadyToTest
21+
import launch_ros.actions
22+
from launch.substitutions import FileContent, PathSubstitution
23+
from launch_ros.substitutions import FindPackageShare
24+
25+
import rclpy
26+
27+
from controller_manager.test_utils import check_controllers_running
28+
29+
from controller_manager.launch_utils import generate_controllers_spawner_launch_description
30+
31+
32+
@pytest.mark.launch_test
33+
def generate_test_description():
34+
"""
35+
Generate launch description for testing.
36+
"""
37+
38+
urdf = FileContent(
39+
PathSubstitution(FindPackageShare("ros2_control_test_assets"))
40+
/ "urdf"
41+
/ "test_hardware_components.urdf"
42+
)
43+
robot_description = {"robot_description": urdf}
44+
45+
# Path to combined YAML
46+
robot_controllers = (
47+
PathSubstitution(FindPackageShare("controller_manager"))
48+
/ "test"
49+
/ "test_launch_utils"
50+
/ "test_ros2_control_node_combined.yaml"
51+
)
52+
53+
# ===== DEFINE CONTROLLERS TO SPAWN =====
54+
controller_list = ["test_broadcaster", "controller1", "controller2"]
55+
56+
# ===== CREATE LAUNCH DESCRIPTION =====
57+
return LaunchDescription(
58+
[
59+
launch_ros.actions.Node(
60+
package="robot_state_publisher",
61+
executable="robot_state_publisher",
62+
namespace="",
63+
output="both",
64+
parameters=[robot_description],
65+
),
66+
launch_ros.actions.Node(
67+
package="controller_manager",
68+
executable="ros2_control_node",
69+
namespace="",
70+
parameters=[robot_description, robot_controllers],
71+
output="both",
72+
),
73+
generate_controllers_spawner_launch_description(
74+
controller_names=controller_list.copy(),
75+
controller_params_files=[robot_controllers],
76+
extra_spawner_args=["--inactive"],
77+
),
78+
ReadyToTest(),
79+
]
80+
), {"controller_list": controller_list}
81+
82+
83+
# Active tests
84+
class TestControllerSpawnerList(unittest.TestCase):
85+
"""Active tests that run while the launch is active."""
86+
87+
@classmethod
88+
def setUpClass(cls):
89+
rclpy.init()
90+
91+
@classmethod
92+
def tearDownClass(cls):
93+
rclpy.shutdown()
94+
95+
def setUp(self):
96+
self.node = rclpy.create_node("test_controller_spawner")
97+
98+
def tearDown(self):
99+
self.node.destroy_node()
100+
101+
def test_controllers_start(self, proc_info, controller_list):
102+
cnames = controller_list.copy()
103+
check_controllers_running(self.node, cnames, state="inactive")
104+
105+
# Wait for controller_spawner to finish and verify successful exit.
106+
proc_info.assertWaitForShutdown(process="spawner", timeout=30)
107+
launch_testing.asserts.assertExitCodes(proc_info, process="spawner")
108+
109+
# Re-check controllers after spawner has exited.
110+
check_controllers_running(self.node, cnames, state="inactive")
111+
112+
113+
@launch_testing.post_shutdown_test()
114+
class TestShutdown(unittest.TestCase):
115+
"""Post-shutdown tests."""
116+
117+
def test_exit_codes(self, proc_info):
118+
"""Verify all processes exited successfully."""
119+
launch_testing.asserts.assertExitCodes(proc_info, allowable_exit_codes=[0])

0 commit comments

Comments
 (0)