This tutorial introduces unit testing in ROS 2 using GTest (GoogleTest), Google’s C++ testing framework. We’ll explore how to write and run tests for ROS 2 nodes, ensuring your robotic software functions correctly at the component level.
By the end, you’ll be equipped to implement comprehensive unit tests, enhancing the reliability and maintainability of your ROS 2 projects.
Prerequisites
- You have completed this tutorial: How to Create a Subscriber Node in C++ – Jazzy (required).
- You have completed this tutorial: How to Create Unit Tests with Pytest – ROS 2 Jazzy (required).
- I am assuming you are using Visual Studio Code, but you can use any code editor.
You can find all the code here on GitHub.
Why Use GTest
Imagine you’re building a complex robot using ROS 2, with multiple C++ nodes working together to control its behavior. Every time you update your code, install a new version of ROS 2, or upgrade any dependencies, something could break without you realizing it. This is where GTest comes in handy.
GTest is like a safety net for your robot’s C++ code. It automatically checks that everything still works correctly after you make changes. For example, if you update your navigation code or install a new version of a sensor driver, GTest can quickly verify that your nodes are still publishing the right messages at the right times. This helps you catch problems early – before your robot starts behaving unexpectedly in the real world.
Create the Unit Test
Let’s write a basic unit test for our ROS 2 C++ publisher node. The test code we will write sets up a testing environment for a ROS 2 publisher node, making sure it can properly start up and shut down.
The first test checks if the node is created with the right name and is publishing on the correct topic. The second test verifies that the node actually publishes messages with the correct “Hello World!” format we expect.
Open a terminal window and move to your workspace:
cd ~/ros2_ws/
colcon build && source ~/.bashrc
cd ~/ros2_ws/src/ros2_fundamentals_examples/
Create a new folder:
mkdir -p test/gtest
Create the test file test/gtest/test_publisher.cpp:
/**
* @file test_publisher.cpp
* @brief Unit tests for the ROS 2 minimal publisher node.
*
* These tests verify the functionality of a minimal ROS 2 publisher including
* node creation, message formatting, and publishing behavior.
*
* @author Addison Sears-Collins
* @date November 6, 2024
*/
#include <gtest/gtest.h>
#include <memory>
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"
#include "../../src/cpp_minimal_publisher.cpp"
/**
* @class TestMinimalPublisher
* @brief Test fixture for the MinimalCppPublisher tests
*
* This fixture handles the setup and teardown of ROS 2 infrastructure
* needed for each test case.
*/
class TestMinimalPublisher : public ::testing::Test
{
protected:
void SetUp() override
{
rclcpp::init(0, nullptr);
node = std::make_shared<MinimalCppPublisher>();
}
void TearDown() override
{
node.reset();
rclcpp::shutdown();
}
std::shared_ptr<MinimalCppPublisher> node;
};
/**
* @test TestNodeCreation
* @brief Verify the publisher node is created correctly
*
* Tests:
* - Node name is set correctly
* - Publisher exists on the correct topic
* - Message type is correct
*/
TEST_F(TestMinimalPublisher, TestNodeCreation)
{
// Verify the node name is set correctly
EXPECT_EQ(node->get_name(), "minimal_cpp_publisher");
// Get all publisher endpoints
auto pub_endpoints = node->get_publishers_info_by_topic("/cpp_example_topic");
// Verify we have exactly one publisher on the topic
EXPECT_EQ(pub_endpoints.size(), 1u);
// Verify the topic name and message type
EXPECT_EQ(pub_endpoints[0].topic_name, "/cpp_example_topic");
EXPECT_EQ(pub_endpoints[0].topic_type, "std_msgs/msg/String");
}
/**
* @test TestMessageContent
* @brief Verify the published message content is correct
*
* Creates a subscription to capture published messages and verifies
* the message format matches expectations.
*/
TEST_F(TestMinimalPublisher, TestMessageContent)
{
// Create a subscription to capture the published message
std::shared_ptr<std_msgs::msg::String> received_msg;
auto subscription = node->create_subscription<std_msgs::msg::String>(
"/cpp_example_topic", 10,
[&received_msg](const std_msgs::msg::String::SharedPtr msg) {
received_msg = std::make_shared<std_msgs::msg::String>(*msg);
});
// Trigger the timer callback
node->timerCallback();
// Spin until we receive the message or timeout
rclcpp::spin_some(node);
// Verify the message format
ASSERT_NE(received_msg, nullptr);
EXPECT_EQ(received_msg->data, "Hello World! 0");
}
/**
* @brief Main function to run all tests
*/
int main(int argc, char** argv)
{
testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
Edit the C++ Publisher
Open cpp_minimal_publisher.cpp.
We need to wrap the main function in the TESTING_EXCLUDE_MAIN condition so it won’t be included during testing. Here’s how to modify it:
#ifndef TESTING_EXCLUDE_MAIN
int main(int argc, char * argv[])
{
// Initialize ROS 2.
rclcpp::init(argc, argv);
// Create an instance of the MinimalCppPublisher node and keep it running.
auto minimal_cpp_publisher_node = std::make_shared<MinimalCppPublisher>();
rclcpp::spin(minimal_cpp_publisher_node);
// Shutdown ROS 2 upon node termination.
rclcpp::shutdown();
// End of program.
return 0;
}
#endif
By adding the #ifndef TESTING_EXCLUDE_MAIN and #endif, this main function will only be compiled when we’re not running tests. When we include this file in our test file (where we defined TESTING_EXCLUDE_MAIN), this main function will be skipped.
Edit CMakeLists.txt
Update CMakeLists.txt. Find the if(BUILD_TESTING) block and replace it with:
if(BUILD_TESTING)
# Find required test packages
find_package(ament_lint_auto REQUIRED)
find_package(ament_cmake_pytest REQUIRED)
find_package(ament_cmake_gtest REQUIRED)
# Python tests
ament_add_pytest_test(minimal_publisher_test test/pytest/test_publisher.py
TIMEOUT 60
)
# C++ tests
ament_add_gtest(cpp_minimal_publisher_test
test/gtest/test_publisher.cpp
)
# Add dependencies for the C++ test executable
ament_target_dependencies(cpp_minimal_publisher_test
${dependencies}
)
# Optional: Enable other linting tools
#set(ament_cmake_copyright_FOUND TRUE)
#set(ament_cmake_cpplint_FOUND TRUE)
#ament_lint_auto_find_test_dependencies()
endif()
Edit package.xml
We need to add this to the package.xml so that our tests are discoverable to cmake:
<test_depend>ament_cmake_gtest</test_depend>
Save the file, and close it.
Build and Run
Build and run the tests:
cd ~/ros2_ws
colcon build --packages-select ros2_fundamentals_examples
source ~/.bashrc
colcon test --packages-select ros2_fundamentals_examples
To see detailed test results, type:
colcon test --packages-select ros2_fundamentals_examples --event-handlers console_direct+
When we run the tests using colcon test, it first runs the Python tests which all pass (“3 passed in 0.37s”). Then it runs our two C++ GTest tests: one checking the node name and publisher setup, and another checking the message content. Both C++ tests pass as shown by the output “[ PASSED ] 2 tests” and “100% tests passed, 0 tests failed out of 2”, indicating that our publisher node is working exactly as intended.
To check test results:
colcon test-result --all
To see which test cases failed (if any):
colcon test-result --all --verbose
That’s it. Keep building!