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.cppDefining 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.xmlCommon 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:
- HAL interfaces — pure virtual classes that real hardware and mocks both implement
- Dependency injection — pass interfaces by reference, never use global hardware state in testable code
- Host-side build — CMake configuration that compiles firmware logic for x86 without hardware drivers
- Register faking —
#ifdef UNIT_TESTguards 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.