|
| 1 | +Writing Basic Integration Tests with launch_testing |
| 2 | +=================================================== |
| 3 | + |
| 4 | +**Goal:** Create and run integration tests on the ROS 2 turtlesim node. |
| 5 | + |
| 6 | +**Tutorial level:** Intermediate |
| 7 | + |
| 8 | +**Time:** 20 minutes |
| 9 | + |
| 10 | +.. contents:: Contents |
| 11 | + :depth: 2 |
| 12 | + :local: |
| 13 | + |
| 14 | +Prerequisites |
| 15 | +------------- |
| 16 | + |
| 17 | +Before starting this tutorial, it is recommended to have completed the following tutorials on launching nodes: |
| 18 | + |
| 19 | +* :doc:`Launching Multiple Nodes <../../Beginner-CLI-Tools/Launching-Multiple-Nodes/Launching-Multiple-Nodes>` |
| 20 | +* :doc:`Creating Launch files <../../Intermediate/Launch/Creating-Launch-Files>` |
| 21 | + |
| 22 | +Background |
| 23 | +---------- |
| 24 | + |
| 25 | +Where unit tests focus on validating a very specific piece of functionality, integration tests focus on validating the interaction between pieces of code. |
| 26 | +In ROS 2 this is often accomplished by launching a system of one or several nodes, for example the `Gazebo simulator <https://gazebosim.org/home>`__ and the `Nav2 navigation <https://github.com/ros-planning/navigation2.git>`__ stack. |
| 27 | +As a result, these tests are more complex both to set up and to run. |
| 28 | + |
| 29 | +A key aspect of ROS 2 integration testing is that nodes that are part of different tests shouldn't communicate with each other, even when run in parallel. |
| 30 | +This will be achieved here using a specific test runner that picks unique :doc:`ROS domain IDs <../../../Concepts/Intermediate/About-Domain-ID>`. |
| 31 | +In addition, integration tests have to fit in the overall testing workflow. |
| 32 | +A standardized approach is to ensure each test outputs an XUnit file, which are easily parsed using common test tooling. |
| 33 | + |
| 34 | +Overview |
| 35 | +-------- |
| 36 | + |
| 37 | +The main tool in use here is the `launch_testing <https://docs.ros.org/en/{DISTRO}/p/launch_testing/index.html>`_ package |
| 38 | +(`launch_testing repository <https://github.com/ros2/launch/tree/{REPOS_FILE_BRANCH}/launch_testing>`_). |
| 39 | +This ROS-agnostic functionality can extend a Python launch file with both active tests (that run while the nodes are also running) and post-shutdown tests (which run once after all nodes have exited). |
| 40 | +``launch_testing`` relies on the Python standard module `unittest <https://docs.python.org/3/library/unittest.html>`_ for the actual testing. |
| 41 | +To get our integration tests run as part of ``colcon test``, we register the launch file in the ``CMakeLists.txt``. |
| 42 | + |
| 43 | +Steps |
| 44 | +----- |
| 45 | + |
| 46 | +1 Describe the test in the test launch file |
| 47 | +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ |
| 48 | + |
| 49 | +Both the nodes under test and the tests themselves are launched using a Python launch file, which resembles a ROS 2 Python launch file. |
| 50 | +It is customary to make the integration test launch file names follow the pattern ``test/test_*.py``. |
| 51 | + |
| 52 | +There are two common types of tests in integration testing: active tests, which run while the nodes under test are running, and post-shutdown tests, which are run after exiting the nodes. |
| 53 | +We will cover both in this tutorial. |
| 54 | + |
| 55 | +1.1 Imports |
| 56 | +~~~~~~~~~~~ |
| 57 | + |
| 58 | +We first start by importing the Python modules we will be using. |
| 59 | +Only two modules are specific to testing: the general-purpose ``unittest``, and ``launch_testing``. |
| 60 | + |
| 61 | +.. code-block:: python |
| 62 | +
|
| 63 | + import os |
| 64 | + import sys |
| 65 | + import time |
| 66 | + import unittest |
| 67 | +
|
| 68 | + import launch |
| 69 | + import launch_ros |
| 70 | + import launch_testing.actions |
| 71 | + import rclpy |
| 72 | + from turtlesim.msg import Pose |
| 73 | +
|
| 74 | +1.2 Generate the test description |
| 75 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 76 | + |
| 77 | +The function ``generate_test_description`` describes what to launch, similar to ``generate_launch_description`` in a ROS 2 Python launch file. |
| 78 | +In the example below, we launch the turtlesim node and half a second later our tests. |
| 79 | + |
| 80 | +In more complex integration test setups, you will probably want to launch a system of several nodes, together with additional nodes that perform mocking or must otherwise interact with the nodes under test. |
| 81 | + |
| 82 | +.. code-block:: python |
| 83 | +
|
| 84 | + def generate_test_description(): |
| 85 | + return ( |
| 86 | + launch.LaunchDescription( |
| 87 | + [ |
| 88 | + # Nodes under test |
| 89 | + launch_ros.actions.Node( |
| 90 | + package='turtlesim', |
| 91 | + namespace='', |
| 92 | + executable='turtlesim_node', |
| 93 | + name='turtle1', |
| 94 | + ), |
| 95 | + # Launch tests 0.5 s later |
| 96 | + launch.actions.TimerAction( |
| 97 | + period=0.5, actions=[launch_testing.actions.ReadyToTest()]), |
| 98 | + ] |
| 99 | + ), {}, |
| 100 | + ) |
| 101 | +
|
| 102 | +1.3 Active tests |
| 103 | +~~~~~~~~~~~~~~~~ |
| 104 | + |
| 105 | +The active tests interact with the running nodes. |
| 106 | +In this tutorial, we will check whether the turtlesim node publishes pose messages (by listening to the node's 'turtle1/pose' topic) and whether it logs that it spawned the turtle (by listening to stderr). |
| 107 | + |
| 108 | +The active tests are defined as methods of a class inheriting from `unittest.TestCase <https://docs.python.org/3/library/unittest.html#unittest.TestCase>`_. |
| 109 | +The child class, here ``TestTurtleSim``, contains the following methods: |
| 110 | + |
| 111 | +- ``test_*``: the test methods, each performing some ROS communication with the nodes under test and/or listening to the process output (passed in through ``proc_output``). |
| 112 | + They are executed sequentially. |
| 113 | +- ``setUp``, ``tearDown``: respectively run before (to prepare the test fixture) and after executing each test method. |
| 114 | + By creating the node in the ``setUp`` method, we use a different node instance for each test to reduce the risk of tests communicating with each other. |
| 115 | +- ``setUpClass``, ``tearDownClass``: these class methods respectively run once before and after executing all the test methods. |
| 116 | + |
| 117 | +It's highly recommended to go through `launch_testing's detailed documentation on this topic <https://docs.ros.org/en/{DISTRO}/p/launch_testing/index.html>`_. |
| 118 | + |
| 119 | +.. code-block:: python |
| 120 | +
|
| 121 | + # Active tests |
| 122 | + class TestTurtleSim(unittest.TestCase): |
| 123 | + @classmethod |
| 124 | + def setUpClass(cls): |
| 125 | + rclpy.init() |
| 126 | +
|
| 127 | + @classmethod |
| 128 | + def tearDownClass(cls): |
| 129 | + rclpy.shutdown() |
| 130 | +
|
| 131 | + def setUp(self): |
| 132 | + self.node = rclpy.create_node('test_turtlesim') |
| 133 | +
|
| 134 | + def tearDown(self): |
| 135 | + self.node.destroy_node() |
| 136 | +
|
| 137 | + def test_publishes_pose(self, proc_output): |
| 138 | + """Check whether pose messages published""" |
| 139 | + msgs_rx = [] |
| 140 | + sub = self.node.create_subscription( |
| 141 | + Pose, 'turtle1/pose', |
| 142 | + lambda msg: msgs_rx.append(msg), 100) |
| 143 | + try: |
| 144 | + # Listen to the pose topic for 10 s |
| 145 | + end_time = time.time() + 10 |
| 146 | + while time.time() < end_time: |
| 147 | + # spin to get subscriber callback executed |
| 148 | + rclpy.spin_once(self.node, timeout_sec=1) |
| 149 | + # There should have been 100 messages received |
| 150 | + assert len(msgs_rx) > 100 |
| 151 | + finally: |
| 152 | + self.node.destroy_subscription(sub) |
| 153 | +
|
| 154 | + def test_logs_spawning(self, proc_output): |
| 155 | + """Check whether logging properly""" |
| 156 | + proc_output.assertWaitFor( |
| 157 | + 'Spawning turtle [turtle1] at x=', |
| 158 | + timeout=5, stream='stderr') |
| 159 | +
|
| 160 | +Note that the way we listen to the 'turtle1/pose' topic in ``test_publishes_pose`` differs from :doc:`the usual approach <../../Beginner-Client-Libraries/Writing-A-Simple-Py-Publisher-And-Subscriber>`. |
| 161 | +Instead of calling the blocking ``rclpy.spin``, we trigger the ``spin_once`` method - which executes the first available callback (our subscriber callback if a message arrived within 1 second) - until we have gathered all messages published over the last 10 seconds. |
| 162 | +The package `launch_testing_ros <https://docs.ros.org/en/{DISTRO}/p/launch_testing_ros/index.html>`_ provides some convenience functions to achieve similar behavior, |
| 163 | +such as `WaitForTopics <https://docs.ros.org/en/{DISTRO}/p/launch_testing_ros/launch_testing_ros.wait_for_topics.html>`_. |
| 164 | + |
| 165 | +If you want to go further, you can implement a third test that publishes a twist message, asking the turtle to move, and subsequently checks that it moved by asserting that the pose message changed. |
| 166 | +This effectively automates part of the `Turtlesim introduction tutorial <../../Beginner-CLI-Tools/Introducing-Turtlesim/Introducing-Turtlesim>`. |
| 167 | + |
| 168 | +1.4 Post-shutdown tests |
| 169 | +~~~~~~~~~~~~~~~~~~~~~~~ |
| 170 | + |
| 171 | +The classes marked with the ``launch_testing.post_shutdown_test`` decorator are run after letting the nodes under test exit. |
| 172 | +A typical test here is whether the nodes exited cleanly, for which ``launch_testing`` provides the method |
| 173 | +`asserts.assertExitCodes <https://docs.ros.org/en/{DISTRO}/p/launch_testing/launch_testing.asserts.html#launch_testing.asserts.assertExitCodes>`_. |
| 174 | + |
| 175 | +.. code-block:: python |
| 176 | +
|
| 177 | + # Post-shutdown tests |
| 178 | + @launch_testing.post_shutdown_test() |
| 179 | + class TestTurtleSimShutdown(unittest.TestCase): |
| 180 | + def test_exit_codes(self, proc_info): |
| 181 | + """Check if the processes exited normally.""" |
| 182 | + launch_testing.asserts.assertExitCodes(proc_info) |
| 183 | +
|
| 184 | +2 Register the test in the CMakeLists.txt |
| 185 | +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ |
| 186 | + |
| 187 | +Registering the test in the ``CMakeLists.txt`` fulfills two functions: |
| 188 | + |
| 189 | +- it integrates it in the ``CTest`` framework ROS 2 CMake-based packages rely on |
| 190 | + (and hence it will be called when running ``colcon test``). |
| 191 | +- it allows to specify *how* the test is to be run - |
| 192 | + in this case, with a unique domain id to ensure test isolation. |
| 193 | + |
| 194 | +This latter aspect is realized using the special test runner `run_test_isolated.py <https://github.com/ros2/ament_cmake_ros/blob/{REPOS_FILE_BRANCH}/ament_cmake_ros/cmake/run_test_isolated.py>`_. |
| 195 | +To ease adding several integration tests, we define the CMake function ``add_ros_isolated_launch_test`` such that each additional test requires only a single line. |
| 196 | + |
| 197 | +.. code-block:: cmake |
| 198 | +
|
| 199 | + cmake_minimum_required(VERSION 3.8) |
| 200 | + project(app) |
| 201 | +
|
| 202 | + ######## |
| 203 | + # test # |
| 204 | + ######## |
| 205 | +
|
| 206 | + if(BUILD_TESTING) |
| 207 | + # Integration tests |
| 208 | + find_package(ament_cmake_ros REQUIRED) |
| 209 | + find_package(launch_testing_ament_cmake REQUIRED) |
| 210 | + function(add_ros_isolated_launch_test path) |
| 211 | + set(RUNNER "${ament_cmake_ros_DIR}/run_test_isolated.py") |
| 212 | + add_launch_test("${path}" RUNNER "${RUNNER}" ${ARGN}) |
| 213 | + endfunction() |
| 214 | + add_ros_isolated_launch_test(test/test_integration.py) |
| 215 | + endif() |
| 216 | +
|
| 217 | +3 Dependencies and package organization |
| 218 | +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ |
| 219 | + |
| 220 | +Finally, add the following dependencies to your ``package.xml``: |
| 221 | + |
| 222 | +.. code-block:: XML |
| 223 | +
|
| 224 | + <test_depend>ament_cmake_ros</test_depend> |
| 225 | + <test_depend>launch</test_depend> |
| 226 | + <test_depend>launch_ros</test_depend> |
| 227 | + <test_depend>launch_testing</test_depend> |
| 228 | + <test_depend>launch_testing_ament_cmake</test_depend> |
| 229 | + <test_depend>rclpy</test_depend> |
| 230 | + <test_depend>turtlesim</test_depend> |
| 231 | +
|
| 232 | +After following the above steps, your package (here named 'app') ought to look as follows: |
| 233 | + |
| 234 | +.. code-block:: |
| 235 | +
|
| 236 | + app/ |
| 237 | + CMakeLists.txt |
| 238 | + package.xml |
| 239 | + tests/ |
| 240 | + test_integration.py |
| 241 | +
|
| 242 | +Integration tests can be part of any ROS package. |
| 243 | +One can dedicate one or more packages to just integration testing, or alternatively add them to the package of which they test the functionality. |
| 244 | +In this tutorial, we go with the first option as we will test the existing turtlesim node. |
| 245 | + |
| 246 | +4 Running tests and report generation |
| 247 | +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ |
| 248 | + |
| 249 | +For running the integration test and examining the results, see the tutorial :doc:`Running Tests in ROS 2 from the Command Line<../../Intermediate/Testing/CLI>`. |
| 250 | + |
| 251 | +Summary |
| 252 | +------- |
| 253 | + |
| 254 | +In this tutorial, we explored the process of creating and running integration tests on the ROS 2 turtlesim node. |
| 255 | +We discussed the integration test launch file and covered writing active tests and post-shutdown tests. |
| 256 | +To recap, the four key elements of the integration test launch file are: |
| 257 | + |
| 258 | +* The function ``generate_test_description``: This launches our nodes under tests as well as our tests. |
| 259 | +* ``launch_testing.actions.ReadyToTest()``: This alerts the test framework that the tests should be run, and ensures that the active tests and the nodes are run together. |
| 260 | +* An undecorated class inheriting from ``unittest.TestCase``: This houses the active tests, including set up and teardown, and gives access to ROS logging through ``proc_output``. |
| 261 | +* A second class inheriting from ``unittest.TestCase`` decorated with ``@launch_testing.post_shutdown_test()``: These are tests that run after all nodes have shutdown; it is common to assert that the nodes exited cleanly. |
| 262 | + |
| 263 | +The launch test is subsequently registered in the ``CMakeLists.txt`` using the custom cmake macro ``add_ros_isolated_launch_test`` which ensures that each launch test runs with a unique ``ROS_DOMAIN_ID``, |
| 264 | +avoiding undesired cross communication. |
| 265 | + |
| 266 | +Related content |
| 267 | +--------------- |
| 268 | + |
| 269 | +* :doc:`Why automatic tests? <../../Intermediate/Testing/Testing-Main>` |
| 270 | +* :doc:`C++ unit testing with GTest <../../Intermediate/Testing/Cpp>` |
| 271 | + and :doc:`Python unit testing with Pytest <../../Intermediate/Testing/Python>` |
| 272 | +* `launch_pytest documentation <https://docs.ros.org/en/{DISTRO}/p/launch_pytest/index.html>`_, |
| 273 | + an alternative launch integration testing package to ``launch_testing`` |
0 commit comments