Testing
|
This section documents C23 (ISO/IEC 9899:2024), per ISO/IEC JTC1/SC22/WG14’s freely available working draft N3220, which WG14 documents as differing from the published standard only editorially — the reference these pages are written and verified against. This content was generated with the assistance of AI and should be verified against the WG14 draft and cppreference.com’s C reference before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
C has no built-in test framework and no test runner. What it has is assert, separate compilation, and a
convention of returning a nonzero exit status on failure — which is enough to build a working harness in a
dozen lines, and enough for every framework on this page to plug into any build system.
assert-Based Self-Checks
The simplest useful test is a function full of assertions, compiled without NDEBUG:
#include <assert.h>
#include <stdio.h>
#include <string.h>
// The unit under test.
static size_t count_words(const char *text)
{
size_t words = 0;
bool in_word = false;
for (const char *p = text; *p != '\0'; ++p) {
if (*p == ' ' || *p == '\t' || *p == '\n') {
in_word = false;
} else if (!in_word) {
in_word = true;
++words;
}
}
return words;
}
int main(void)
{
// Ordinary cases.
assert(count_words("one two three") == 3);
assert(count_words("single") == 1);
// Edge cases -- the ones that actually find bugs.
assert(count_words("") == 0);
assert(count_words(" ") == 0);
assert(count_words(" leading and trailing ") == 3);
assert(count_words("tabs\tand\nnewlines") == 3);
assert(count_words("multiple spaces") == 2);
puts("all assertions passed");
return 0;
}
This is a real test: it fails loudly, returns nonzero via abort, and needs no dependencies. Its limits are
what the frameworks below exist to fix — it stops at the first failure, reports no summary, and vanishes
entirely under -DNDEBUG.
|
Never build a test suite with |
A Minimal Test Harness
About thirty lines gets you failure counts, a summary and continuation past the first failure — which is 90% of what a framework provides:
#include <stdio.h>
#include <string.h>
static int tests_run = 0;
static int tests_failed = 0;
// The one macro that matters: report and continue, rather than abort.
#define CHECK(condition) \
do { \
++tests_run; \
if (!(condition)) { \
++tests_failed; \
printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #condition); \
} \
} while (0)
#define CHECK_STR_EQ(actual, expected) \
do { \
++tests_run; \
const char *a_ = (actual); \
const char *e_ = (expected); \
if (a_ == nullptr || e_ == nullptr || strcmp(a_, e_) != 0) { \
++tests_failed; \
printf("FAIL %s:%d: expected \"%s\", got \"%s\"\n", \
__FILE__, __LINE__, e_ ? e_ : "(null)", a_ ? a_ : "(null)"); \
} \
} while (0)
#define RUN_TEST(fn) \
do { \
printf("-- %s\n", #fn); \
fn(); \
} while (0)
// ---- the unit under test ----------------------------------------------------
static size_t safe_copy(char *destination, size_t size, const char *source)
{
size_t length = strlen(source);
if (size > 0) {
size_t copied = length < size - 1 ? length : size - 1;
memcpy(destination, source, copied);
destination[copied] = '\0';
}
return length; // the length it WANTED, snprintf-style
}
// ---- the tests --------------------------------------------------------------
static void test_copy_fits(void)
{
char buffer[16];
CHECK(safe_copy(buffer, sizeof buffer, "hello") == 5);
CHECK_STR_EQ(buffer, "hello");
}
static void test_copy_truncates(void)
{
char buffer[4];
CHECK(safe_copy(buffer, sizeof buffer, "hello") == 5); // reports the full length
CHECK_STR_EQ(buffer, "hel"); // and terminates
}
static void test_copy_zero_size(void)
{
char buffer[5] = "keep"; // 4 chars + terminator
CHECK(safe_copy(buffer, 0, "hello") == 5);
CHECK_STR_EQ(buffer, "keep"); // untouched
}
int main(void)
{
RUN_TEST(test_copy_fits);
RUN_TEST(test_copy_truncates);
RUN_TEST(test_copy_zero_size);
printf("\n%d checks, %d failed\n", tests_run, tests_failed);
return tests_failed == 0 ? 0 : 1; // the exit status IS the test result
}
The exit status convention is what lets make test, CTest and CI treat a plain executable as a test.
The Frameworks
| Framework | Style | Notes |
|---|---|---|
Three files, no dependencies |
The embedded world’s default. Drop |
|
Library, with mocking built in |
Test fixtures (setup/teardown), |
|
Library, forks per test |
Runs each test in its own process, so a segfault or an |
|
Library, auto-registering |
Tests register themselves (no runner boilerplate), run in parallel and in isolated processes, with theories and parameterized tests. |
|
Single header |
Tiny, dependency-free, closest to the hand-rolled harness above. |
A Unity-style test file, since it is the most common shape you will meet:
// Illustrative: this is what a Unity test file looks like. Building it needs
// unity.c and unity.h from the Unity distribution.
#if 0
#include "unity.h"
#include "parser.h"
// Run before and after EVERY test function -- the fixture hooks.
void setUp(void) { parser_init(); }
void tearDown(void) { parser_shutdown(); }
void test_parser_accepts_valid_input(void)
{
TEST_ASSERT_EQUAL_INT(0, parser_parse("key=value"));
TEST_ASSERT_EQUAL_STRING("value", parser_get("key"));
}
void test_parser_rejects_malformed_input(void)
{
TEST_ASSERT_NOT_EQUAL(0, parser_parse("no-equals-sign"));
}
void test_parser_handles_empty_value(void)
{
TEST_ASSERT_EQUAL_INT(0, parser_parse("key="));
TEST_ASSERT_EQUAL_STRING("", parser_get("key"));
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_parser_accepts_valid_input);
RUN_TEST(test_parser_rejects_malformed_input);
RUN_TEST(test_parser_handles_empty_value);
return UNITY_END();
}
#endif
int main(void)
{
return 0;
}
Testing Strategy in C
What differs from testing in a language with exceptions and reflection:
-
Make units testable by making them
static-free at the boundary. A function you want to test needs external linkage; a common convention is to keep helpersstaticand#include "impl.c"from the test file when a helper genuinely needs direct testing. -
Inject dependencies through function pointers or a struct of callbacks, since there is no way to monkey-patch a call. That is also how you mock: pass a fake
read_fninstead of the real one. -
Test the error paths. In C these are the majority of the interesting behavior — allocation failure, short reads, malformed input. Force allocation failure with a
mallocwrapper that fails on the n-th call. -
Test boundaries relentlessly: zero, one, exactly-the-buffer-size, one past it,
INT_MAX, empty string, null pointer. -
Isolate the process for crash-prone code. Check and Criterion fork per test so a segfault is a failure, not the end of the run.
#include <stdio.h>
#include <stdlib.h>
// Dependency injection with a function pointer -- the C way to mock.
struct Allocator {
void *(*allocate)(size_t size, void *context);
void (*release)(void *pointer, void *context);
void *context;
};
static void *real_allocate(size_t size, void *context)
{
(void)context;
return malloc(size);
}
static void real_release(void *pointer, void *context)
{
(void)context;
free(pointer);
}
// The unit under test takes its allocator, so a test can supply a failing one.
static char *duplicate(const struct Allocator *allocator, const char *text)
{
size_t length = 0;
while (text[length] != '\0') {
++length;
}
char *copy = allocator->allocate(length + 1, allocator->context);
if (copy == nullptr) {
return nullptr; // the path a test must exercise
}
for (size_t i = 0; i <= length; ++i) {
copy[i] = text[i];
}
return copy;
}
// ---- the fake: fails after N successful allocations -------------------------
struct FailingContext { int allowed; };
static void *failing_allocate(size_t size, void *context)
{
struct FailingContext *state = context;
if (state->allowed <= 0) {
return nullptr; // simulated out-of-memory
}
--state->allowed;
return malloc(size);
}
int main(void)
{
struct Allocator real = { real_allocate, real_release, nullptr };
char *ok = duplicate(&real, "hello");
printf("real allocator: %s\n", ok != nullptr ? ok : "(failed)");
real.release(ok, real.context);
// Now force the failure path, deterministically.
struct FailingContext state = { .allowed = 0 };
struct Allocator failing = { failing_allocate, real_release, &state };
char *failed = duplicate(&failing, "hello");
printf("failing allocator: %s\n", failed == nullptr ? "handled correctly" : "unexpected success");
return failed == nullptr ? 0 : 1;
}
Running Tests Through CTest
CTest turns "a directory of test executables" into a reportable suite, with no framework required:
cmake_minimum_required(VERSION 3.21) # CMAKE_C_STANDARD 23 needs 3.21
project(myapp LANGUAGES C)
set(CMAKE_C_STANDARD 23)
set(CMAKE_C_STANDARD_REQUIRED ON)
add_library(mylib src/parse.c src/util.c)
target_include_directories(mylib PUBLIC include)
include(CTest)
# One executable per test file; each is a CTest test.
foreach(test_name IN ITEMS test_parse test_util)
add_executable(${test_name} tests/${test_name}.c)
target_link_libraries(${test_name} PRIVATE mylib)
# Tests must NOT be built with NDEBUG, or every assert disappears.
target_compile_definitions(${test_name} PRIVATE $<$<CONFIG:Release>:>)
target_compile_options(${test_name} PRIVATE -UNDEBUG)
add_test(NAME ${test_name} COMMAND ${test_name})
endforeach()
# A sanitizer-instrumented variant of the same suite.
option(ENABLE_SANITIZERS "Build tests with ASan and UBSan" OFF)
if(ENABLE_SANITIZERS)
foreach(test_name IN ITEMS test_parse test_util)
target_compile_options(${test_name} PRIVATE -fsanitize=address,undefined -g)
target_link_options(${test_name} PRIVATE -fsanitize=address,undefined)
endforeach()
endif()
# Mark tests that are expected to fail, or that have a timeout:
set_tests_properties(test_parse PROPERTIES TIMEOUT 30 LABELS "unit")
$ cmake -S . -B build -DENABLE_SANITIZERS=ON && cmake --build build -j
$ ctest --test-dir build --output-on-failure # run everything
$ ctest --test-dir build -R parse # only matching names
$ ctest --test-dir build -L unit --parallel 8 # by label, in parallel
$ ctest --test-dir build --rerun-failed --output-on-failure
Testing UB-Sensitive Code Under Sanitizers
This is the part of C testing with no analogue in a memory-safe language: a passing test proves nothing if the code has undefined behavior, because the behavior may differ at another optimization level. So run the suite instrumented.
# The suite that actually catches memory bugs:
$ clang -std=c23 -Wall -Wextra -Werror -UNDEBUG -g -O1 \
-fsanitize=address,undefined -fno-omit-frame-pointer \
-o test_parse tests/test_parse.c src/parse.c
$ ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 \
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 ./test_parse
# ...and again for concurrency, if the code has threads:
$ clang -std=c23 -UNDEBUG -g -fsanitize=thread -o test_queue tests/test_queue.c src/queue.c
$ ./test_queue
# Run the suite at several optimization levels: UB frequently only manifests at -O2.
$ for opt in -O0 -O1 -O2 -O3; do
> clang -std=c23 -UNDEBUG $opt -o t tests/test_parse.c src/parse.c && ./t || echo "FAILED at $opt"
> done
Two further techniques worth adopting for anything that parses untrusted input:
# Coverage: find the lines the suite never reaches.
$ clang -std=c23 -UNDEBUG --coverage -O0 -o test_parse tests/test_parse.c src/parse.c
$ ./test_parse && gcov parse.c
$ lcov --capture --directory . --output-file coverage.info && genhtml coverage.info -o html
# Fuzzing: libFuzzer generates the inputs, ASan/UBSan judges the results.
# One function, no main():
# int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { ... }
$ clang -std=c23 -g -fsanitize=fuzzer,address,undefined -o fuzz_parse fuzz/parse.c src/parse.c
$ ./fuzz_parse corpus/ -max_total_time=60
Coverage tells you what you have not tested; fuzzing writes the tests you would not have thought of. For a parser, a decoder or anything reading a file format, an afternoon of fuzzing under ASan typically finds more than a week of hand-written cases.
See Also
-
Build and Tooling — CMake, sanitizers and static analysis in detail.
-
Error Handling and Program Failure —
assertvs. input validation, and what each sanitizer catches. -
Standard Library Overview —
assertandNDEBUG. -
Pointers — function pointers, the mechanism behind the mocking example.
-
C++: Testing — C++ brings GoogleTest, Catch2 and Boost.Test, plus
static_assertfor compile-time checks.
References
-
WG14 N3220 — the C23 working draft (§7.2 "Diagnostics `<assert.h>`").