Unit Testing in C: assert, Unity & CTest
A unit test is a small program that calls one function (one unit) with chosen inputs and checks that the results are exactly what you expect. Run the tests after every change and you know within seconds whether you broke something. In companies, tests usually run automatically on every commit, and code without tests is often not accepted in review. C has no test framework built in, but the tools are simple, and the habits matter more than the tool.
In this lesson you will use assert and static_assert, build a tiny test macro to see how frameworks work, write proper tests with Unity, a small framework very popular in embedded C, read a failing test, run everything through CTest, and learn which test cases to write.
The code under test
A library in Kochi charges late fees: Rs 2 per day for the first week, Rs 5 per day after that, never more than Rs 100. A negative number of days is invalid.
// late_fee.h
#ifndef LATE_FEE_H
#define LATE_FEE_H
int late_fee(int days_late); // returns the fee in rupees, or -1 if days_late < 0
#endif// late_fee.c
#include "late_fee.h"
int late_fee(int days_late)
{
if (days_late < 0) {
return -1; // invalid input
}
if (days_late <= 7) {
return days_late * 2; // first week
}
if (days_late >= 25) {
return 100; // cap; also keeps the maths small
}
return 14 + (days_late - 7) * 5; // Rs 14 for the first week, then Rs 5 a day
}The cap starts at 25 days because 24 days cost 14 + 17 x 5 = Rs 99, and 25 days would cost 104.
assert and static_assert
assert(condition) from assert.h checks a condition while the program runs. If it is false, the program prints the condition, file, line and function, then stops. Use it for things that must never happen if your code is correct, such as a broken internal rule. It is not for user input, because defining NDEBUG (as release builds often do) removes every assert.
#include <assert.h>
#include <limits.h>
#include "late_fee.h"
static_assert(INT_MAX >= 32767, "int too small"); // checked at compile time
int main(void)
{
assert(late_fee(0) == 0);
assert(late_fee(3) == 6);
assert(late_fee(8) == 20); // wrong expectation, on purpose
return 0;
}check: check.c:11: main: Assertion `late_fee(8) == 20' failed. Aborted (core dumped)
This is the message format of glibc on Linux, for a program named check; other systems word it differently. The test was wrong, not the code (8 days cost 14 + 5 = 19), which happens too: a failing test means "code and expectation disagree", and you must decide which one is right. assert stops at the first failure and cannot report the others, which is why real projects use a framework.
A tiny homemade test macro
A test framework is mostly a few clever macros. This one keeps going after a failure and counts results, using the predefined macros __FILE__ and __LINE__ to say where the check is:
#include <stdio.h>
#include "late_fee.h"
static int checks = 0;
static int failures = 0;
#define CHECK_EQ(expected, actual) \
do { \
int e_ = (expected), a_ = (actual); \
checks++; \
if (e_ != a_) { \
failures++; \
printf("%s:%d: expected %d, got %d (%s)\n", \
__FILE__, __LINE__, e_, a_, #actual); \
} \
} while (0)
int main(void)
{
CHECK_EQ(0, late_fee(0));
CHECK_EQ(14, late_fee(7));
CHECK_EQ(19, late_fee(8));
CHECK_EQ(100, late_fee(25));
CHECK_EQ(-1, late_fee(-1));
printf("%d checks, %d failures\n", checks, failures);
return failures == 0 ? 0 : 1;
}5 checks, 0 failures
#actual turns the expression into text for the message, and do { ... } while (0) makes the macro behave like one statement, as explained in The Preprocessor & Macros. Returning non-zero on failure lets build tools detect it. This is fine for a small project, but a framework gives you more checks, clean output and test isolation for free.
Testing with Unity
Unity (by ThrowTheSwitch.org, MIT licence) is a test framework written in plain C, small enough to run on microcontrollers. You add three files to your project from its GitHub repository: unity.c, unity.h and unity_internals.h. Each test is a void function with no parameters. setUp runs before each test and tearDown after it; when you use Unity directly you must define both, even if they are empty.
// test_late_fee.c
#include "unity.h"
#include "late_fee.h"
void setUp(void) {}
void tearDown(void) {}
void test_no_fee_when_on_time(void)
{
TEST_ASSERT_EQUAL_INT(0, late_fee(0));
}
void test_first_week_is_two_rupees_a_day(void)
{
TEST_ASSERT_EQUAL_INT(6, late_fee(3));
TEST_ASSERT_EQUAL_INT(14, late_fee(7));
}
void test_after_first_week_is_five_rupees_a_day(void)
{
TEST_ASSERT_EQUAL_INT(19, late_fee(8));
TEST_ASSERT_EQUAL_INT(99, late_fee(24));
}
void test_fee_is_capped_at_100(void)
{
TEST_ASSERT_EQUAL_INT(100, late_fee(25));
TEST_ASSERT_EQUAL_INT(100, late_fee(365));
}
void test_negative_days_are_invalid(void)
{
TEST_ASSERT_EQUAL_INT(-1, late_fee(-1));
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_no_fee_when_on_time);
RUN_TEST(test_first_week_is_two_rupees_a_day);
RUN_TEST(test_after_first_week_is_five_rupees_a_day);
RUN_TEST(test_fee_is_capped_at_100);
RUN_TEST(test_negative_days_are_invalid);
return UNITY_END();
}Now suppose someone "tidies" the last line of late_fee into return 14 + (days_late - 8) * 5;. Build and run the tests:
gcc -std=c23 -Wall -Wextra -Iunity late_fee.c test_late_fee.c unity/unity.c -o test_late_fee
./test_late_feetest_late_fee.c:39:test_no_fee_when_on_time:PASS test_late_fee.c:40:test_first_week_is_two_rupees_a_day:PASS test_late_fee.c:21:test_after_first_week_is_five_rupees_a_day:FAIL: Expected 19 Was 14 test_late_fee.c:42:test_fee_is_capped_at_100:PASS test_late_fee.c:43:test_negative_days_are_invalid:PASS ----------------------- 5 Tests 1 Failures 0 Ignored FAIL
Each line shows the file, a line number, the test name and the result. For a pass, the line number is where RUN_TEST was called; for a failure, it is the failing assertion, line 21 here. "Expected 19 Was 14" points straight at the off-by-one. A failing assertion ends that test immediately, so the check on line 22 did not run, but the other tests still did. UNITY_END() returns the number of failures, so the program's exit status is non-zero.
Unity has many assertions: TEST_ASSERT_TRUE, TEST_ASSERT_EQUAL_STRING, TEST_ASSERT_NULL, TEST_ASSERT_EQUAL_INT_ARRAY, TEST_ASSERT_EQUAL_HEX8 for register values, and more. Floating-point assertions such as TEST_ASSERT_DOUBLE_WITHIN exist but are switched off by default for small targets; define UNITY_INCLUDE_DOUBLE when compiling to enable them.
Running tests with CTest
With CMake, each test program becomes a CTest test, as shown in CMake for C Projects:
add_library(unity STATIC unity/unity.c)
target_include_directories(unity PUBLIC unity)
enable_testing()
add_executable(test_late_fee tests/test_late_fee.c src/late_fee.c)
target_include_directories(test_late_fee PRIVATE src)
target_link_libraries(test_late_fee PRIVATE unity)
add_test(NAME late_fee COMMAND test_late_fee)ctest --test-dir build --output-on-failure then runs every test program and shows the Unity output of any that fail. Build your tests with -fsanitize=address,undefined as well: a test that passes while writing out of bounds is not really passing.
What to test
Good tests come from thinking about where code breaks, not from repeating easy cases:
- Boundaries: both sides of every limit. Here: 0, 7 and 8, 24 and 25.
- Invalid input: negative, zero, empty strings, null pointers (if the function promises to handle them), very large values.
- One behaviour per test, with a name that says what should happen, so a failure explains itself.
- Independent tests: no test depends on another having run first;
setUpresets shared state. - Fast tests: unit tests run in milliseconds. Code that touches files, networks or hardware is hidden behind a small interface so tests can replace it. In embedded projects, Unity's companion tools CMock and Ceedling generate such fake ("mock") versions of hardware functions.
GCC can also measure coverage: build with --coverage, run the tests, then run gcov late_fee.c to see which lines ran. Coverage shows untested code; it does not prove the tested code is right.
Common mistakes
- Using
assertto check user input. It disappears with-DNDEBUG. - Testing only the happy path. Bugs live at boundaries and in error handling.
- Several behaviours in one test. The first failure hides the rest.
- Tests that depend on order or on files left by other tests. They fail randomly.
- Ignoring the exit status. CI only notices failures if the test program returns non-zero.
Key points
assertchecks conditions that must never be false in correct code;static_assertchecks at compile time.- A test framework is a set of macros that record file, line and values, and keep going after failures.
- Unity tests are
voidfunctions run withRUN_TESTbetweenUNITY_BEGIN()andUNITY_END(); definesetUpandtearDown. - Failure messages such as "Expected 19 Was 14" show the line and both values.
- Test boundaries, invalid input and error paths, one behaviour per test.
- Register test programs with CTest and run them with sanitizers.
Exercise
Write Unity tests for this function, which should return the number of digits in a non-negative number, and -1 for a negative number. Cover 0, a single digit, a boundary such as 9 and 10, a large number such as 2,147,483,647 and a negative number. Then say which test fails for the buggy version below and what Unity prints.
int count_digits(int n)
{
if (n < 0) return -1;
int digits = 0;
while (n > 0) {
digits++;
n /= 10;
}
return digits;
}Show solution
#include "unity.h"
#include "digits.h"
void setUp(void) {}
void tearDown(void) {}
void test_zero_has_one_digit(void)
{
TEST_ASSERT_EQUAL_INT(1, count_digits(0));
}
void test_single_digits(void)
{
TEST_ASSERT_EQUAL_INT(1, count_digits(7));
TEST_ASSERT_EQUAL_INT(1, count_digits(9));
}
void test_boundary_ten(void)
{
TEST_ASSERT_EQUAL_INT(2, count_digits(10));
}
void test_largest_int(void)
{
TEST_ASSERT_EQUAL_INT(10, count_digits(2147483647));
}
void test_negative_is_invalid(void)
{
TEST_ASSERT_EQUAL_INT(-1, count_digits(-5));
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_zero_has_one_digit);
RUN_TEST(test_single_digits);
RUN_TEST(test_boundary_ten);
RUN_TEST(test_largest_int);
RUN_TEST(test_negative_is_invalid);
return UNITY_END();
}The zero test fails: the loop never runs for 0, so the function returns 0. Unity prints a line such as test_digits.c:9:test_zero_has_one_digit:FAIL: Expected 1 Was 0, and the summary shows 5 Tests 1 Failures 0 Ignored. The fix is to treat 0 as a special case, or to use a do-while loop, which always runs at least once.
Quick Quiz
1. Why should assert not be used to reject bad user input?
assert is for programming errors; input errors need real error handling.
2. In Unity, what does UNITY_END() return?
A non-zero exit status tells CTest and CI that something failed.
3. Which inputs are most valuable for testing a function with a limit at 25?
Off-by-one bugs live at boundaries, so test both sides of each limit.
4. What happens after a Unity assertion fails inside a test?
Unity ends the failing test and moves on, then reports the totals.
Learn C with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
