diff --git a/codecov.yml b/codecov.yml
index 97106f32ff..764afc34e6 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -14,8 +14,6 @@ fixes:
 comment:
   layout: "diff, flags, files"
   behavior: default
-ignore:
-  - "**/test"
 flags:
   unittests:
     paths:
diff --git a/controller_manager/CMakeLists.txt b/controller_manager/CMakeLists.txt
index 543daeae29..53999d4f70 100644
--- a/controller_manager/CMakeLists.txt
+++ b/controller_manager/CMakeLists.txt
@@ -189,6 +189,11 @@ if(BUILD_TESTING)
     controller_manager_msgs
     ros2_control_test_assets
   )
+
+  find_package(ament_cmake_pytest REQUIRED)
+  install(FILES test/test_ros2_control_node.yaml
+    DESTINATION test)
+  ament_add_pytest_test(test_ros2_control_node test/test_ros2_control_node_launch.py)
 endif()
 
 # Install Python modules
diff --git a/controller_manager/package.xml b/controller_manager/package.xml
index 28f30141f6..2bacfd6289 100644
--- a/controller_manager/package.xml
+++ b/controller_manager/package.xml
@@ -28,6 +28,8 @@
   <depend>std_msgs</depend>
 
   <test_depend>ament_cmake_gmock</test_depend>
+  <test_depend>ament_cmake_pytest</test_depend>
+  <test_depend>python3-coverage</test_depend>
   <test_depend>hardware_interface_testing</test_depend>
   <test_depend>ros2_control_test_assets</test_depend>
 
diff --git a/controller_manager/test/test_ros2_control_node.yaml b/controller_manager/test/test_ros2_control_node.yaml
new file mode 100644
index 0000000000..ce0602d6b3
--- /dev/null
+++ b/controller_manager/test/test_ros2_control_node.yaml
@@ -0,0 +1,11 @@
+controller_manager:
+  ros__parameters:
+    update_rate: 100  # Hz
+
+ctrl_with_parameters_and_type:
+  ros__parameters:
+    type: "controller_manager/test_controller"
+    joint_names: ["joint0"]
+
+joint_state_broadcaster:
+  type: joint_state_broadcaster/JointStateBroadcaster
diff --git a/controller_manager/test/test_ros2_control_node_launch.py b/controller_manager/test/test_ros2_control_node_launch.py
new file mode 100644
index 0000000000..c8c1136849
--- /dev/null
+++ b/controller_manager/test/test_ros2_control_node_launch.py
@@ -0,0 +1,95 @@
+# Copyright (c) 2024 AIT - Austrian Institute of Technology GmbH
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#    * Redistributions of source code must retain the above copyright
+#      notice, this list of conditions and the following disclaimer.
+#
+#    * Redistributions in binary form must reproduce the above copyright
+#      notice, this list of conditions and the following disclaimer in the
+#      documentation and/or other materials provided with the distribution.
+#
+#    * Neither the name of the {copyright_holder} nor the names of its
+#      contributors may be used to endorse or promote products derived from
+#      this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# Author: Christoph Froehlich
+
+import pytest
+import unittest
+import time
+
+from launch import LaunchDescription
+from launch.substitutions import PathJoinSubstitution
+from launch_ros.substitutions import FindPackageShare
+from launch_testing.actions import ReadyToTest
+
+import launch_testing.markers
+import rclpy
+import launch_ros.actions
+from rclpy.node import Node
+
+
+# Executes the given launch file and checks if all nodes can be started
+@pytest.mark.launch_test
+def generate_test_description():
+
+    robot_controllers = PathJoinSubstitution(
+        [
+            FindPackageShare("controller_manager"),
+            "test",
+            "test_ros2_control_node.yaml",
+        ]
+    )
+
+    control_node = launch_ros.actions.Node(
+        package="controller_manager",
+        executable="ros2_control_node",
+        parameters=[robot_controllers],
+        output="both",
+    )
+
+    return LaunchDescription([control_node, ReadyToTest()])
+
+
+# This is our test fixture. Each method is a test case.
+# These run alongside the processes specified in generate_test_description()
+class TestFixture(unittest.TestCase):
+
+    def setUp(self):
+        rclpy.init()
+        self.node = Node("test_node")
+
+    def tearDown(self):
+        self.node.destroy_node()
+        rclpy.shutdown()
+
+    def test_node_start(self):
+        start = time.time()
+        found = False
+        while time.time() - start < 2.0 and not found:
+            found = "controller_manager" in self.node.get_node_names()
+            time.sleep(0.1)
+        assert found, "controller_manager not found!"
+
+
+@launch_testing.post_shutdown_test()
+# These tests are run after the processes in generate_test_description() have shutdown.
+class TestDescriptionCraneShutdown(unittest.TestCase):
+
+    def test_exit_codes(self, proc_info):
+        """Check if the processes exited normally."""
+        launch_testing.asserts.assertExitCodes(proc_info)
diff --git a/controller_manager/test/test_spawner_unspawner.cpp b/controller_manager/test/test_spawner_unspawner.cpp
index 017e98937c..e4cdf8a94f 100644
--- a/controller_manager/test/test_spawner_unspawner.cpp
+++ b/controller_manager/test/test_spawner_unspawner.cpp
@@ -28,6 +28,7 @@
 
 using ::testing::_;
 using ::testing::Return;
+const char coveragepy_script[] = "python3 -m coverage run --append --branch";
 
 using namespace std::chrono_literals;
 class TestLoadController : public ControllerManagerFixture<controller_manager::ControllerManager>
@@ -68,14 +69,18 @@ class TestLoadController : public ControllerManagerFixture<controller_manager::C
 
 int call_spawner(const std::string extra_args)
 {
-  std::string spawner_script = "ros2 run controller_manager spawner ";
+  std::string spawner_script =
+    std::string(coveragepy_script) +
+    " $(ros2 pkg prefix controller_manager)/lib/controller_manager/spawner ";
   return std::system((spawner_script + extra_args).c_str());
 }
 
 int call_unspawner(const std::string extra_args)
 {
-  std::string spawner_script = "ros2 run controller_manager unspawner ";
-  return std::system((spawner_script + extra_args).c_str());
+  std::string unspawner_script =
+    std::string(coveragepy_script) +
+    " $(ros2 pkg prefix controller_manager)/lib/controller_manager/unspawner ";
+  return std::system((unspawner_script + extra_args).c_str());
 }
 
 TEST_F(TestLoadController, spawner_with_no_arguments_errors)
@@ -236,7 +241,8 @@ TEST_F(TestLoadController, unload_on_kill)
   // timeout command will kill it after the specified time with signal SIGINT
   std::stringstream ss;
   ss << "timeout --signal=INT 5 "
-     << "ros2 run controller_manager spawner "
+     << std::string(coveragepy_script) +
+          " $(ros2 pkg prefix controller_manager)/lib/controller_manager/spawner "
      << "ctrl_3 -c test_controller_manager -t "
      << std::string(test_controller::TEST_CONTROLLER_CLASS_NAME) << " --unload-on-kill";