Question
Unit Testing in C for Embedded Systems: A Beginner-Friendly Guide
Question
I am working with an embedded system written in plain C. The project already exists, and some of the code needs refactoring while new functionality is also being added.
I am familiar with writing unit tests in Java using JUnit, but I am unsure about the best way to approach unit testing for existing C code and for newly written C modules.
How can unit testing be done effectively in plain C, especially in a way that feels similar to the workflow of JUnit? Also, what practices are especially useful for embedded development, such as when cross-compiling for an ARM Linux target?
Short Answer
By the end of this page, you will understand how unit testing works in plain C, how it differs from JUnit-style testing in Java, and how to structure C code so it becomes testable. You will also learn practical patterns for testing embedded C code, including separating hardware-dependent code, using mocks and stubs, and running tests on either the host machine or the target platform.
Concept
Unit testing in C means verifying small pieces of code—usually individual functions or tightly related groups of functions—in isolation.
In Java, frameworks like JUnit provide a polished test structure with classes, annotations, assertions, and runners. In C, the language itself does not include these features, so unit testing usually relies on three things:
- A test framework for assertions and test reporting
- Code organization that allows logic to be tested separately from hardware or system dependencies
- Test doubles such as stubs, fakes, or mocks to replace external dependencies
The key idea is still the same as in any language: test one unit of behavior at a time.
In C, unit testing matters even more because:
- Memory errors can be subtle
- Global state can make bugs harder to trace
- Embedded code often interacts with hardware, which is difficult to test directly
- Refactoring legacy C code is risky without safety checks
For embedded systems, a common strategy is to split code into two layers:
- Pure logic: calculations, parsing, state transitions, validation
- Hardware interface: GPIO, UART, timers, registers, operating system calls
You usually unit test the pure logic directly and replace the hardware layer with controlled test doubles.
This approach makes C testing much closer to the JUnit mindset: write a test, call a function, assert the result, and keep each test independent.
Mental Model
Think of unit testing in C like testing a machine on a workbench.
- The unit is one small part of the machine, such as a gear.
- A test checks whether that gear behaves correctly.
- If the gear normally connects to a motor or a sensor, you temporarily replace those with simple stand-ins.
In embedded C, this is especially important. You do not want every test to require the real board, real timing, or real hardware registers. Instead, you remove the part under test from the full machine and test it in a controlled environment.
A good mental rule is:
- Test logic directly
- Abstract hardware access
- Replace external dependencies during tests
That is the C equivalent of how Java developers often mock services, databases, or network clients.
Syntax and Examples
In C, a unit test is usually just a function that calls the code under test and checks the result.
Simple example without a framework
Suppose we have a function:
int add(int a, int b) {
return a + b;
}
A very basic test might look like this:
#include <assert.h>
int add(int a, int b);
void test_add(void) {
assert(add(2, 3) == 5);
assert(add(-1, 1) == 0);
}
int main(void) {
test_add();
return 0;
}
This works, but it is minimal. Real test frameworks improve this by providing:
- Better failure messages
- Multiple test cases in one run
- Setup and teardown support
- Easier reporting
Example with a JUnit-like style in C
Step by Step Execution
Consider this function:
int is_valid_range(int value) {
if (value >= 10 && value <= 20) {
return 1;
}
return 0;
}
And this test:
#include <assert.h>
int is_valid_range(int value);
int main(void) {
assert(is_valid_range(15) == 1);
assert(is_valid_range(9) == 0);
assert(is_valid_range(21) == 0);
return 0;
}
What happens step by step
mainstarts running.- The first assertion calls
is_valid_range(15). - Inside the function:
15 >= 10is true
Real World Use Cases
Unit testing in C is useful in many practical situations.
Embedded systems
- Validating sensor data conversion
- Testing state machines for devices
- Verifying communication packet parsing
- Checking control logic without needing real hardware
Example:
- Convert raw ADC values into temperature
- Decide whether to enable an alarm
- Ensure edge cases behave correctly
Systems programming
- Parsing configuration files
- Validating string handling functions
- Testing data structures such as queues or ring buffers
- Checking error code behavior
Networking and protocol code
- Parsing binary packets
- Verifying checksums
- Ensuring malformed input is rejected safely
Legacy code refactoring
Before changing old code, you can write tests around current behavior. These tests act as a safety net, helping you confirm that refactoring does not accidentally break expected behavior.
Real Codebase Usage
In real C projects, unit testing usually depends more on code structure than on any specific framework.
Common patterns developers use
Guard clauses
Developers often validate inputs early:
int divide(int a, int b, int *result) {
if (result == NULL) {
return -1;
}
if (b == 0) {
return -2;
}
*result = a / b;
return 0;
}
This is easy to test because each path is clear.
Dependency injection in C
Instead of calling hardware directly, pass in function pointers or wrapper functions.
typedef int (*read_temp_fn)(void);
int control_fan(read_temp_fn reader) {
int temp = reader();
return temp > 70;
}
In tests, you can provide a fake function.
Testing pure functions first
Common Mistakes
1. Testing hardware-dependent code directly
Beginners often try to unit test code that reads registers, uses timers, or depends on interrupts.
Broken style:
int get_button_state(void) {
return GPIO_PORTA & 0x01;
}
This is hard to test on a host machine.
Better approach:
int is_button_pressed(int raw_gpio_value) {
return (raw_gpio_value & 0x01) != 0;
}
Test the logic separately from the hardware read.
2. Relying too much on global state
Broken example:
int current_mode = 0;
void set_mode_if_valid(int mode) {
if (mode >= 0 && mode <= 3) {
current_mode = mode;
}
}
Globals make tests depend on previous tests.
Avoid this by:
- Resetting state before each test
Comparisons
| Concept | Purpose | Best For | Trade-off |
|---|---|---|---|
| Unit test | Test one function or small unit in isolation | Logic, validation, parsing, calculations | May miss integration issues |
| Integration test | Test multiple components together | Driver + logic + OS + hardware interactions | Slower and harder to isolate failures |
| Host-based test | Run tests on your development machine | Fast feedback, CI pipelines, legacy code refactoring | Does not fully represent target hardware |
| Target-based test | Run tests on the embedded board | Verifying target-specific behavior | Slower and more complex to automate |
| Approach | Example | Good For |
|---|
Cheat Sheet
Core idea
Test small pieces of C code in isolation.
Best candidates for unit tests
- Pure functions
- Validation logic
- Parsers
- State transitions
- Buffer handling
- Error code paths
Design rules for testable C code
- Separate logic from hardware access
- Minimize global state
- Prefer small functions
- Use clear inputs and outputs
- Return status codes consistently
Basic test pattern
int function_under_test(int input);
void test_case(void) {
assert(function_under_test(123) == expected_value);
}
Good embedded testing strategy
- Run most unit tests on the host machine
- Keep hardware code thin
- Test hardware-independent logic separately
- Use integration tests on the real board
Common things to test
- Boundary values
- Invalid inputs
NULLpointers- Empty data
- Full capacity conditions
- Success and failure return codes
Useful test doubles
- Stub: returns fixed values
FAQ
How do you unit test C code without JUnit?
You usually write test functions in C and run them with assertions or a C testing framework. The main idea is the same as JUnit: arrange input, call the code, and verify the result.
Can embedded C code be unit tested on a PC?
Yes. In fact, this is a common practice. Most logic can be tested on the host machine, while hardware-specific behavior is covered separately with integration or target-based tests.
What parts of embedded code should I unit test first?
Start with pure logic: calculations, validation, parsers, state machines, and error handling. These are easiest to isolate and give the fastest value.
How do I test functions that depend on hardware?
Move hardware access behind wrapper functions or function pointers, then replace those dependencies with stubs or fakes during tests.
Should I test legacy C code before refactoring it?
Yes. If possible, write characterization tests first. These capture current behavior so you can refactor more safely.
Are assertions enough for unit testing in C?
They are enough for simple tests, but dedicated test frameworks usually provide better reporting, organization, and automation.
What is the difference between a unit test and an integration test in embedded development?
A unit test isolates one piece of logic. An integration test checks how multiple parts work together, often including drivers, operating system code, or real hardware.
Mini Project
Description
Build a small C module for an embedded-style temperature alarm system. The module should decide whether an alarm should turn on based on a temperature reading. The goal is to practice separating pure logic from hardware-related code so the important behavior can be unit tested easily.
Goal
Create and test a small temperature alarm module in C with clear, isolated logic and simple test cases.
Requirements
- Write a function that returns whether the alarm should be enabled for a given temperature.
- Write tests for normal cases and boundary values.
- Keep the decision logic independent from hardware access.
- Include a small test runner in C that reports passing or failing tests.
Keep learning
Related questions
Building More Fault-Tolerant Embedded C++ Applications for Radiation-Prone ARM Systems
Learn practical C++ and compile-time techniques to reduce soft-error damage in embedded ARM systems exposed to radiation.
Definition vs Declaration in C and C++: What’s the Difference?
Learn the difference between declarations and definitions in C and C++ with simple examples, common mistakes, and practical usage.
Difference Between #include <...> and #include "..." in C and C++
Learn the difference between #include with angle brackets and quotes in C and C++, including search paths, examples, and common mistakes.