Embedded software development has long been characterized by unique constraints such as limited resources, strict real-time requirements, and complex interactions with hardware. As a result, traditional software development methodologies often fail to translate smoothly into the embedded space. However, modern embedded development is evolving, and techniques like Test-Driven Development (TDD) are no longer just for web and enterprise applications: they’re essential for building reliable and maintainable embedded systems.
In my previous article, I introduced a modern CMake build system[1] designed to support flexible embedded development, including DevOps, simulation, and automated testing. Now, it’s time to focus on one of the most powerful software engineering practices that this system enables: Test-Driven Development.
What is Test-Driven Development (TDD)?
A big problem with software development is that developers write a lot of code quickly and then wait until the end to test it. Humans aren’t perfect, and the chances are high that they won’t catch every edge case and boundary condition. Making things worse is the fact that the testing is often done manually, so as the project grows in size, the time required to run those manual tests grows until there isn’t enough time to test everything. The result is a system with bugs hiding and waiting to rear their ugly heads to users.
TDD is a development methodology that emphasizes writing tests before writing the actual implementation. Instead of blindly coding and hoping everything works at runtime, developers don’t write a single line of production code unless a test is written to prove it does what it’s supposed to do. There are many advantages to this approach. No code is written unless it’s actually needed. All code has a test to prove that it works. Tests can be automated, which removes manual testing time
TDD enforces a disciplined approach to writing software. Developers use a simple process, which Kent Beck developed in his book Test-Driven Development. For embedded developers, though, the best interpretation of this list is the one that James Grenning put together in his book Test-Driven Development for Embedded C, called the TDD microcycle, which includes the following steps:
- Add a small test
- Run all the tests and see the new one fail, maybe not even compile
- Make the small changes needed to pass the test
- Run all the tests and see the new one pass
- Refactor to remove duplication and improve expressiveness.
The TDD microcycle ensures that code is always written with a purpose, making debugging, maintaining, and scaling easier.
Understanding the TDD Microcycle
The TDD microcycle revolves around rapid iterations of small tests and incremental development. Each loop should take no more than a few minutes, ensuring that code is continuously validated and refined.
Let’s look at how this process works. Typically, when you are assigned a feature to develop, you create a list of tests necessary to assess the feature. The list won’t be extensive. You’ll likely come up with new tests as you develop the code. However, it helps a lot to sit down and list what tests you think you’ll need to assess your new feature. Writing a list of tests is not included in the microcycle since it’s something you do to set the stage for the microcycle.
Once you have your list of tests, you’re ready for the first step: add a small test. You pick the most straightforward first test you could implement for your feature and write the test.
Next, run the test in your test harness. You might be thinking, “But Jacob, I haven’t written the code yet!” That’s precisely the point! You’d typically develop software by writing the code and then testing it. But how do you know that your test would catch a problem? You don’t! You’re leaving giant holes in your testing strategy when you develop your code first.
When you run the test in your harness before writing the code, you should see your test fail. In fact, you might not even see you code compile! The idea here is to see that the test case is implemented in a way such that it catches the code not working. That way, if it stops working in the future due to a code change, the test will catch it!
Now that we have a test case and have seen that it fails without the code, we’re ready to start writing code. Make small, incremental changes towards implementing your feature. Don’t go too far though! You want to write just enough code to make the test you wrote pass and nothing more!
Honestly, this is the toughest part when you are first adopting TDD. There is always a temptation to jump forward and write more code because you know what needs to be implemented. That is not the TDD way! Tests lead implementation.
Once you see that your test is passing, it’s the perfect time to verify you haven’t broken anything that already works. Rerun your entire test harness. Make sure the old tests and the new test pass with flying colors. Once you do that, you can be confident that your code is working as you expect it to.
Now, you can clean up your code, refactor, and set your eyes on repeating the cycle again by choosing your next test.
Where Does TDD Fit in Embedded Development?
Many embedded developers believe that TDD is difficult—if not impossible—to implement in their workflow. Traditional workflows rely heavily on debugging on hardware, which makes rapid iterations challenging. However, the modern approach to embedded software enables effective use of TDD by introducing off-target testing, simulation, and modular architectures.
TDD in embedded development typically fits into the following areas:
- Application Logic – Most business logic, data processing, and decision-making code can be tested without hardware dependencies.
- Device Drivers – While direct hardware access can be challenging, mocking frameworks allow developers to simulate interactions with registers, GPIOs, and peripherals.
- RTOS and Concurrency Management – Scheduling policies, thread synchronization, and inter-process communication can all be tested using TDD.
- State Machines and Control Algorithms – These often follow predictable patterns that lend themselves well to TDD-based testing.
The key to leveraging TDD isn’t just adding a test harness to your builds. Using TDD in embedded development helps force you to decouple your application code from your hardware. It encourages you to use hardware abstraction layers. By following TDD you can more easily leverage simulation and integrate your software into a DevOps platform.
Let’s look at how you can leverage TDD with the modern build system we discussed in the last time.
Using TDD with the Modern Build System
My previous article introduced a modern embedded build system using CMake, supporting multiple build targets, including test and simulation builds. This infrastructure makes it possible to implement TDD efficiently in embedded projects.
In case you missed the last article, we used Docker to create a containerized build environment using GCC. That environment used a high-level makefile to allow to us build and run a Docker container. It also allowed us to build using CMake or using the high-level makefile. (Two build systems in one!).
Now, we’ll look at how I integrated a test harness into that build system and how you can leverage TDD.
First, if you haven’t done so already, you can download the modern build system example from the link in the resources section.
Make sure that you have installed Docker and that you can run makefiles on your development machine. You’ll also want to review the README file which describes how to make sure that everything is set up properly on your system. (There are small variations in the commands needed in the makefile based on whether you are on Windows or Linux-based systems).
From a terminal, you can build your docker container by running the command:
make docker_build
Once the container is built, you can run your container by executing the following command:
make docker_run
In order for us to run any tests, we need a test harness. A test harness is a collection of scripts, tools, and code that automate the execution of tests for a software application or system. It provides a controlled environment to execute test cases, capture results, and compare expected outputs with actual outcomes.
A test harness typically includes:
- A test execution framework to run test cases in a structured manner.
- Mocking/Stubbing components that replaces dependencies (e.g., hardware, databases) with simulated versions to isolate testing.
- Test data management that supplies predefined inputs for reproducible testing.
- Logging and reporting to capture test execution details, results, and failures.
- Automation capabilities that support continuous integration (CI) and regression testing.
There are several different test harnesses that are available for use in embedded systems that vary from open-source to commercial solutions. A few example open-source solutions include:
- CppUTest
- Unity
- Ceedling
- GoogleTest
The example build system that I put together uses the CppUTest harness. It is an open-source C/C++ test framework that is freely available and has most of the features you’d probably want in a test harness.
If you look inside the modern build systems makefile, you’ll find that I’ve defined a recipe named unit-tests. The exact recipe looks something like the following:
.PHONY: unit_tests
unit_tests:
$(MAKE) -j CC=gcc -f $(TEST_DIR)/cpputest.mk
mkdir -p $(COVERAGE_DIR)
@gcovr $(GCOVR_FLAGS)
You can see that it executes the cpputest makefile and then also runs an application named gcovr, which will tell you what your code coverage is. For example, if you have 100 lines of code in your application but the tests only execute 95 lines, then it will tell you that your test coverage is 95%. Not only will it tell you the coverage, it will tell you which lines weren’t covered in the tests!
From your terminal, inside your docker container, if you run the command:
make unit-tests
You’ll find that the CppUTest harness runs and provides you with the output seen in Figure 1.

CppUTest output on a simple application shows that fourl tests were executed with a 100% code coverage.
You can see that this output actually has two different outputs. First, you can see the CppUTest output that tells you that there are 4 tests, 4 tests were run, there were 4 checks, and none of them were ignored. Eventually, as your tests grow in size, you won’t want to run them all. You’ll typically choose a subset and then let your CI/CD pipelines run the complete tests when you commit your code. The report enables you to know what was run and what wasn’t.
Next, you can see that there is a GCC Code Coverage Report. The report tells you every file tested, the number of lines, those executed, and then the code coverage. As you develop your tests, it is very helpful to make sure that you have 100% coverage. However, be warned! 100% coverage does not mean you don’t have any bugs! It just means that every line of code is being executed in the tests. You could have conditions in those lines that are not tested for and that are not correct!
Exploring TDD
Let’s dig a bit deeper into how this example works. You’ve seen how we run Docker and CppUTest, but let’s look at some of the files and the tests that makes this example.
— ADVERTISMENT—
—Advertise Here—
First, navigate into the firmware folder. You’ll notice that the app folder contains an example application adder.c and adder.h. This module is an example of a feature that was added to the project. Next, look in the tests folder. You’ll find the coverage report in the coverage folder. Feel free to explore it, but what we are really interested in is adder_tests.cpp.
If you open adder_tests.cpp, you’ll find source code like that listed in Listing 1.
LISTING 1
Example test cases that were used to develop the adder module.
#include “CppUTest/TestHarness.h”extern “C”{ /* * Add your c-only include files here */ #include “adder.h”}TEST_GROUP(adder){ void setup() { } void teardown() { }};TEST(adder, onePlusOneEqualsTwo){ CHECK_EQUAL(2, add(1, 1));}TEST(adder, zeroPlusZeroEqualsZero){ CHECK_EQUAL(0, add(0, 0));}TEST(adder, OverflowByOneTest){ CHECK_EQUAL(256, add(255, 1));}
There are a couple of important features to notice. First, every test module that you create should have a setup and a teardown section. These allow you to refactor your tests and run the same code at the start of each test and at the end of the test. Code you might put in these areas would include things like:
- A reset for a state machine
- A reset for the state of a variable
- A way to clear a buffer
And so on. These sections help you to get your software into a good starting position to run your test.
Next, you’ll notice the test cases. In our case, there are currently four of them. They look like the following:
TEST(adder, onePlusOneEqualsTwo)
{
CHECK_EQUAL(2, add(1, 1));
}
Notice that they use a TEST macro that then takes two parameters: first the name of the test group, which for us is adder. Second, it takes a unique string name for our test. After that, the test itself is a series of macro calls that check the results of calling various functions in our module.
In the example above, we use the CHECK_EQUAL macro to make sure that if we call the function add with the values one and one, we get a result of two. You’ll find that you can check the CppUTest documentation for a wide range of macros that can be used to validate your functions behaviors.
Now that you understand how the test harness works and a little about the TDD microcycle, I’d recommend that you try it out by doing the following:
- Create a list of tests that would be needed for a subtractor module.
- Add a new file to the test harness called subtractor_tests.cpp, and write your first test.
- Run the test harness and watch it fail a test.
- Add your subtractor module and write the minimum code to make the test pass.
- Watch the tests pass.
- Refactor.
Now you can repeat the microcycle for the remaining tests. By the end of the implementation, you’ll have a much better feel for how you can use TDD for embedded development.
Conclusions
Test-Driven Development is not just a trend, it’s a practical and essential approach for embedded developers looking to build robust, maintainable software. When combined with a modern build system that supports test and simulation builds, TDD becomes a powerful tool for accelerating development and reducing costly debugging time.
By following the steps outlined here and integrating CPPUTest into your build system, you can start leveraging TDD today to create better embedded systems faster. The key is to take it one step at a time—write a failing test, make it pass, then refactor. Before long, your test suite will become your greatest asset.
Once you get the feel for TDD, you’ll find that it brings several important benefits to your development cycle:
- Better Code Quality
- Faster Debugging
- Reduced Time Spent on Hardware Debugging
- Easier Refactoring
- A flexible and scalable software architecture
Try out the example we’ve discussed in this article and see how you can unlock these benefits. When I first encountered TDDD I wasn’t convinced. Today, I can’t write a line of code without first writing a test!
RESOURCES
[1] Modern Embedded CMake Build System Download
https://www.beningo.com/embedded-software-cmake-build-system/
PUBLISHED IN CIRCUIT CELLAR MAGAZINE • APRIL 2025 #417 – Get a PDF of the issue
Sponsor this Article
