From 7135b327895a5920dd9c7eb5363ee9dfd2c8a99e Mon Sep 17 00:00:00 2001 From: Michel Hidalgo Date: Tue, 13 Aug 2019 17:08:39 -0300 Subject: [PATCH 1/7] Support launch_ros test runner in pytest. Signed-off-by: Michel Hidalgo --- .../launch_testing_ros/pytest/hooks.py | 23 +++++++++++++++++++ launch_testing_ros/setup.py | 3 +++ 2 files changed, 26 insertions(+) create mode 100644 launch_testing_ros/launch_testing_ros/pytest/hooks.py diff --git a/launch_testing_ros/launch_testing_ros/pytest/hooks.py b/launch_testing_ros/launch_testing_ros/pytest/hooks.py new file mode 100644 index 00000000..95073c35 --- /dev/null +++ b/launch_testing_ros/launch_testing_ros/pytest/hooks.py @@ -0,0 +1,23 @@ +# Copyright 2019 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from ..test_runner import LaunchTestRunner + + +def pytest_launch_test_makerunner(test_runs, launch_args, debug): + return LaunchTestRunner( + test_runs=test_runs, launch_file_arguments=launch_args, debug=debug + ) diff --git a/launch_testing_ros/setup.py b/launch_testing_ros/setup.py index 33ff3539..791ac883 100644 --- a/launch_testing_ros/setup.py +++ b/launch_testing_ros/setup.py @@ -12,6 +12,9 @@ ('lib/launch_testing_ros', glob.glob('example_nodes/**')), ('share/launch_testing_ros/examples', glob.glob('examples/[!_]**')), ], + entry_points={ + 'pytest11': ['launch_ros = launch_testing_ros.pytest.hooks'], + } install_requires=['setuptools'], zip_safe=True, author='Pete Baughman', From 68f7d32e66a9808a876fc838a8a61c7ef6d07575 Mon Sep 17 00:00:00 2001 From: Michel Hidalgo Date: Fri, 16 Aug 2019 10:56:54 -0300 Subject: [PATCH 2/7] Test examples using pytest Signed-off-by: Michel Hidalgo --- .../examples/talker_listener.test.py | 195 ------------------ .../launch_testing_ros/pytest/__init__.py | 0 .../launch_testing_ros/pytest/hooks.py | 27 ++- launch_testing_ros/setup.py | 2 +- .../{ => test}/examples/README.md | 20 +- .../examples/talker_listener_launch_test.py | 160 ++++++++++++++ launch_testing_ros/test/test_examples.py | 40 ---- 7 files changed, 190 insertions(+), 254 deletions(-) delete mode 100644 launch_testing_ros/examples/talker_listener.test.py create mode 100644 launch_testing_ros/launch_testing_ros/pytest/__init__.py rename launch_testing_ros/{ => test}/examples/README.md (50%) create mode 100644 launch_testing_ros/test/examples/talker_listener_launch_test.py delete mode 100644 launch_testing_ros/test/test_examples.py diff --git a/launch_testing_ros/examples/talker_listener.test.py b/launch_testing_ros/examples/talker_listener.test.py deleted file mode 100644 index 91096098..00000000 --- a/launch_testing_ros/examples/talker_listener.test.py +++ /dev/null @@ -1,195 +0,0 @@ -# Copyright 2019 Apex.AI, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import time -import unittest -import uuid - -import launch -import launch_ros -import launch_ros.actions -import launch_testing.util -import launch_testing_ros -import rclpy -import rclpy.context -import rclpy.executors -import std_msgs.msg - - -def generate_test_description(ready_fn): - # Necessary to get real-time stdout from python processes: - proc_env = os.environ.copy() - proc_env['PYTHONUNBUFFERED'] = '1' - - # Normally, talker publishes on the 'chatter' topic and listener listens on the - # 'chatter' topic, but we want to show how to use remappings to munge the data so we - # will remap these topics when we launch the nodes and insert our own node that can - # change the data as it passes through - talker_node = launch_ros.actions.Node( - package='demo_nodes_py', - node_executable='talker', - env=proc_env, - remappings=[('chatter', 'talker_chatter')], - ) - - listener_node = launch_ros.actions.Node( - package='demo_nodes_py', - node_executable='listener', - env=proc_env, - ) - - return ( - launch.LaunchDescription([ - talker_node, - listener_node, - # Start tests right away - no need to wait for anything - launch.actions.OpaqueFunction(function=lambda context: ready_fn()), - ]), - { - 'talker': talker_node, - 'listener': listener_node, - } - ) - - -class TestTalkerListenerLink(unittest.TestCase): - - @classmethod - def setUpClass(cls, proc_output, listener): - cls.context = rclpy.context.Context() - rclpy.init(context=cls.context) - cls.node = rclpy.create_node('test_node', context=cls.context) - - # The demo node listener has no synchronization to indicate when it's ready to start - # receiving messages on the /chatter topic. This plumb_listener method will attempt - # to publish for a few seconds until it sees output - publisher = cls.node.create_publisher( - std_msgs.msg.String, - 'chatter', - 10 - ) - msg = std_msgs.msg.String() - msg.data = 'test message {}'.format(uuid.uuid4()) - for _ in range(5): - try: - publisher.publish(msg) - proc_output.assertWaitFor( - expected_output=msg.data, - process=listener, - timeout=1.0 - ) - except AssertionError: - continue - except launch_testing.util.NoMatchingProcessException: - continue - else: - return - else: - assert False, 'Failed to plumb chatter topic to listener process' - - @classmethod - def tearDownClass(cls): - cls.node.destroy_node() - - def spin_rclpy(self, timeout_sec): - executor = rclpy.executors.SingleThreadedExecutor(context=self.context) - executor.add_node(self.node) - try: - executor.spin_once(timeout_sec=timeout_sec) - finally: - executor.remove_node(self.node) - - def test_talker_transmits(self, talker): - # Expect the talker to publish strings on '/talker_chatter' and also write to stdout - msgs_rx = [] - sub = self.node.create_subscription( - std_msgs.msg.String, - 'talker_chatter', - lambda msg: msgs_rx.append(msg), - 10 - ) - self.addCleanup(self.node.destroy_subscription, sub) - - # Wait until the talker transmits two messages over the ROS topic - end_time = time.time() + 10 - while time.time() < end_time: - self.spin_rclpy(1.0) - if len(msgs_rx) > 2: - break - - self.assertGreater(len(msgs_rx), 2) - - # Make sure the talker also output the same data via stdout - for txt in [msg.data for msg in msgs_rx]: - self.proc_output.assertWaitFor( - expected_output=txt, - process=talker - ) - - def test_listener_receives(self, listener): - pub = self.node.create_publisher( - std_msgs.msg.String, - 'chatter', - 10 - ) - self.addCleanup(self.node.destroy_publisher, pub) - - # Publish a unique message on /chatter and verify that the listener - # gets it and prints it - msg = std_msgs.msg.String() - msg.data = str(uuid.uuid4()) - for _ in range(10): - - pub.publish(msg) - success = self.proc_output.waitFor( - expected_output=msg.data, - process=listener, - timeout=1.0, - ) - if success: - break - assert success, 'Waiting for output timed out' - - def test_fuzzy_data(self, listener): - # This test shows how to insert a node in between the talker and the listener to - # change the data. Here we're going to change 'Hello World' to 'Aloha World' - def data_mangler(msg): - msg.data = msg.data.replace('Hello', 'Aloha') - return msg - - republisher = launch_testing_ros.DataRepublisher( - self.node, - 'talker_chatter', - 'chatter', - std_msgs.msg.String, - data_mangler - ) - self.addCleanup(republisher.shutdown) - - # Spin for a few seconds until we've republished some mangled messages - end_time = time.time() + 10 - while time.time() < end_time: - self.spin_rclpy(1.0) - if republisher.get_num_republished() > 2: - break - - self.assertGreater(republisher.get_num_republished(), 2) - - # Sanity check that we're changing 'Hello World' - self.proc_output.assertWaitFor('Aloha World') - - # Check for the actual messages we sent - for msg in republisher.get_republished(): - self.proc_output.assertWaitFor(msg.data, listener) diff --git a/launch_testing_ros/launch_testing_ros/pytest/__init__.py b/launch_testing_ros/launch_testing_ros/pytest/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/launch_testing_ros/launch_testing_ros/pytest/hooks.py b/launch_testing_ros/launch_testing_ros/pytest/hooks.py index 95073c35..1e4fa8c1 100644 --- a/launch_testing_ros/launch_testing_ros/pytest/hooks.py +++ b/launch_testing_ros/launch_testing_ros/pytest/hooks.py @@ -12,12 +12,31 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pytest +from launch_testing.pytest.hooks import LaunchTestItem +from launch_testing.pytest.hooks import LaunchTestModule from ..test_runner import LaunchTestRunner -def pytest_launch_test_makerunner(test_runs, launch_args, debug): - return LaunchTestRunner( - test_runs=test_runs, launch_file_arguments=launch_args, debug=debug +class LaunchROSTestItem(LaunchTestItem): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs, runner_cls=LaunchTestRunner) + + +class LaunchROSTestModule(LaunchTestModule): + + def makeitem(self, *args, **kwargs): + return LaunchROSTestItem(*args, **kwargs) + + +def pytest_launch_collect_makemodule(path, parent, entrypoint): + marks = getattr(entrypoint, 'pytestmark', []) + if marks and len(marks) == 1 and marks[0].name == 'rostest': + return LaunchROSTestModule(path, parent) + + +def pytest_configure(config): + config.addinivalue_line( + 'markers', 'rostest: mark a launch test as a ROS launch test' ) diff --git a/launch_testing_ros/setup.py b/launch_testing_ros/setup.py index 791ac883..2e853466 100644 --- a/launch_testing_ros/setup.py +++ b/launch_testing_ros/setup.py @@ -14,7 +14,7 @@ ], entry_points={ 'pytest11': ['launch_ros = launch_testing_ros.pytest.hooks'], - } + }, install_requires=['setuptools'], zip_safe=True, author='Pete Baughman', diff --git a/launch_testing_ros/examples/README.md b/launch_testing_ros/test/examples/README.md similarity index 50% rename from launch_testing_ros/examples/README.md rename to launch_testing_ros/test/examples/README.md index ed7b5524..7c7c9a59 100644 --- a/launch_testing_ros/examples/README.md +++ b/launch_testing_ros/test/examples/README.md @@ -1,31 +1,23 @@ # Examples -## `talker_listener.test.py` +## `talker_listener_launch_test.py` Usage: -> launch_test examples/talker_listener.test.py +> launch\_test talker_listener\_launch\_test.py -This test launches the talker and listener example nodes from demo_nodes_py and interacts +This test launches the talker and listener example nodes from demo\_nodes\_py and interacts with them via their ROS interfaces. Remapping rules are used so that one of the tests can sit in between the talker and the listener and change the data on the fly. -Node that in the setUpClass method, the test makes sure that the listener is subscribed and -republishing messages. Since the listener process provides no synchronization mechanism to -inform the outside world that it's up and running, this step is necessary especially in resource -constrained environments where process startup may take a non negligible amount of time. This -is often the cause of "flakyness" in tests on CI systems. A more robust design of the talker and -listener processes might provide some positive feedback that the node is up and running, but these -are simple example nodes. - -#### test_fuzzy_data +#### test\_fuzzy\_data This test gives an example of what a test that fuzzes data might look like. A ROS subscriber and publisher pair encapsulated in a `DataRepublisher` object changes the string "Hello World" to "Aloha World" as it travels between the talker and the listener. -#### test_listener_receives +#### test\_listener\_receives This test publishes unique messages on the `/chatter` topic and asserts that the same messages go to the stdout of the listener node -#### test_talker_transmits +#### test\_talker\_transmits This test subscribes to the remapped `/talker_chatter` topic and makes sure the talker node also writes the data it's transmitting to stdout diff --git a/launch_testing_ros/test/examples/talker_listener_launch_test.py b/launch_testing_ros/test/examples/talker_listener_launch_test.py new file mode 100644 index 00000000..8cff33c2 --- /dev/null +++ b/launch_testing_ros/test/examples/talker_listener_launch_test.py @@ -0,0 +1,160 @@ +# Copyright 2019 Apex.AI, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time +import unittest +import uuid + +import launch +import launch_ros +import launch_ros.actions +import launch_testing_ros + +import pytest + +import std_msgs.msg + + +@pytest.mark.rostest +def generate_test_description(ready_fn): + # Normally, talker publishes on the 'chatter' topic and listener listens on the + # 'chatter' topic, but we want to show how to use remappings to munge the data so we + # will remap these topics when we launch the nodes and insert our own node that can + # change the data as it passes through + talker_node = launch_ros.actions.Node( + package='demo_nodes_py', + node_executable='talker', + additional_env={'PYTHONUNBUFFERED': '1'}, + remappings=[('chatter', 'talker_chatter')] + ) + + listener_node = launch_ros.actions.Node( + package='demo_nodes_py', + node_executable='listener', + additional_env={'PYTHONUNBUFFERED': '1'}, + remappings=[('chatter', 'listener_chatter')] + ) + + return ( + launch.LaunchDescription([ + talker_node, + listener_node, + # Start tests right away - no need to wait for anything + launch.actions.OpaqueFunction(function=lambda context: ready_fn()), + ]), + { + 'talker': talker_node, + 'listener': listener_node, + } + ) + + +class TestTalkerListenerLink(unittest.TestCase): + + def test_talker_transmits(self, launch_service, talker): + # Get launch context ROS node + launch_context = launch_service.context + node = launch_context.locals.launch_ros_node + + # Expect the talker to publish strings on '/talker_chatter' and also write to stdout + msgs_rx = [] + + sub = node.create_subscription( + std_msgs.msg.String, + 'talker_chatter', + lambda msg: msgs_rx.append(msg), + 10 + ) + try: + # Wait until the talker transmits two messages over the ROS topic + end_time = time.time() + 10 + while time.time() < end_time: + time.sleep(1.0) + if len(msgs_rx) > 2: + break + + self.assertGreater(len(msgs_rx), 2) + + # Make sure the talker also output the same data via stdout + for msg in msgs_rx: + self.proc_output.assertWaitFor( + expected_output=msg.data, process=talker + ) + finally: + node.destroy_subscription(sub) + + def test_listener_receives(self, launch_service, listener): + # Get launch context ROS node + launch_context = launch_service.context + node = launch_context.locals.launch_ros_node + + pub = node.create_publisher( + std_msgs.msg.String, + 'listener_chatter', + 10 + ) + try: + # Publish a unique message on /chatter and verify that the listener + # gets it and prints it + msg = std_msgs.msg.String() + msg.data = str(uuid.uuid4()) + for _ in range(10): + pub.publish(msg) + success = self.proc_output.waitFor( + expected_output=msg.data, + process=listener, + timeout=1.0, + ) + if success: + break + assert success, 'Waiting for output timed out' + finally: + node.destroy_publisher(pub) + + def test_fuzzy_data(self, launch_service, listener): + # Get launch context ROS node + launch_context = launch_service.context + node = launch_context.locals.launch_ros_node + + # This test shows how to insert a node in between the talker and the listener to + # change the data. Here we're going to change 'Hello World' to 'Aloha World' + def data_mangler(msg): + msg.data = msg.data.replace('Hello', 'Aloha') + return msg + + republisher = launch_testing_ros.DataRepublisher( + node, + 'talker_chatter', + 'listener_chatter', + std_msgs.msg.String, + data_mangler + ) + try: + # Spin for a few seconds until we've republished some mangled messages + end_time = time.time() + 10 + while time.time() < end_time: + time.sleep(1.0) + if republisher.get_num_republished() > 2: + break + + self.assertGreater(republisher.get_num_republished(), 2) + + # Sanity check that we're changing 'Hello World' + self.proc_output.assertWaitFor('Aloha World') + + # Check for the actual messages we sent + for msg in republisher.get_republished(): + self.proc_output.assertWaitFor(msg.data, listener) + finally: + republisher.shutdown() diff --git a/launch_testing_ros/test/test_examples.py b/launch_testing_ros/test/test_examples.py deleted file mode 100644 index fe13cf2c..00000000 --- a/launch_testing_ros/test/test_examples.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright 2019 Apex.AI, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import glob -import os -import subprocess - -import ament_index_python - -import pytest - - -testdata = glob.glob( - os.path.join( - ament_index_python.get_package_share_directory('launch_testing_ros'), - 'examples', - '*.test.py' - ) -) - - -# This test will automatically run for any *.test.py file in the examples folder and expect -# it to pass -@pytest.mark.parametrize('example_path', testdata, ids=[os.path.basename(d) for d in testdata]) -def test_examples(example_path): - - proc = ['launch_test', example_path] - - assert 0 == subprocess.run(args=proc).returncode From 44f81949b771c6b6079f0e65f2f4fbe342d03d8c Mon Sep 17 00:00:00 2001 From: Michel Hidalgo Date: Wed, 21 Aug 2019 12:35:41 -0300 Subject: [PATCH 3/7] Improve pytest rostest marker documentation. Signed-off-by: Michel Hidalgo --- launch_testing_ros/launch_testing_ros/pytest/hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/launch_testing_ros/launch_testing_ros/pytest/hooks.py b/launch_testing_ros/launch_testing_ros/pytest/hooks.py index 1e4fa8c1..7da4dd91 100644 --- a/launch_testing_ros/launch_testing_ros/pytest/hooks.py +++ b/launch_testing_ros/launch_testing_ros/pytest/hooks.py @@ -38,5 +38,5 @@ def pytest_launch_collect_makemodule(path, parent, entrypoint): def pytest_configure(config): config.addinivalue_line( - 'markers', 'rostest: mark a launch test as a ROS launch test' + 'markers', 'rostest: mark a generate_test_description function as a ROS launch test entrypoint' ) From 7983a7b238648a181d06128b60406cccd802b125 Mon Sep 17 00:00:00 2001 From: Michel Hidalgo Date: Wed, 21 Aug 2019 12:38:05 -0300 Subject: [PATCH 4/7] Move examples README to the root. Signed-off-by: Michel Hidalgo --- launch_testing_ros/{test/examples => }/README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) rename launch_testing_ros/{test/examples => }/README.md (86%) diff --git a/launch_testing_ros/test/examples/README.md b/launch_testing_ros/README.md similarity index 86% rename from launch_testing_ros/test/examples/README.md rename to launch_testing_ros/README.md index 7c7c9a59..0cc28bd5 100644 --- a/launch_testing_ros/test/examples/README.md +++ b/launch_testing_ros/README.md @@ -1,9 +1,14 @@ -# Examples +# launch\_testing\_ros -## `talker_listener_launch_test.py` +## Examples + +### `talker_listener_launch_test.py` Usage: -> launch\_test talker_listener\_launch\_test.py + +```sh +launch_test test/examples/talker_listener_launch_test.py +``` This test launches the talker and listener example nodes from demo\_nodes\_py and interacts with them via their ROS interfaces. Remapping rules are used so that one of the tests can sit in From e0c3c01138bfdfe7e5a372a58d947c436a8e7ef9 Mon Sep 17 00:00:00 2001 From: Michel Hidalgo Date: Wed, 21 Aug 2019 12:44:35 -0300 Subject: [PATCH 5/7] Remove single marker constraint for generate_test_description functions. Signed-off-by: Michel Hidalgo --- launch_testing_ros/launch_testing_ros/pytest/hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/launch_testing_ros/launch_testing_ros/pytest/hooks.py b/launch_testing_ros/launch_testing_ros/pytest/hooks.py index 7da4dd91..a99556fa 100644 --- a/launch_testing_ros/launch_testing_ros/pytest/hooks.py +++ b/launch_testing_ros/launch_testing_ros/pytest/hooks.py @@ -32,7 +32,7 @@ def makeitem(self, *args, **kwargs): def pytest_launch_collect_makemodule(path, parent, entrypoint): marks = getattr(entrypoint, 'pytestmark', []) - if marks and len(marks) == 1 and marks[0].name == 'rostest': + if marks and any(m.name == 'rostest' for m in marks): return LaunchROSTestModule(path, parent) From a77890c4337b1bde386870ee187e54de113741ce Mon Sep 17 00:00:00 2001 From: Michel Hidalgo Date: Wed, 21 Aug 2019 13:30:17 -0300 Subject: [PATCH 6/7] Please flake8 Signed-off-by: Michel Hidalgo --- launch_testing_ros/launch_testing_ros/pytest/hooks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/launch_testing_ros/launch_testing_ros/pytest/hooks.py b/launch_testing_ros/launch_testing_ros/pytest/hooks.py index a99556fa..1bb504e3 100644 --- a/launch_testing_ros/launch_testing_ros/pytest/hooks.py +++ b/launch_testing_ros/launch_testing_ros/pytest/hooks.py @@ -38,5 +38,6 @@ def pytest_launch_collect_makemodule(path, parent, entrypoint): def pytest_configure(config): config.addinivalue_line( - 'markers', 'rostest: mark a generate_test_description function as a ROS launch test entrypoint' + 'markers', + 'rostest: mark a generate_test_description function as a ROS launch test entrypoint' ) From ef7474c6267409feec23325fa2d54fd2836a818b Mon Sep 17 00:00:00 2001 From: Michel Hidalgo Date: Wed, 21 Aug 2019 15:19:36 -0300 Subject: [PATCH 7/7] Install examples properly. Signed-off-by: Michel Hidalgo --- launch_testing_ros/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/launch_testing_ros/setup.py b/launch_testing_ros/setup.py index 2e853466..89a7f2df 100644 --- a/launch_testing_ros/setup.py +++ b/launch_testing_ros/setup.py @@ -10,7 +10,7 @@ data_files=[ ('share/ament_index/resource_index/packages', ['resource/launch_testing_ros']), ('lib/launch_testing_ros', glob.glob('example_nodes/**')), - ('share/launch_testing_ros/examples', glob.glob('examples/[!_]**')), + ('share/launch_testing_ros/examples', glob.glob('test/examples/[!_]**')), ], entry_points={ 'pytest11': ['launch_ros = launch_testing_ros.pytest.hooks'],