Unit Testing Robotic Software Components: Best Practices in C++

Unit Testing Robotic Software Components: Best Practices in C++
15 minutes

Apr 22, 2024

Stay in the loop

Join thousands of readers and get the best content delivered directly to your inbox.

Get a list of personally curated and freely accessible ML, NLP, and computer vision resources for FREE on newsletter sign-up.

Writing reliable software for robotic systems presents unique challenges that set it apart from conventional software development. When a single line of code can translate into physical movement, the stakes for accuracy and reliability become significantly higher. This is where comprehensive unit testing becomes not just good practice, but essential for safety and reliability.

The Foundation: Why Unit Testing Matters in Robotics

When developing software for robots, we're not just dealing with data transformations – we're writing code that controls physical systems that interact with the real world. A bug in a robot's collision avoidance system or joint controller isn't just an inconvenience; it could lead to hardware damage or safety incidents.

💡

Unit testing in robotics is about more than just code coverage – it's about building confidence in your robot's behavior before it makes its first move.

Understanding Google Test Basics

Before diving into robotics-specific testing, let's understand the key elements of the Google Test framework that we'll be using throughout this guide.

Test Fixtures with TEST_F

TEST_F stands for "Test Fixture" and is used when you need to create a common environment for multiple tests. The 'F' suffix indicates that the test uses a fixture class. This is particularly useful in robotics testing where multiple tests need the same initialized hardware interfaces or controller objects.

// Test fixture class
class JointControllerTest : public ::testing::Test {
protected:
    void SetUp() override {
        // Called before each test
        controller = std::make_unique(
            0.1,  // control frequency (Hz)
            45.0, // max velocity (deg/s)
            5.0   // max acceleration (deg/s^2)
        );
    }

    void TearDown() override {
        // Called after each test
        controller.reset();
    }

    std::unique_unique controller;
};

// Using TEST_F with our fixture
TEST_F(JointControllerTest, InitializesInIdleState) {
    EXPECT_EQ(controller->getState(), JointController::State::IDLE);
}
💡

Why use TEST_F instead of TEST?

  • TEST_F maintains state between test steps
  • Provides SetUp() and TearDown() hooks for initialization and cleanup
  • Allows sharing common objects across multiple tests
  • Ensures a fresh copy of the test fixture for each test case

Parameterized Tests with TEST_P

When you need to run the same test with different parameters, TEST_P (Parameterized Test) becomes valuable. This is especially useful for testing robot behaviors across different operating conditions:

class JointControllerParamTest : public ::testing::TestWithParam<
    std::tuple> {  // velocity, acceleration, target_position
protected:
    std::unique_ptr controller;
};

TEST_P(JointControllerParamTest, ReachesTargetPosition) {
    auto [velocity, acceleration, target] = GetParam();
    controller = std::make_unique(0.1, velocity, acceleration);
    
    EXPECT_TRUE(controller->enable());
    EXPECT_TRUE(controller->startMotion(target));
    EXPECT_TRUE(controller->waitForMotionComplete(5000));
    EXPECT_NEAR(controller->getCurrentPosition(), target, 0.1);
}

// Define test parameters
INSTANTIATE_TEST_SUITE_P(
    MotionProfiles,
    JointControllerParamTest,
    ::testing::Values(
        std::make_tuple(30.0, 5.0, 45.0),   // Normal motion
        std::make_tuple(10.0, 2.0, 90.0),   // Slow motion
        std::make_tuple(60.0, 10.0, 180.0)  // Fast motion
    )
);

Mock Objects and EXPECT_CALL

Google Mock (part of Google Test) provides powerful features for creating mock objects. The EXPECT_CALL macro is used to set expectations on mock object behavior:

class MockJointSensor : public IJointSensor {
public:
    MOCK_METHOD(double, getPosition, (), (override));
    MOCK_METHOD(double, getVelocity, (), (override));
    MOCK_METHOD(bool, isCalibrated, (), (override));
};

TEST_F(JointControllerTest, HandlesSensorFailure) {
    MockJointSensor mockSensor;
    
    // EXPECT_CALL syntax:
    // EXPECT_CALL(mock_object, method_name(parameters))
    //     .WillOnce/WillRepeatedly(Return(value));
    
    // Expect getPosition to be called exactly twice
    EXPECT_CALL(mockSensor, getPosition())
        .WillOnce(Return(45.0))        // First call returns 45.0
        .WillOnce(::testing::Throw(SensorException()));  // Second call throws
    
    // Expect isCalibrated to be called any number of times
    EXPECT_CALL(mockSensor, isCalibrated())
        .WillRepeatedly(Return(true));
    
    controller->setSensor(&mockSensor);
    
    // First read should succeed
    EXPECT_EQ(controller->readPosition(), 45.0);
    
    // Second read should trigger error handling
    EXPECT_THROW(controller->readPosition(), SensorException);
    EXPECT_EQ(controller->getState(), JointController::State::FAULT);
}

Assertion Macros Explained

Google Test provides various assertion macros for different testing needs:

TEST_F(JointControllerTest, AssertionExamples) {
    // EXPECT vs ASSERT:
    // EXPECT_* continues running the test after failure
    // ASSERT_* stops the test immediately upon failure
    
    // Equality checks
    EXPECT_EQ(controller->getState(), JointController::State::IDLE);  // ==
    EXPECT_NE(controller->getVelocity(), 10.0);  // !=
    
    // Comparison checks
    EXPECT_LT(controller->getVelocity(), 50.0);  // <
    EXPECT_LE(controller->getPosition(), 180.0);  // <=
    EXPECT_GT(controller->getMinPosition(), -180.0);  // >
    EXPECT_GE(controller->getMaxPosition(), 180.0);  // >=
    
    // Floating point comparisons (with tolerance)
    EXPECT_NEAR(controller->getPosition(), 45.0, 0.001);  // abs(a-b) <= tolerance
    
    // Boolean checks
    EXPECT_TRUE(controller->enable());
    EXPECT_FALSE(controller->isMoving());
    
    // Exception checks
    EXPECT_THROW(controller->setVelocity(999.0), std::out_of_range);
    EXPECT_NO_THROW(controller->setVelocity(45.0));
}

Setting Up Your Testing Environment

Before diving into specific testing practices, let's set up a robust testing environment using Google Test, one of the most popular testing frameworks for C++. Here's a basic setup that we'll build upon:

#include <gtest/gtest.h>
#include <memory>
#include "robot_joint_controller.hpp"

class JointControllerTest : public ::testing::Test {
protected:
    void SetUp() override {
        // Create a mock joint controller with safe testing parameters
        controller = std::make_unique<JointController>(
            0.1,  // control frequency (Hz)
            45.0, // max velocity (deg/s)
            5.0   // max acceleration (deg/s^2)
        );
    }

    void TearDown() override {
        controller.reset();
    }

    std::unique_ptr<JointController> controller;
};

Testing State Transitions

One of the most critical aspects of robotic software is state management. Robots often transition between different operational modes, and these transitions must be both safe and predictable.

TEST_F(JointControllerTest, StateTransitionsShouldBeValid) {
    // Initialize in IDLE state
    EXPECT_EQ(controller->getState(), JointController::State::IDLE);
    
    // Enable the controller
    EXPECT_TRUE(controller->enable());
    EXPECT_EQ(controller->getState(), JointController::State::ENABLED);
    
    // Verify that invalid state transitions are prevented
    EXPECT_FALSE(controller->enable());  // Can't enable an enabled controller
    
    // Start motion
    EXPECT_TRUE(controller->startMotion(30.0));  // Move to 30 degrees
    EXPECT_EQ(controller->getState(), JointController::State::MOVING);
    
    // Verify emergency stop behavior
    controller->emergencyStop();
    EXPECT_EQ(controller->getState(), JointController::State::E_STOP);
    // Verify we can't start motion from E_STOP
    EXPECT_FALSE(controller->startMotion(0.0));
}

Testing Numerical Computations

Robotic systems often involve complex mathematical operations. When testing these, we need to account for floating-point precision and ensure our calculations are both accurate and numerically stable.

TEST_F(JointControllerTest, VelocityProfileCalculationShouldBeAccurate) {
    const double start_pos = 0.0;
    const double target_pos = 90.0;
    const double max_vel = 30.0;
    const double max_acc = 5.0;
    
    auto profile = controller->calculateVelocityProfile(
        start_pos, target_pos, max_vel, max_acc);
    
    // Use appropriate tolerance for floating-point comparisons
    constexpr double kEpsilon = 1e-6;
    
    // Verify acceleration phase
    EXPECT_NEAR(profile.acceleration_time, 6.0, kEpsilon);
    EXPECT_NEAR(profile.peak_velocity, max_vel, kEpsilon);
    
    // Verify total movement time
    double expected_total_time = 9.0;  // Calculated based on kinematics
    EXPECT_NEAR(profile.total_time, expected_total_time, kEpsilon);
}

Mock Objects and Dependency Injection

Robotic components often depend on hardware interfaces, sensors, and other systems. Using mock objects allows us to test our components in isolation while simulating various scenarios.

class MockJointSensor : public IJointSensor {
public:
    MOCK_METHOD(double, getPosition, (), (override));
    MOCK_METHOD(double, getVelocity, (), (override));
    MOCK_METHOD(bool, isCalibrated, (), (override));
};

TEST_F(JointControllerTest, HandlesLimitSwitchTrigger) {
    MockJointSensor mockSensor;
    
    // Set up expectations
    EXPECT_CALL(mockSensor, getPosition())
        .WillOnce(Return(45.0))
        .WillOnce(Return(45.1))  // Slight movement
        .WillOnce(Return(45.1)); // Stopped after limit trigger
        
    EXPECT_CALL(mockSensor, isCalibrated())
        .WillRepeatedly(Return(true));
    
    controller->setSensor(&mockSensor);
    
    // Simulate hitting a limit switch
    controller->onLimitSwitchTriggered();
    
    // Verify that the controller stops movement
    EXPECT_EQ(controller->getState(), JointController::State::LIMIT_TRIGGERED);
    EXPECT_NEAR(controller->getCommandedVelocity(), 0.0, 1e-6);
}

Testing Error Handling and Recovery

Robust error handling is crucial in robotics. Our tests should verify that our components handle errors gracefully and maintain system safety.

TEST_F(JointControllerTest, RecoversFromCommunicationTimeout) {
    // Simulate normal operation
    EXPECT_TRUE(controller->enable());
    EXPECT_TRUE(controller->startMotion(45.0));
    
    // Simulate communication timeout
    controller->onCommunicationTimeout();
    
    // Verify safe state
    EXPECT_EQ(controller->getState(), JointController::State::FAULT);
    EXPECT_NEAR(controller->getCommandedVelocity(), 0.0, 1e-6);
    
    // Test recovery sequence
    EXPECT_TRUE(controller->clearFaults());
    EXPECT_TRUE(controller->enable());
    EXPECT_EQ(controller->getState(), JointController::State::ENABLED);
}

Performance Testing

While traditional unit tests focus on correctness, in robotics we often need to verify performance characteristics as well.

TEST_F(JointControllerTest, ControlLoopMeetsTimingRequirements) {
    const int kNumIterations = 1000;
    const double kMaxAllowedTime = 100;  // microseconds
    
    std::vector<double> execution_times;
    execution_times.reserve(kNumIterations);
    
    for (int i = 0; i < kNumIterations; ++i) {
        auto start= std::chrono::high_resolution_clock::now();
        controller->updateControlLoop();
        auto end = std::chrono::high_resolution_clock::now();
        
        auto duration = std::chrono::duration_cast<std::chrono::microseconds>(
            end - start).count();
        execution_times.push_back(duration);
    }
    
    // Calculate statistics
    double average = std::accumulate(execution_times.begin(), 
        execution_times.end(), 0.0) / kNumIterations;
    
    EXPECT_LT(average, kMaxAllowedTime);
}

Continuous Integration and Test Automation

While individual unit tests are important, their true value emerges when integrated into a continuous integration (CI) pipeline. Here's an example of how to configure your tests to run in a CI environment:

TEST_F(JointControllerTest, FullSystemTest) {
    testing::internal::CaptureStderr();  // Capture logging output
    
    // Run a comprehensive test sequence
    EXPECT_TRUE(controller->selfTest());
    EXPECT_TRUE(controller->calibrate());
    EXPECT_TRUE(controller->enable());
    
    // Test multiple movement commands
    std::vector<double> positions = {30.0, -30.0, 0.0, 45.0, -45.0};
    for (double position : positions) {
        EXPECT_TRUE(controller->startMotion(position));
        EXPECT_TRUE(controller->waitForMotionComplete(5000));  // 5s timeout
        EXPECT_NEAR(controller->getCurrentPosition(), position, 0.1);
    }
    
    std::string log_output = testing::internal::GetCapturedStderr();
    EXPECT_TRUE(log_output.find("error") == std::string::npos);
}
⚠️

Remember that passing unit tests don't guarantee perfect robot behavior. Always combine unit testing with integration testing, simulation testing, and careful real-world validation.

Conclusion

Effective unit testing of robotic software components requires a careful balance of traditional software testing practices and domain-specific considerations. By following these practices and examples, you can build more reliable and maintainable robotic systems while catching potential issues before they manifest in the physical world.

The key is to remember that unit testing in robotics isn't just about finding bugs – it's about building confidence in your system's behavior and ensuring safety at every level of your software stack.

Testing AspectKey Considerations
State ManagementVerify all valid transitions, prevent invalid ones, ensure safety states are reachable
Numerical ComputationsAccount for floating-point precision, ensure stability, test edge cases
PerformanceVerify timing requirements, resource usage, and real-time constraints
Error HandlingTest recovery procedures, validate safety behaviors, ensure graceful degradation

Authors

Federico Sarrocco

Federico Sarrocco

View Portfolio