From cfb0048fc9f9d99632f9c1eea9152d11d41e5b70 Mon Sep 17 00:00:00 2001 From: Felix Divo Date: Mon, 13 May 2024 16:40:34 +0000 Subject: [PATCH] Better testing of calling actions --- ros2_easy_test/ros2_easy_test/env.py | 4 +- .../example_nodes/minimal_action_server.py | 10 +++- ros2_easy_test/tests/test_actions.py | 47 ---------------- ros2_easy_test/tests/test_env_coverage.py | 56 +++++++++++++++++++ ros2_easy_test/tests/test_interactions.py | 31 ++++++++++ 5 files changed, 97 insertions(+), 51 deletions(-) delete mode 100644 ros2_easy_test/tests/test_actions.py 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()