Unit Testing Firmware With Unity and CMock on Host Targets

Host-based unit tests catch bugs at the desk instead of waiting weeks for hardware access.

Columnist · · 12 min read
Cover illustration for “Unit Testing Firmware With Unity and CMock on Host Targets”
Test Harness Design · September 23, 2026 · 12 min read · 2,654 words

According to WikiOT's embedded testing guide, fewer than 15% of embedded projects run any kind of unit test. That number is the whole story: bugs get caught in integration instead of at the desk where they were written, regressions slip through into shipped product, and every commit becomes a bet against hardware nobody can fully see into ahead of time.

The traditional cycle is familiar enough: write code, flash the board, watch the LED blink or fail to blink, repeat. That loop works fine for a weekend project with one developer and one board on the desk. It breaks down once the test matrix grows past a handful of cases, or once the dev kit belongs to someone else on the team and has to be signed out. Developers wait on hardware access, sometimes for weeks, and move on to other tickets while they wait. When the board finally frees up, the bug's return forces them to rebuild all the mental context they'd set aside. That context-switching cost never appears on a burndown chart, and it compounds.

Unity and CMock, two tools out of the ThrowTheSwitch.org project, close most of that gap. Paired with Ceedling as the build orchestrator, they let a developer compile and run tests directly on a host machine, an x86 laptop or a CI runner, with no target chip attached and no board flashed. The approach separates the code that touches hardware from the code that doesn't, then uses CMock to generate stand-in versions of the hardware-facing calls at link time. The rest of this piece is how that separation actually works, and how the three tools fit into a build a CI pipeline can run on every push.

What host-based testing can and cannot cover

Host-based testing does not replace hardware validation. It replaces the parts of hardware validation that were never really about hardware to begin with. Business logic, protocol parsers, CRC calculations, state machines, error-handling branches: all of it runs and gets checked on a host processor with zero dependency on the target silicon. A large share of a typical firmware codebase falls into that bucket, code that takes inputs, applies rules, and produces outputs, with no register access and no timing dependency.

The remaining 20% genuinely needs real silicon, and pretending otherwise is where teams get burned. Timing-critical routines, analog peripheral behavior, RF characteristics, DMA edge cases where the exact sequencing of bus transactions matters: none of that can be faithfully reproduced on a host machine, because the host doesn't have the peripheral, the clock domain, or the electrical behavior in question. Hardware-in-the-loop testing still owns that territory, and no amount of clever mocking changes that.

The case for drawing this line carefully comes down to speed, and the numbers make it bluntly. A host-based unit test suite finishes in seconds. A cross-compiled firmware build, the kind that actually targets the chip, takes something closer to five minutes. A full HIL suite, exercising real boards over real interfaces, can run around ten minutes per pass. Stacking those three next to each other reveals the shape of a sensible test pyramid on its own: host unit tests at the base, cheap enough to run on every keystroke; cross-compiled integration tests in the middle, run less often; HIL at the top, run sparingly, because it's slow and because it depends on physical inventory that doesn't scale the way a CI runner does. Teams that skip the base of that pyramid end up doing all their testing at the top. That's exactly the bottleneck keeping embedded unit test adoption as low as the figures above show, and it's a choice, not an inevitability.

Diagram: The Embedded Test Pyramid: Speed vs. Coverage. Visualizes: Visualize a three-tier test pyramid for embedded firmware showing how speed and frequency trade off across layers.

None of the speed advantage above matters if the code is written in a way that fuses hardware and logic together. Code that reads and writes registers directly is hard to test on a host by construction, since a host has no registers to read. Code that calls through a defined interface instead, an abstract function like i2c_write_read(), can have that interface satisfied by something other than real hardware. Depend on interfaces, not implementations: that's the whole architectural principle behind this, and it demands treating as non-negotiable rather than a nice-to-have.

A workable directory layout makes the principle concrete. src/app/ holds business logic, things like sensor_manager.c or alarm_logic.c, and everything in that folder should be testable on a host with no exceptions. src/drivers/ holds the abstract driver layer: a header like i2c_driver.h defines the interface, while the MCU-specific implementation, something like i2c_stm32.c, lives in the same folder but never gets pulled into a host test build. src/hal/ holds the MCU-specific hardware abstraction layer, and it doesn't get unit-tested directly, because there's nothing left to abstract at that layer; it's already talking to silicon. tests/ holds the test files, with CMock's auto-generated mocks landing in tests/mocks/.

One detail surprises developers coming to this for the first time. The hardware module doesn't need to be implemented yet for its tests to exist, only the header does. CMock reads the function prototypes in i2c_driver.h, and the business logic along with its tests can be written and verified before a single line of i2c_stm32.c exists. That ordering, interface first, implementation later, tends to produce cleaner interfaces anyway, since the person writing them is thinking about what the logic needs rather than what the hardware happens to expose.

For teams retrofitting this onto an existing codebase, trying to unit-test everything at once is the wrong move, and it's the mistake most teams make on their first attempt. Pick the three to five modules most critical to product behavior or most frequently touched by bug fixes, and start there. Better still, start with the purest functions available: CRC routines, JSON or protocol parsing, unit conversion math, anything that takes inputs and returns outputs without touching a peripheral. Those functions need no mocking at all to test, and they build the team's fluency with Unity before CMock enters the picture.

The ThrowTheSwitch tools: Unity, CMock, and Ceedling as a system

Unity, CMock, and Ceedling all come out of ThrowTheSwitch.org, a project whose codebase carries copyright notices naming Mike Karlesky, Mark VanderVoord, and Greg Williams, spanning 2007 through 2026. The three are built to work together, but each solves a distinct problem.

Unity is the test framework: it runs assertions and reports pass or fail. It ships as a single C file plus a pair of headers, a deliberately minimal footprint, and it compiles under GCC, IAR, Clang, Green Hills, Microchip's toolchain, and one mainstream IDE's compiler. It targets everything from 8-bit microcontrollers up through 64-bit host machines, which is exactly the range a workflow needs when it tests on a laptop today and cross-compiles for a target chip tomorrow. On GitHub the project carries around 4,000 stars and 966 forks, a reasonable proxy for how widely it's been picked up across the embedded world. This Unity has nothing to do with the well-known game engine of the same name. Same name, unrelated project, and the confusion trips up newcomers searching for it online often enough that it deserves a direct correction.

CMock is the mock generator, built from Ruby scripts that parse C header files and emit ready-to-compile mock source files standing in for hardware or any other dependency a test wants to isolate. It carries roughly 671 stars and 273 forks, smaller numbers than Unity's, which tracks with it being the more specialized of the two tools. CMock is built specifically to slot into Unity's assertion and test-running model, not to work as a standalone mocking library for some other framework.

Ceedling is the build system tying the two together, riding on a Ruby task-running library underneath, so a developer or a CI job can run one command and get compiled, mocked, executed tests with a pass or fail result. Without Ceedling, using Unity and CMock together means hand-writing makefiles that call the CMock Ruby scripts, then compiling the generated mocks alongside the test files and the Unity runner. Ceedling automates that entire chain, and skipping it in favor of hand-rolled makefiles is effort spent on the wrong problem.

Writing Unity tests: assertions and test structure

A Unity test file follows a small number of conventions, and Ceedling's test runner generator expects those conventions to hold. Test functions are prefixed with test_ or spec_, take no arguments, and return nothing. That's the pattern the runner generator scans for when it builds the boilerplate main() function automatically, and it isn't an arbitrary style choice.

Two lifecycle functions bracket every test. setUp() runs before each individual test function, and tearDown() runs after. In a mocked test, setUp() is where a mock gets initialized, and tearDown() is where its expectations get verified. The main() function calls UNITY_BEGIN(), then a RUN_TEST() line for every test function in the file, then returns the result of UNITY_END(). That return value becomes the process exit code, which is what a CI system checks to decide whether the build passed. Writing that main() by hand for every test file gets tedious fast, so Unity ships generate_test_runner.rb to produce it automatically, and Ceedling calls that script as part of its own build.

Unity's assertion library is broad, and the choice of assertion matters because a specific assertion produces a specific, useful failure message. The equality family covers TEST_ASSERT_EQUAL_INT, _UINT, and _HEX variants, each with width-specific versions from _INT8 through _INT64. For values that won't compare exactly, sensor readings especially, TEST_ASSERT_INT_WITHIN(delta, expected, actual) checks that a value falls inside a tolerance band rather than matching bit for bit. Boolean checks come via TEST_ASSERT_TRUE and TEST_ASSERT_FALSE. For register-level work, the bitwise family, TEST_ASSERT_BITS, TEST_ASSERT_BIT_HIGH, TEST_ASSERT_BIT_LOW, checks individual bits rather than a whole value, which matters when a register packs several unrelated flags into one word.

Floating-point assertions, TEST_ASSERT_FLOAT_WITHIN and TEST_ASSERT_EQUAL_FLOAT, are available, while double-precision assertions require the build-time define UNITY_INCLUDE_DOUBLE, and either family can be disabled entirely for targets that only support fixed-point math. Arrays get their own variants: appending _ARRAY to most assertion macros turns them into element-wise comparisons, and an _EACH_EQUAL variant checks that every element in an array matches one single expected value, useful for confirming a buffer got zeroed out correctly.

That configurability is what "portable" actually means for Unity in practice: a set of real build-time switches. Large integer types can be disabled for MCUs that only support up to 16-bit values. UNITY_OUTPUT_CHAR redirects where test output goes, useful on targets without a standard console. UNITY_EXCLUDE_SETJMP strips out setjmp/longjmp usage for targets that don't support it. Each of these is a compile-time define, not a runtime flag, keeping the framework's footprint proportional to what the target can actually handle.

Unity has no native parameterized test support, nothing equivalent to a TEST_P macro from a comparable framework. The workaround is a loop inside a single test function, iterating over a table of inputs and expected outputs, or writing separate test functions for each case. Neither is elegant. Separate functions win in practice, though, because a failing loop iteration doesn't always tell you which row of the table broke, and chasing that down eats more time than writing the extra functions would have.

CMock's generated mock API

The substitution CMock performs happens at link time, and that's the whole mechanism worth understanding. At build time, the linker pulls in mock_i2c_driver.c instead of the real i2c_stm32.c. The sensor module under test still calls i2c_write_read() exactly as it would in production, but the symbol it resolves to is the mock's version, not the driver's. No hardware needs to be present, because from the test's perspective nothing about the call site changed.

CMock builds that mock by parsing every function prototype declared in the header, i2c_driver.h in this example, and generating a family of helper functions for each one. i2c_write_read_ExpectAndReturn(...) queues an expected call with specific argument values and specifies what the mock should return when that call happens. i2c_write_read_Expect(...) does the same without a return value, useful for void functions. i2c_write_read_IgnoreArg_write_data() relaxes the check on one specific parameter, letting a test assert on the arguments that matter while ignoring ones that don't, a pointer to a buffer whose exact address is irrelevant, for instance.

Each Expect call queues exactly one expected invocation. If the code under test calls that function more times or fewer times than queued, the mock fails the test. That's per-function ordering by default, not global ordering, so calls to two different mocked functions can happen in either order without triggering a failure. Cross-function call order needs an explicit setting, :enforce_strict_ordering: true, inside project.yml. Without it, CMock cares that each function was called the right number of times with the right arguments, not that function A necessarily happened before function B, and most test suites are better off leaving strict ordering off unless the sequencing genuinely matters.

The lifecycle of a mock follows the same setUp()/tearDown() pattern Unity uses for tests generally. mock_i2c_driver_Init() is used to reset the mock's internal state before each test runs. mock_i2c_driver_Verify() and mock_i2c_driver_Destroy() are typically called in tearDown() to check unmet expectations and clean up mock state._Destroy() belong in tearDown(), and Verify() is the step that actually catches unmet expectations, a call that was queued but never made. Skipping that verify step is a common mistake, and a costly one: without it, a test can pass even though the code under test silently failed to make a call it should have made.

Configuring Ceedling to wire Unity and CMock into an automated build

Getting a project started is a single command: ceedling new <projectname> scaffolds the directory structure and drops in a starter project.yml. That file is where the whole system gets configured, following a handful of top-level blocks.

The :project: block sets overall behavior, things like :build_root, and turns on mocking with :use_mocks: true, along with :test_file_prefix: test_ to tell Ceedling which files in the tree are tests rather than production source. The :paths: block lists :test, :source, and :include directories, pointing Ceedling at the same layout described earlier: src/app/, src/drivers/, and so on. The :cmock: block configures the mock generator specifically. :mock_prefix: mock_ sets the naming convention for generated files, :enforce_strict_ordering: true turns on the cross-function ordering check described above, and :plugins: can enable extras like :array, :ignore, and :return_thru_ptr for handling pointer-based output parameters. A :defines: block, with something like :common: [UNIT_TEST=1], lets production code guard sections with a preprocessor check, so a module can detect it's being compiled for host testing and route around code that genuinely can't run off-target.

The convention tying a test file to a mock is simple once it clicks. To mock a module, a test includes its header with the mock_ prefix rather than the plain header: #include "mock_i2c_driver.h" instead of #include "i2c_driver.h". Ceedling watches for that naming pattern, runs CMock against the real i2c_driver.h automatically, and drops the generated mock source into the build directory before compiling anything. No separate invocation of the CMock Ruby scripts is needed by hand.

Day to day, the task surface stays small. ceedling test:all builds and runs the entire test suite in one pass. ceedling test:<name> runs a single module's tests, useful when iterating on one piece of logic without waiting on the full suite. ceedling gcov:all generates coverage reports, giving a concrete answer to how much of the codebase the host-based suite actually exercises. ceedling module:create scaffolds a new module along with a matching test file stub, keeping the source-and-test pairing consistent as the codebase grows.

Put together, that's the whole system: Unity asserts, CMock substitutes, Ceedling orchestrates. None of the three pieces is complicated on its own, and that's the point rather than a coincidence. What fewer than 15% of embedded projects have solved for is the discipline of drawing a clean line between logic and hardware early enough that the tooling has something to hook into. It's the discipline of drawing a clean line between logic and hardware early enough that the tooling has something to hook into.

Sources

  1. GitHub - ThrowTheSwitch/Unity: Simple unit testing for C
  2. Tests Unitaires Embarqués 2026 : Unity, CMock, Mocking I²C/SPI et Pipeline CI/CD GitHub Actions | WikiOT — Le Wiki de l'IoT, de l'IA et de l'Ingénierie Tech
  3. github.com
  4. GitHub - ThrowTheSwitch/CMock: Mock/stub generator for C
  5. How CMock Works &mdash; Throw The Switch
  6. throwtheswitch.org
  7. github.com
  8. github.com

More in Test Harness Design