Repository: felixdivo/ros2-easy-test (language: python)

Issue:
Support testing actions
Implements #25.

This is based on the great work of @vik748 in #27 (!) and some feedback from @Timple.


Discussion / hints:
@vik748 & @Timple: Does this suit your needs? Is the API helpful as-is?

If it looks good, I'd add a comment on it to the README and maybe extend a bit on the tests.
I can't reproduce the error in https://github.com/felixdivo/ros2-easy-test/actions/runs/9054380251/job/24874170253 locally, and it appears to be unrelated.
Looks great thanks.

Fix patch:
diff --git a/README.md b/README.md
index 66897e0..9f625bc 100644
--- a/README.md
+++ b/README.md
@@ -82,10 +82,12 @@ Using `ROS2TestEnvironment`, you can call:
 - `publish(topic: str, message: RosMessage) -> None`
 - `listen_for_messages(topic: str, time_span: float) -> List[RosMessage]`
 - `clear_messages(topic: str) -> None` to forget all messages that have been received so far.
-- `call_service(name: str, request: Request, timeout_availability: Optional[float], timeout_call: Optional[float]) -> Response`
+- `call_service(name: str, request: Request, ...) -> Response`
+- `send_action_goal(name: str, goal: Any, ...) -> Tuple[ClientGoalHandle, List[FeedbackMsg]]`
+- `send_action_goal_and_wait_for_result(name: str, goal: Any, ...) -> Tuple[List[FeedbackMsg], ResultMsg]`
 
-Note that `ROS2TestEnvironment` is a [`rclpy.node.Node`](https://docs.ros2.org/latest/api/rclpy/api/node.html) and thus has all the methods of a ROS2 node.
-So feel free to call offer a service with `env.create_service()`, interface with an action using `ActionClient(env, DoTheThing, 'maker')`, etc., to cover more specific use cases.
+Note that a `ROS2TestEnvironment` is a normal [`rclpy.node.Node`](https://docs.ros2.org/latest/api/rclpy/api/node.html) and thus has all the methods of any other ROS2 node.
+So feel free to offer a service with `env.create_service()` and cover more specific use cases.
 Extend as you please!
 
 In addition, nothing stops you from using any other means of interacting with ROS2 that would work otherwise.


Test patch:
diff --git a/ros2_easy_test/ros2_easy_test/env.py b/ros2_easy_test/ros2_easy_test/env.py
index de28d42..c11f066 100644
--- a/ros2_easy_test/ros2_easy_test/env.py
+++ b/ros2_easy_test/ros2_easy_test/env.py
@@ -492,10 +492,10 @@ def send_action_goal_and_wait_for_result(
         result = self.await_future(goal_handle.get_result_async(), timeout=timeout_get_result)
 
         # Make sure the goal was reached successfully
-        assert goal_handle.status == GoalStatus.STATUS_SUCCEEDED
+        assert result.status == GoalStatus.STATUS_SUCCEEDED, f"Goal did not succeed: {result.status=}"
 
         # Return the results to the test case
-        return feedbacks, result
+        return feedbacks, result.result
 
     def destroy_node(self):
         # Actions don't get destroyed automatically
diff --git a/ros2_easy_test/tests/example_nodes/minimal_action_server.py b/ros2_easy_test/tests/example_nodes/minimal_action_server.py
index 4d9defd..691df74 100644
--- a/ros2_easy_test/tests/example_nodes/minimal_action_server.py
+++ b/ros2_easy_test/tests/example_nodes/minimal_action_server.py
@@ -1,6 +1,8 @@
 # Modified from original example in examples_rclpy_minimal_action_server.server as follows:
 #       1. pass *args, **kwargs to __init__ and super().__init__.
 #       2. Reduce sleep times.
+#       3. Reject goal requests with order < 0.
+#       4. Cleanup/modernization.
 #
 # Copyright 2019 Open Source Robotics Foundation, Inc.
 #
@@ -45,8 +47,12 @@ def destroy(self):
     def goal_callback(self, goal_request):
         """Accept or reject a client request to begin an action."""
         # This server allows multiple goals in parallel
-        self.get_logger().info("Received goal request")
-        return GoalResponse.ACCEPT
+        if goal_request.order < 0:
+            self.get_logger().info("Rejecting goal request")
+            return GoalResponse.REJECT
+        else:
+            self.get_logger().info("Accepting goal request")
+            return GoalResponse.ACCEPT
 
     def cancel_callback(self, goal_handle):
         """Accept or reject a client request to cancel an action."""
diff --git a/ros2_easy_test/tests/test_actions.py b/ros2_easy_test/tests/test_actions.py
deleted file mode 100644
index 74f6927..0000000
--- a/ros2_easy_test/tests/test_actions.py
+++ /dev/null
@@ -1,47 +0,0 @@
-"""Tests that actions can be checked correctly."""
-
-# ROS2 infrastructure
-from example_interfaces.action import Fibonacci
-
-# What we are testing
-from ros2_easy_test import ROS2TestEnvironment, with_launch_file, with_single_node
-
-# Module under test and interfaces
-from . import LAUNCH_FILES
-from .example_nodes.minimal_action_server import MinimalActionServer
-
-
-@with_single_node(MinimalActionServer)
-def test_fibonacci_action_direct(env: ROS2TestEnvironment) -> None:
-    """Test calling an action."""
-
-    feedbacks, result = env.send_action_goal_and_wait_for_result(
-        name="fibonacci", goal=Fibonacci.Goal(order=4)
-    )
-
-    assert len(feedbacks) == 3
-    assert feedbacks == [
-        Fibonacci.Feedback(sequence=[0, 1, 1]),
-        Fibonacci.Feedback(sequence=[0, 1, 1, 2]),
-        Fibonacci.Feedback(sequence=[0, 1, 1, 2, 3]),
-    ]
-
-    assert result.result == Fibonacci.Result(sequence=[0, 1, 1, 2, 3])
-
-
-@with_launch_file(LAUNCH_FILES / "fibonacci_action.yaml")
-def test_fibonacci_action_launch_file(env: ROS2TestEnvironment) -> None:
-    """Test calling an action."""
-
-    feedbacks, result = env.send_action_goal_and_wait_for_result(
-        name="fibonacci", goal=Fibonacci.Goal(order=4)
-    )
-
-    assert len(feedbacks) == 3
-    assert feedbacks == [
-        Fibonacci.Feedback(sequence=[0, 1, 1]),
-        Fibonacci.Feedback(sequence=[0, 1, 1, 2]),
-        Fibonacci.Feedback(sequence=[0, 1, 1, 2, 3]),
-    ]
-
-    assert result.result == Fibonacci.Result(sequence=[0, 1, 1, 2, 3])
diff --git a/ros2_easy_test/tests/test_env_coverage.py b/ros2_easy_test/tests/test_env_coverage.py
index a55a0d8..7db9b42 100644
--- a/ros2_easy_test/tests/test_env_coverage.py
+++ b/ros2_easy_test/tests/test_env_coverage.py
@@ -4,6 +4,8 @@
 from unittest import TestCase
 
 # Testing
+from action_msgs.srv import CancelGoal
+from example_interfaces.action import Fibonacci
 from pytest import mark
 from std_msgs.msg import Empty, String
 
@@ -14,6 +16,7 @@
 from . import LAUNCH_FILES
 
 # Module under test and interfaces
+from .example_nodes.minimal_action_server import MinimalActionServer
 from .example_nodes.well_behaved import EchoNode, Talker
 
 
@@ -97,6 +100,59 @@ def test_mailbox_clearing_no_topics(self, env: ROS2TestEnvironment) -> None:
     def test_wrong_topic_type(self, env: ROS2TestEnvironment) -> None:
         pass
 
+    @mark.xfail(
+        raises=Exception,
+        reason="specifiying a wrong/nonexistent action server name a common mistake and shall fail loudly",
+        strict=True,
+    )
+    @with_single_node(MinimalActionServer)
+    def test_calling_nonexistent_action(self, env: ROS2TestEnvironment) -> None:
+        # The following isn't even an action at all, but these are no other actions easily available
+        env.send_action_goal("/does_no_exist", String("Hello World"))
+
+    @mark.xfail(
+        raises=TimeoutError,
+        reason="specifiying a wrong action message type is a common mistake and shall fail loudly",
+        strict=True,
+    )
+    @with_single_node(MinimalActionServer)
+    def test_calling_action_wrong_type(self, env: ROS2TestEnvironment) -> None:
+        env.send_action_goal("/does_no_exist", Fibonacci.Goal(order=1), timeout_availability=0.1)
+
+    @mark.xfail(
+        raises=AssertionError,
+        reason="rejections should be recognized as such",
+        strict=True,
+    )
+    @with_single_node(MinimalActionServer)
+    def test_action_rejection(self, env: ROS2TestEnvironment) -> None:
+        env.send_action_goal_and_wait_for_result("fibonacci", Fibonacci.Goal(order=-3))
+
+    @with_single_node(MinimalActionServer)
+    def test_cancelling_an_action(self, env: ROS2TestEnvironment) -> None:
+        goal_handle, _ = env.send_action_goal("fibonacci", Fibonacci.Goal(order=10))
+
+        cancel_response: CancelGoal.Response = goal_handle.cancel_goal()
+        assert cancel_response.return_code in {
+            CancelGoal.Response.ERROR_NONE,
+            CancelGoal.Response.ERROR_GOAL_TERMINATED,
+        }
+
+    @with_single_node(MinimalActionServer)
+    def test_concurrent_actions(self, env: ROS2TestEnvironment) -> None:
+        # (1) Call async
+        goal_handle1, _ = env.send_action_goal("fibonacci", Fibonacci.Goal(order=10))
+
+        # (2) Call sync
+        _, result2 = env.send_action_goal_and_wait_for_result("fibonacci", Fibonacci.Goal(order=1))
+
+        # Check result of (1)
+        result1 = goal_handle1.get_result().result
+        assert result1 == Fibonacci.Result(sequence=[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55])
+
+        # Check result of (2)
+        assert result2 == Fibonacci.Result(sequence=[0, 1])
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/ros2_easy_test/tests/test_interactions.py b/ros2_easy_test/tests/test_interactions.py
index ec2bf35..40ceca2 100644
--- a/ros2_easy_test/tests/test_interactions.py
+++ b/ros2_easy_test/tests/test_interactions.py
@@ -7,6 +7,7 @@
 from unittest import TestCase
 
 # Other ROS2 interfaces
+from example_interfaces.action import Fibonacci
 from example_interfaces.srv import AddTwoInts
 
 # Testing
@@ -20,6 +21,7 @@
 from . import LAUNCH_FILES
 
 # Module under test and interfaces
+from .example_nodes.minimal_action_server import MinimalActionServer
 from .example_nodes.well_behaved import AddTwoIntsServer, EchoNode, Talker
 
 
@@ -95,6 +97,27 @@ def test_multiple_messages(self, env: ROS2TestEnvironment, count: int = 5) -> No
         expected = [String(data=f"Hi #{identifier}") for identifier in range(count)]
         self.assertListEqual(all_messages, expected)
 
+    def test_calling_an_action(self, env: ROS2TestEnvironment) -> None:
+        feedbacks, result = env.send_action_goal_and_wait_for_result(
+            name="fibonacci", goal=Fibonacci.Goal(order=4)
+        )
+
+        assert len(feedbacks) == 3
+        assert feedbacks == [
+            Fibonacci.Feedback(sequence=[0, 1, 1]),
+            Fibonacci.Feedback(sequence=[0, 1, 1, 2]),
+            Fibonacci.Feedback(sequence=[0, 1, 1, 2, 3]),
+        ]
+
+        assert result == Fibonacci.Result(sequence=[0, 1, 1, 2, 3])
+
+        # We call it again to test if resources are reused properly
+        feedbacks2, result2 = env.send_action_goal_and_wait_for_result(
+            name="fibonacci", goal=Fibonacci.Goal(order=3)
+        )
+        assert feedbacks2 == feedbacks[:2]
+        assert result2 == Fibonacci.Result(sequence=[0, 1, 1, 2])
+
 
 class TestSingleNode(SharedTestCases, TestCase):
     """This test case uses the ``with_single_node`` decorator to set up the test environment."""
@@ -137,6 +160,10 @@ def test_multiple_messages_stress_test(self, env: ROS2TestEnvironment) -> None:
     def test_assertion_raised(self, env: ROS2TestEnvironment) -> None:
         self.fail("This should fail the test case")
 
+    @with_single_node(MinimalActionServer)
+    def test_calling_an_action(self, env: ROS2TestEnvironment) -> None:
+        super().test_calling_an_action(env)
+
 
 class TestLaunchFile(SharedTestCases, TestCase):
     """This test case uses the ``with_launch_file`` decorator to set up the test environment."""
@@ -195,6 +222,10 @@ def test_multiple_messages_stress_test(self, env: ROS2TestEnvironment) -> None:
     def test_assertion_raised(self, env: ROS2TestEnvironment) -> None:
         self.fail("This should fail the test case")
 
+    @with_launch_file(LAUNCH_FILES / "fibonacci_action.yaml")
+    def test_calling_an_action(self, env: ROS2TestEnvironment) -> None:
+        super().test_calling_an_action(env)
+
 
 if __name__ == "__main__":
     unittest.main()