GoogleTest Advanced Techniques for Embedded C++ Development

GoogleTest Advanced Techniques for Embedded C++ Development

Embedded C++ development presents unique testing challenges: hardware dependencies, limited resources, and real-time constraints make traditional unit testing difficult. GoogleTest, when configured correctly, handles all of these—letting you test firmware logic on your development machine before it ever touches silicon.

Why GoogleTest for Embedded C++

GoogleTest (GTest) is the most widely used C++ testing framework, and it works excellently for embedded projects when you separate hardware abstraction from business logic. The framework supports:

  • Cross-compilation — build tests for x86 host while targeting ARM
  • Mock objects — replace hardware registers with software fakes
  • Parameterized tests — validate behavior across input ranges
  • Death tests — verify assertions and fault handlers

Project Structure for Testability

The key insight: hardware-dependent code belongs in HAL (Hardware Abstraction Layer) classes. Business logic depends on interfaces, not concrete hardware.

firmware/
├── hal/
│   ├── gpio_interface.h      # Pure virtual interface
│   ├── gpio_stm32.cpp        # Real hardware implementation
│   └── gpio_mock.h           # GoogleMock implementation
├── drivers/
│   └── led_driver.cpp        # Uses GpioInterface, fully testable
├── app/
│   └── blink_controller.cpp  # Business logic
└── tests/
    ├── CMakeLists.txt
    ├── test_led_driver.cpp
    └── test_blink_controller.cpp

Defining Hardware Interfaces

// hal/gpio_interface.h
#pragma once
#include <cstdint>

class GpioInterface {
public:
    virtual ~GpioInterface() = default;
    virtual void setPin(uint8_t pin, bool value) = 0;
    virtual bool readPin(uint8_t pin) = 0;
    virtual void configureOutput(uint8_t pin) = 0;
    virtual void configureInput(uint8_t pin, bool pullup) = 0;
};
// hal/gpio_mock.h
#pragma once
#include "gpio_interface.h"
#include <gmock/gmock.h>

class MockGpio : public GpioInterface {
public:
    MOCK_METHOD(void, setPin, (uint8_t pin, bool value), (override));
    MOCK_METHOD(bool, readPin, (uint8_t pin), (override));
    MOCK_METHOD(void, configureOutput, (uint8_t pin), (override));
    MOCK_METHOD(void, configureInput, (uint8_t pin, bool pullup), (override));
};

Testing LED Driver Logic

// drivers/led_driver.cpp
#include "led_driver.h"

LedDriver::LedDriver(GpioInterface& gpio, uint8_t pin)
    : gpio_(gpio), pin_(pin), state_(false) {
    gpio_.configureOutput(pin_);
}

void LedDriver::on() {
    gpio_.setPin(pin_, true);
    state_ = true;
}

void LedDriver::off() {
    gpio_.setPin(pin_, false);
    state_ = false;
}

void LedDriver::toggle() {
    state_ = !state_;
    gpio_.setPin(pin_, state_);
}
// tests/test_led_driver.cpp
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "hal/gpio_mock.h"
#include "drivers/led_driver.h"

using ::testing::_;
using ::testing::Return;
using ::testing::InSequence;

class LedDriverTest : public ::testing::Test {
protected:
    MockGpio gpio_;
    
    void SetUp() override {
        // Constructor calls configureOutput
        EXPECT_CALL(gpio_, configureOutput(5)).Times(1);
    }
};

TEST_F(LedDriverTest, TurnsOnCorrectPin) {
    LedDriver led(gpio_, 5);
    
    EXPECT_CALL(gpio_, setPin(5, true)).Times(1);
    led.on();
}

TEST_F(LedDriverTest, TurnsOffCorrectPin) {
    LedDriver led(gpio_, 5);
    
    EXPECT_CALL(gpio_, setPin(5, false)).Times(1);
    led.off();
}

TEST_F(LedDriverTest, TogglesFromOffToOn) {
    LedDriver led(gpio_, 5);
    
    InSequence seq;
    EXPECT_CALL(gpio_, setPin(5, false)).Times(1);  // off() first
    EXPECT_CALL(gpio_, setPin(5, true)).Times(1);   // toggle from off → on
    
    led.off();
    led.toggle();
}

TEST_F(LedDriverTest, MultipleTogglesAlternate) {
    LedDriver led(gpio_, 5);
    
    // Start off, then toggle 4 times
    EXPECT_CALL(gpio_, setPin(5, false)).Times(1);
    led.off();
    
    {
        InSequence seq;
        EXPECT_CALL(gpio_, setPin(5, true));
        EXPECT_CALL(gpio_, setPin(5, false));
        EXPECT_CALL(gpio_, setPin(5, true));
        EXPECT_CALL(gpio_, setPin(5, false));
    }
    
    for (int i = 0; i < 4; i++) led.toggle();
}

Testing UART Communication

UART drivers involve more complex state. Here's how to mock a serial interface:

// hal/uart_interface.h
class UartInterface {
public:
    virtual ~UartInterface() = default;
    virtual bool write(const uint8_t* data, size_t len) = 0;
    virtual size_t read(uint8_t* buf, size_t maxLen) = 0;
    virtual bool isRxAvailable() = 0;
    virtual void flush() = 0;
};
// hal/uart_mock.h
class MockUart : public UartInterface {
public:
    MOCK_METHOD(bool, write, (const uint8_t* data, size_t len), (override));
    MOCK_METHOD(size_t, read, (uint8_t* buf, size_t maxLen), (override));
    MOCK_METHOD(bool, isRxAvailable, (), (override));
    MOCK_METHOD(void, flush, (), (override));
};
// tests/test_protocol_handler.cpp
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "hal/uart_mock.h"
#include "drivers/protocol_handler.h"

using ::testing::Return;
using ::testing::DoAll;
using ::testing::SetArrayArgument;

TEST(ProtocolHandlerTest, ParsesValidPacket) {
    MockUart uart;
    ProtocolHandler handler(uart);
    
    // Simulate received bytes: [0xAA, 0x03, 0x01, 0x02, 0x03, checksum]
    uint8_t packet[] = {0xAA, 0x03, 0x01, 0x02, 0x03, 0x06};
    
    EXPECT_CALL(uart, isRxAvailable())
        .WillOnce(Return(true))
        .WillRepeatedly(Return(false));
    
    EXPECT_CALL(uart, read(_, 1))
        .WillOnce(DoAll(
            SetArrayArgument<0>(packet, packet + 6),
            Return(6)
        ));
    
    auto result = handler.receivePacket();
    ASSERT_TRUE(result.has_value());
    EXPECT_EQ(result->payload.size(), 3);
    EXPECT_EQ(result->payload[0], 0x01);
}

Cross-Compilation CMakeLists

# CMakeLists.txt for host-side tests
cmake_minimum_required(VERSION 3.16)
project(firmware_tests)

set(CMAKE_CXX_STANDARD 17)

# Fetch GoogleTest
include(FetchContent)
FetchContent_Declare(
    googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG v1.14.0
)
FetchContent_MakeAvailable(googletest)

# Include firmware source (exclude hardware-specific files)
set(FIRMWARE_SOURCES
    ../drivers/led_driver.cpp
    ../drivers/protocol_handler.cpp
    ../app/blink_controller.cpp
    # NOT: ../hal/gpio_stm32.cpp (real hardware)
)

add_executable(firmware_tests
    test_led_driver.cpp
    test_protocol_handler.cpp
    test_blink_controller.cpp
    ${FIRMWARE_SOURCES}
)

target_include_directories(firmware_tests PRIVATE
    ${CMAKE_SOURCE_DIR}/..
    ${CMAKE_SOURCE_DIR}/../hal
)

target_link_libraries(firmware_tests
    GTest::gtest_main
    GTest::gmock_main
)

include(GoogleTest)
gtest_discover_tests(firmware_tests)

Testing Register-Level Code

Sometimes you need to test code that directly manipulates memory-mapped registers. Use a fake register map:

// For production (embedded):
#define GPIOA_MODER  (*((volatile uint32_t*)0x40020000))
#define GPIOA_ODR    (*((volatile uint32_t*)0x40020014))

// For tests:
// register_map.h
#ifdef UNIT_TEST
    extern uint32_t fake_GPIOA_MODER;
    extern uint32_t fake_GPIOA_ODR;
    #define GPIOA_MODER  fake_GPIOA_MODER
    #define GPIOA_ODR    fake_GPIOA_ODR
#else
    #define GPIOA_MODER  (*((volatile uint32_t*)0x40020000))
    #define GPIOA_ODR    (*((volatile uint32_t*)0x40020014))
#endif
// tests/test_gpio_direct.cpp
#include <gtest/gtest.h>

uint32_t fake_GPIOA_MODER = 0;
uint32_t fake_GPIOA_ODR = 0;

#define UNIT_TEST
#include "hal/gpio_direct.h"

class GpioDirectTest : public ::testing::Test {
protected:
    void SetUp() override {
        fake_GPIOA_MODER = 0;
        fake_GPIOA_ODR = 0;
    }
};

TEST_F(GpioDirectTest, ConfiguresPin5AsOutput) {
    gpio_configure_output(5);
    
    // Pin 5 mode bits are at [11:10] in MODER
    uint32_t pin5_mode = (fake_GPIOA_MODER >> 10) & 0x3;
    EXPECT_EQ(pin5_mode, 0x01);  // 01 = output mode
}

TEST_F(GpioDirectTest, SetsPin5High) {
    gpio_set_pin(5, true);
    EXPECT_TRUE(fake_GPIOA_ODR & (1 << 5));
}

Parameterized Tests for Boundary Conditions

Embedded systems often need validation across input ranges:

// Test PWM duty cycle clamping
struct PwmTestCase {
    int input;
    int expected_output;
};

class PwmClampTest : public ::testing::TestWithParam<PwmTestCase> {};

TEST_P(PwmClampTest, ClampsDutyCycle) {
    auto [input, expected] = GetParam();
    EXPECT_EQ(clamp_duty_cycle(input), expected);
}

INSTANTIATE_TEST_SUITE_P(
    BoundaryValues,
    PwmClampTest,
    ::testing::Values(
        PwmTestCase{-1, 0},
        PwmTestCase{0, 0},
        PwmTestCase{50, 50},
        PwmTestCase{100, 100},
        PwmTestCase{101, 100},
        PwmTestCase{255, 100}
    )
);

CI Integration

# .github/workflows/firmware-tests.yml
name: Firmware Unit Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install dependencies
        run: sudo apt-get install -y cmake g++ ninja-build
      
      - name: Configure
        run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
        working-directory: firmware/tests
      
      - name: Build
        run: cmake --build build
        working-directory: firmware/tests
      
      - name: Run tests
        run: ./build/firmware_tests --gtest_output=xml:test_results.xml
        working-directory: firmware/tests
      
      - name: Upload results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: firmware/tests/build/test_results.xml

Common Pitfalls

Forgetting volatile: When mocking registers, real code uses volatile but test fakes don't need it. Use #ifdef UNIT_TEST guards carefully.

Static initialization order: GoogleTest fixtures handle SetUp() reliably; prefer them over global test state.

Timing-dependent tests: Don't test timing directly—test that your code calls the right timer API with the right parameters. Test the timer driver separately.

Template bloat: GoogleMock template instantiation can be slow. Consider forward-declaring mock classes and putting implementations in .cpp files.

Summary

Effective GoogleTest usage for embedded C++ requires:

  1. HAL interfaces — pure virtual classes that real hardware and mocks both implement
  2. Dependency injection — pass interfaces by reference, never use global hardware state in testable code
  3. Host-side build — CMake configuration that compiles firmware logic for x86 without hardware drivers
  4. Register faking#ifdef UNIT_TEST guards to replace memory-mapped addresses with variables

This approach gives you fast, deterministic tests that run in milliseconds on any developer machine, providing a tight feedback loop before flashing to hardware.

Read more