Skip to content

Commit 5bd4e5e

Browse files
authored
Add ros_launch_test bazel rule (#424)
* Add ros_launch_test bazel rule * Add missing rosdep
1 parent 6b3776f commit 5bd4e5e

6 files changed

Lines changed: 270 additions & 1 deletion

File tree

bazel_ros2_rules/lib/extensions.bzl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ def _local_ros2_implementation(module_ctx):
3434
include_packages += [
3535
"action_msgs",
3636
"builtin_interfaces",
37+
"launch_testing",
38+
"launch_testing_ros",
3739
"ros2cli_common_extensions",
3840
"ros2cli",
3941
"rosidl_default_generators",

bazel_ros2_rules/lib/private/ros_py.bzl

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,110 @@ def ros_launch(
246246
**kwargs
247247
)
248248

249+
_LAUNCH_TEST_MAIN_TEMPLATE = """\
250+
import os
251+
import sys
252+
253+
from python.runfiles import runfiles as runfiles_api
254+
255+
assert __name__ == "__main__"
256+
runfiles = runfiles_api.Create()
257+
launch_test_file = runfiles.Rlocation({launch_test_respath})
258+
259+
# This shim only ever runs inside a Bazel test sandbox. The sandbox sets
260+
# HOME=/does_not_exist (read-only) and the project .bazelrc sets ROS_HOME to
261+
# a similar sentinel. RCL log-dir priority: ROS_LOG_DIR > $ROS_HOME/log >
262+
# $HOME/.ros/log. Redirect all three unconditionally to the per-test tmpdir.
263+
_tmpdir = os.environ.get("TEST_TMPDIR", "/tmp")
264+
os.environ["ROS_LOG_DIR"] = _tmpdir
265+
os.environ["ROS_HOME"] = _tmpdir
266+
os.environ["HOME"] = _tmpdir
267+
268+
sys.argv = ["launch_test", launch_test_file] + sys.argv[1:]
269+
270+
from launch_testing.launch_test import main
271+
sys.exit(main())
272+
"""
273+
274+
def ros_launch_test(
275+
name,
276+
launch_test_file,
277+
workspace_name = None,
278+
executables = [],
279+
share = [],
280+
data = [],
281+
deps = [],
282+
rmw_implementation = None,
283+
isolate = True,
284+
**kwargs):
285+
"""
286+
Builds a launch_testing test and wraps it with a shim that will inject the
287+
minimal runtime environment necessary for execution when depending on
288+
targets from this ROS 2 local repository.
289+
290+
The test file must export generate_test_description() and any number of
291+
unittest.TestCase subclasses following the launch_testing convention.
292+
293+
Args:
294+
name: test target name
295+
launch_test_file: label of the launch_testing test file
296+
workspace_name: optional ament package name override (defaults to
297+
the Bzlmod module name)
298+
executables: executable targets to register in the ament index,
299+
enabling Node(package=<pkg>, executable=...) lookups
300+
share: data file targets to register under share/<pkg>/, enabling
301+
FindPackageShare(<pkg>) and get_package_share_directory(<pkg>)
302+
data: additional runtime data deps
303+
deps: additional Python deps beyond launch_testing and launch
304+
rmw_implementation: optional RMW implementation to run against
305+
isolate: whether to use network namespace isolation (default True)
306+
307+
Additional keyword arguments are forwarded to ros_py_test.
308+
"""
309+
main = "{}_launch_test_main.py".format(name)
310+
launch_test_respath = _make_respath(launch_test_file, workspace_name)
311+
312+
generate_file(
313+
name = main,
314+
content = _LAUNCH_TEST_MAIN_TEMPLATE.format(
315+
launch_test_respath = repr(launch_test_respath),
316+
),
317+
visibility = ["//visibility:private"],
318+
testonly = True,
319+
)
320+
321+
if executables or share:
322+
package_name = workspace_name if workspace_name != None else native.module_name()
323+
index_target = "_{}_ament_index".format(name)
324+
ament_index_share_files(
325+
name = index_target,
326+
package_name = package_name,
327+
executables = executables,
328+
srcs = share,
329+
visibility = ["//visibility:private"],
330+
)
331+
data = data + executables + share + [":" + index_target]
332+
333+
tags = list(kwargs.pop("tags", []))
334+
if "nolint" not in tags:
335+
tags.append("nolint")
336+
337+
ros_py_test(
338+
name = name,
339+
srcs = [main],
340+
main = main,
341+
data = data + [launch_test_file],
342+
deps = deps + [
343+
"@bazel_ros2_rules//deps/python/runfiles",
344+
"@ros2//:launch_testing_py",
345+
"@ros2//:launch_py",
346+
],
347+
rmw_implementation = rmw_implementation,
348+
isolate = isolate,
349+
tags = tags,
350+
**kwargs
351+
)
352+
249353
def ros_py_test(
250354
name,
251355
rmw_implementation = None,

ros2_example_bazel_installed/ros2_example_apps/BUILD.bazel

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# vi: set ft=python :
33

44
load("@ros2//:ros_cc.bzl", "ros_cc_binary", "ros_cc_test")
5-
load("@ros2//:ros_py.bzl", "ros_launch", "ros_py_binary", "ros_py_test")
5+
load("@ros2//:ros_py.bzl", "ros_launch", "ros_launch_test", "ros_py_binary", "ros_py_test")
66
load("@ros2//:rosidl.bzl", "rosidl_interfaces_group")
77
load("@rules_cc//cc:cc_library.bzl", "cc_library")
88
load("//tools:cmd_test.bzl", "cmd_test")
@@ -259,6 +259,35 @@ ros_launch(
259259
workspace_name = workspace_name,
260260
)
261261

262+
ros_launch_test(
263+
name = "roslaunch_eg_launch_test",
264+
size = "medium",
265+
# roslaunch_eg_py carries eg_talker and eg_listener in its ament index
266+
# (via executables= on its ros_launch declaration). Listing it in data=
267+
# propagates those indexes into AMENT_PREFIX_PATH at test runtime.
268+
data = [
269+
":roslaunch_eg_py",
270+
],
271+
launch_test_file = "test/roslaunch_eg_launch_test.py",
272+
rmw_implementation = "rmw_cyclonedds_cpp",
273+
)
274+
275+
ros_launch_test(
276+
name = "roslaunch_eg_node_launch_test",
277+
size = "medium",
278+
# roslaunch_eg_py registers eg_talker and eg_listener in the ament index
279+
# under package "ros2_example_bazel_installed", making them discoverable
280+
# by launch_ros.actions.Node without an explicit Rlocation call.
281+
data = [
282+
":roslaunch_eg_py",
283+
],
284+
launch_test_file = "test/roslaunch_eg_node_launch_test.py",
285+
rmw_implementation = "rmw_cyclonedds_cpp",
286+
deps = [
287+
"@ros2//:launch_ros_py",
288+
],
289+
)
290+
262291
ros_py_test(
263292
name = "roslaunch_eg_test",
264293
srcs = ["test/roslaunch_eg_test.py"],
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# -*- python -*-
2+
# Copyright 2024 Open Source Robotics Foundation
3+
4+
"""launch_testing example: verify talker/listener exchange via ros_launch_test.
5+
6+
This test spins up the eg_talker and eg_listener from the roslaunch_eg_py
7+
launch binary, then checks exit codes in a post-shutdown test.
8+
9+
To run via Bazel:
10+
bazel test //ros2_example_apps:roslaunch_eg_launch_test
11+
"""
12+
13+
import unittest
14+
15+
import launch
16+
import launch.actions
17+
import launch.event_handlers
18+
import launch_testing
19+
import launch_testing.actions
20+
import launch_testing.asserts
21+
import launch_testing.markers
22+
from python.runfiles import runfiles as runfiles_api
23+
24+
25+
@launch_testing.markers.keep_alive
26+
def generate_test_description():
27+
r = runfiles_api.Create()
28+
29+
# Locate the ros_launch binary via Bazel runfiles.
30+
# The binary already carries eg_talker and eg_listener in its ament index
31+
# because roslaunch_eg_py was declared with executables=[":eg_listener",
32+
# ":eg_talker"]. No additional ament registration is needed here.
33+
launch_bin = r.Rlocation(
34+
"ros2_example_bazel_installed/ros2_example_apps/roslaunch_eg_py"
35+
)
36+
37+
launch_action = launch.actions.ExecuteProcess(
38+
cmd=[launch_bin],
39+
output="screen",
40+
)
41+
42+
# Fire ReadyToTest() only after roslaunch_eg_py exits on its own.
43+
# This prevents launch_testing from sending SIGINT before the talker and
44+
# listener have finished exchanging their 10 messages.
45+
return (
46+
launch.LaunchDescription(
47+
[
48+
launch_action,
49+
launch.actions.RegisterEventHandler(
50+
launch.event_handlers.OnProcessExit(
51+
target_action=launch_action,
52+
on_exit=[launch_testing.actions.ReadyToTest()],
53+
)
54+
),
55+
]
56+
),
57+
{"launch_proc": launch_action},
58+
)
59+
60+
61+
@launch_testing.post_shutdown_test()
62+
class TestExitCode(unittest.TestCase):
63+
def test_launch_exit_code(self, launch_proc, proc_info):
64+
launch_testing.asserts.assertExitCodes(proc_info, [0], launch_proc)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# -*- python -*-
2+
# Copyright 2024 Open Source Robotics Foundation
3+
4+
"""launch_testing Pattern B example: launch nodes via launch_ros.actions.Node.
5+
6+
Unlike roslaunch_eg_launch_test.py (which locates the binary via Rlocation),
7+
this test refers to nodes by their ament package/executable name. The ament
8+
index entries for eg_talker and eg_listener are made visible at test runtime
9+
because roslaunch_eg_py (which registered them via executables=) is listed in
10+
the data= of the ros_launch_test target.
11+
12+
To run via Bazel:
13+
bazel test //ros2_example_apps:roslaunch_eg_node_launch_test
14+
"""
15+
16+
import unittest
17+
18+
import launch
19+
import launch.actions
20+
import launch.event_handlers
21+
from launch_ros.actions import Node
22+
import launch_testing
23+
import launch_testing.actions
24+
import launch_testing.asserts
25+
import launch_testing.markers
26+
27+
28+
@launch_testing.markers.keep_alive
29+
def generate_test_description():
30+
talker = Node(
31+
package="ros2_example_bazel_installed",
32+
executable="eg_talker",
33+
output="screen",
34+
)
35+
listener = Node(
36+
package="ros2_example_bazel_installed",
37+
executable="eg_listener",
38+
output="screen",
39+
)
40+
41+
# The listener exits first (transient-local QoS lets it buffer all 10
42+
# messages quickly). Fire ReadyToTest() from the talker's OnProcessExit
43+
# so the transition to post-shutdown tests happens only after both nodes
44+
# have exited cleanly. keep_alive prevents LaunchService from
45+
# auto-shutting down when the first node exits.
46+
return (
47+
launch.LaunchDescription(
48+
[
49+
talker,
50+
listener,
51+
launch.actions.RegisterEventHandler(
52+
launch.event_handlers.OnProcessExit(
53+
target_action=talker,
54+
on_exit=[launch_testing.actions.ReadyToTest()],
55+
)
56+
),
57+
]
58+
),
59+
{"talker": talker, "listener": listener},
60+
)
61+
62+
63+
@launch_testing.post_shutdown_test()
64+
class TestExitCodes(unittest.TestCase):
65+
def test_talker_exit_code(self, talker, proc_info):
66+
launch_testing.asserts.assertExitCodes(proc_info, [0], talker)
67+
68+
def test_listener_exit_code(self, listener, proc_info):
69+
launch_testing.asserts.assertExitCodes(proc_info, [0], listener)

ros2_example_bazel_installed/setup/prereq-rosdep-keys.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ python3-numpy
3232
python3-packaging
3333
python3-pkg-resources
3434
python3-psutil
35+
python3-pytest
3536
python3-rosdistro-modules
3637
python3-yaml
3738
python_cmake_module

0 commit comments

Comments
 (0)