Firmware Unit Testing With Unity: What Works and What Requires Seam Design
Unity is a minimal C test framework that runs on target and host. The hard part is not the framework — it's designing seams in firmware code that was never meant to be tested.

Unity (ThrowTheSwitch Unity 2.5.2) is a minimal C test harness that runs on host and target with one #include "unity.h". The hard part is not assertions—it is seams: firmware written as one main() loop with globals touching HAL registers resists unit testing until you carve interfaces. This note covers patterns that worked on an STM32F4 motor controller codebase (~40k LOC).
What Unity gives you
#include "unity.h"
void test_pid_clamp_output(void) {
pid_t pid = { .kp = 1.0f, .output_max = 100.0f };
float out = pid_compute(&pid, 50.0f, 0.0f);
TEST_ASSERT_FLOAT_WITHIN(0.01f, 100.0f, out);
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_pid_clamp_output);
return UNITY_END();
}
Host build via gcc; target build same sources with arm-none-eabi-gcc, output via UART or semihosting. Fast feedback loop on algorithms.
Seam design patterns
1. HAL wrapper table (dependency injection lite)
Instead of HAL_GPIO_WritePin() scattered:
typedef struct {
void (*gpio_write)(uint16_t pin, bool val);
uint32_t (*millis)(void);
} platform_io_t;
extern platform_io_t platform; /* production: hal_impl.c, test: mock_impl.c */
Production links hal_impl.c; Unity links mock_impl.c recording calls.
Cost: one indirection pointer; worth it for modules under test.
2. Time as injectable input
Motor ramp tests failed on hardware because HAL_GetTick() resolution 1 ms hid bugs. Tests use fake clock:
void fake_advance_ms(uint32_t ms) { fake_now += ms; }
Never call real sleep in unit tests.
3. #ifdef UNIT_TEST — use sparingly
Allowed for static functions you refuse to expose—#ifdef UNIT_TEST include test-only headers. Ban #ifdef that changes behavior logic; tests must exercise production paths.
4. Link-time stub HAL
For host tests, provide weak stubs:
__attribute__((weak)) void HAL_Init(void) {}
Zephyr/native_sim path preferred when whole subsystem integrates—see CI firmware HIL on a budget.
What still requires HIL
- NVIC priority interactions
- DMA ring buffer wrap
- Flash wear leveling state machines after power loss
Unity covers decision logic; HIL covers physics.
Workspace reproducibility: Zephyr west workspace reproducible 2024 pins HAL versions so host/target divergence is visible in CI.
Organizational friction
Legacy module motor.c had 14 static globals. Refactor for testability took 3 days; tests written in 4 hours. Managers see "no feature output"—sell as regression insurance.
We require Unity tests for:
- New pure logic modules
- Bug fixes (test reproduces bug first)
Optional for one-off bring-up scripts.
CMake integration sketch
add_executable(test_pid host/tests/test_pid.c src/pid.c)
target_include_directories(test_pid PRIVATE tests/mocks)
target_compile_definitions(test_pid PRIVATE UNIT_TEST)
target_link_libraries(test_pid unity)
add_test(NAME pid COMMAND test_pid)
ctest in GitHub Actions on every PR; target Unity suite nightly on HIL runner.
Common failures
| Mistake | Symptom |
|---|---|
| Testing copy-pasted prod code | Drift |
| Mock too smart | Tests pass, prod fails |
| Unity on target only | Slow dev cycle |
| No float tolerance macros | Flaky compares |
What I'd do next
- CMock for auto-generated mocks from headers—manual mocks do not scale past 20 functions.
- Fuzz host parsers (CBOR, NMEA) with AFL++—Unity for examples, fuzz for coverage.
- Track host vs target test count ratio—goal 80% host, 20% target for speed.
Coverage targets vs value
We track line coverage on host-tested modules only—42% overall LOC, 78% on src/control/ after seam refactor. Chasing 90% overall would mean testing HAL wrappers—low ROI. Management dashboard shows module-level coverage against risk ranking.
Refactoring budget rule
Any file touched for bug fix gets 30-minute "seam opportunity" slot in sprint planning—small wrappers accumulate without big-bang rewrite. Big-bang still needed for motor.c; rule prevents second big-bang elsewhere.
Mock verification anti-pattern
Mocks that replicate production logic duplicate bugs—we caught mock assuming same endianness as host while target was big-endian for legacy protocol shim. Rule: mocks return canned data, never reimplement transforms under test.
Zephyr ztest vs Unity
Zephyr projects increasingly use ztest for kernel-adjacent code; we keep Unity for portable algorithm modules compiled on host and target identically. Two frameworks, clear boundary in CONTRIBUTING.md—avoid religious war.
Test naming and failure output
Unity's TEST_ASSERT_EQUAL messages default cryptic for non-embedded QA—wrap macros with scenario string: TEST_ASSERT_EQUAL_MESSAGE(100, out, "PID clamp at max duty"). CI artifact includes Unity summary XML parsed into GitHub check annotations.
Fakes vs mocks naming
Team uses "fake" for working in-memory implementation (fake flash driver with RAM backing) and "mock" for call recording only—shared glossary in TESTING.md reduced review arguments.
Parameterized tests pattern
Table-driven Unity tests via macro wrapper cut boilerplate for PID edge cases—twelve scenarios, one test function, failures report row index. Copy pattern from tests/test_pid_table.c in repo template. Host tests run in under 3 s total—fast enough for pre-push hook without developer bypass.
Unity is sufficient. Invest in seams early, or pay refactor tax before every release freeze.
Manish Bookreader
Electronics enthusiast, Embedded Systems Expert, Linux/Networking programmer, and Software Engineer passionate about AI, electronics, books, and cooking.

