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 -DNDEBUG: it removes every assert, so the suite passes unconditionally. If release builds set NDEBUG project-wide, make the test target explicitly -UNDEBUG.

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

Unity

Three files, no dependencies

The embedded world’s default. Drop unity.c/unity.h in and go; pairs with CMock and Ceedling for mocking and project scaffolding.

CMocka

Library, with mocking built in

Test fixtures (setup/teardown), will_return/expect_value mocking, and leak/overflow detection of its own. The usual choice when you need mocks.

Check

Library, forks per test

Runs each test in its own process, so a segfault or an abort is reported as one failed test rather than killing the run. Autotools-friendly.

Criterion

Library, auto-registering

Tests register themselves (no runner boilerplate), run in parallel and in isolated processes, with theories and parameterized tests.

greatest / minunit

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 helpers static and #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_fn instead 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 malloc wrapper 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