Unit Testing in C (Unity / CMock)
Embedded → Firmware Quality & Debug Tools
Why test on your laptop, not on the board
Flashing a board, watching an LED, and eyeballing a UART log is the slowest possible feedback loop for testing pure logic — a CRC calculator, a debounce state machine, a fixed-point conversion function. None of that needs hardware; compile it for the host (plain gcc, not arm-none-eabi-gcc) and hundreds of test cases run in milliseconds, no flashing, no board.
Unity is a single-header, dependency-free C assertion library built for exactly this. CMock is its companion — it auto-generates mock implementations of any C interface from its header, so logic that calls hardware-touching functions can be tested without touching real hardware.
Anatomy of a Unity test
Every Unity test file follows the same shape: optional setUp()/tearDown(), one function per test case named test_*, and a main() registering each with RUN_TEST(). Assertions are plain macros — nothing to learn beyond the macro names:
// test_crc8.c — testing a pure function, no hardware involved
#include "unity.h"
#include "crc8.h"
void setUp(void) {} // runs before every test — nothing to reset here
void tearDown(void) {} // runs after every test
void test_crc8_of_empty_buffer_is_seed(void) {
uint8_t data[] = {0};
TEST_ASSERT_EQUAL_UINT8(0x00, crc8(data, 0));
}
void test_crc8_known_vector(void) {
// "123456789" -> 0xF4 is the standard CRC-8 test vector
uint8_t data[] = "123456789";
TEST_ASSERT_EQUAL_UINT8(0xF4, crc8(data, 9));
}
void test_crc8_detects_single_bit_flip(void) {
uint8_t good[] = {0x01, 0x02, 0x03};
uint8_t bad[] = {0x01, 0x02, 0x02}; // one bit flipped
TEST_ASSERT_NOT_EQUAL(crc8(good, 3), crc8(bad, 3));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_crc8_of_empty_buffer_is_seed);
RUN_TEST(test_crc8_known_vector);
RUN_TEST(test_crc8_detects_single_bit_flip);
return UNITY_END();
}TEST_ASSERT_EQUAL_UINT8, not a bare TEST_ASSERT_EQUAL, matters here — Unity's typed assertion macros (_UINT8, _UINT32, _HEX32, _FLOAT...) print the actual vs. expected values in the failure message using the right format specifier for that width. Using the untyped TEST_ASSERT_EQUAL on a uint8_tstill works, but a failure message that prints a wrapped/truncated value is a real debugging tax you don't need to pay.Mocking hardware with CMock
A debounce state machine tangles logic and hardware together — it reads a raw GPIO level and decides if the button is “really” pressed. The trick: never call the hardware read directly — define a small interface (a header declaring gpio_read(), no implementation in the test build), point CMock at it, and it generates a fake implementation fully controlled from inside the test:
// gpio.h — the interface debounce.c depends on, no hardware in this header
bool gpio_read(uint8_t pin);
// debounce.c — the code under test
#include "gpio.h"
bool debounce_update(debounce_t *db, uint8_t pin) {
bool raw = gpio_read(pin);
if (raw == db->last_raw) {
if (++db->stable_count >= DEBOUNCE_TICKS) db->debounced = raw;
} else {
db->stable_count = 0;
}
db->last_raw = raw;
return db->debounced;
}Running cmock against gpio.h generates mock_gpio.c/.hwith a controllable stub — the test scripts exactly what the “hardware” returns each call, tick by tick, and asserts on the debounced result — a real bouncing signal, reproduced deterministically, with zero actual GPIO pins:
// test_debounce.c
#include "unity.h"
#include "mock_gpio.h"
#include "debounce.h"
debounce_t db;
void setUp(void) { debounce_init(&db); }
void tearDown(void) {}
void test_bouncy_signal_only_registers_after_stable_ticks(void) {
// simulate a bouncing raw read: 1,0,1,1,1,1 (5 ticks stable) then stays high
gpio_read_ExpectAndReturn(3, true);
TEST_ASSERT_FALSE(debounce_update(&db, 3));
gpio_read_ExpectAndReturn(3, false);
TEST_ASSERT_FALSE(debounce_update(&db, 3));
for (int i = 0; i < 5; i++) {
gpio_read_ExpectAndReturn(3, true);
debounce_update(&db, 3);
}
TEST_ASSERT_TRUE(debounce_update(&db, 3)); // finally settled
}gpio_read_ExpectAndReturn(3, true)is CMock's generated API — it means “the next call to gpio_read must be with argument 3, and it will return true.” If debounce_update ever calls gpio_read with the wrong pin, or calls it a different number of times than the test scripted, CMock fails the test — this is what makes a mock stronger than a hand-rolled stub: it verifies the interaction, not just the return value.Try it — run the debounce test suite
Flip “introduce bug” to change DEBOUNCE_TICKS >= to DEBOUNCE_TICKS > (an easy off-by-one) in the code under test, then run the suite again:
Why this matters on a real firmware team
Host-side unit testing is a fast, cheap first line of defense that runs on every commit in CI, seconds before anyone flashes a board. A bug caught by a host test costs a failed CI run; the same bug caught on the bench costs a debugging session with a logic analyzer, and caught in the field costs a recall.
Practice
TEST_ASSERTon host, per its Getting Started guide. Then write a pure function of your own (a moving-average filter, a fixed-point Q15 multiply, a ring-buffer push/pop) and a Unity test file for it with at least 3 cases: a normal case, an edge case (empty/full buffer), and a case designed to catch an off-by-one. Once that's working, add CMock to a function that calls a hardware read/write and mock it out, exactly like the debounce example above.Completion saves automatically after you read through to the end.
Next lesson →