Testing C Code

October 12, 2026 • 6 min read

Testing C Code
Table of contents

Part of the series:C Explained

C gives you no test runner, no test annotations, and no package manager. What it gives you is a language you already understand and an exit code. That is enough to build a real test suite, and understanding the bare mechanism is the fastest way to use the frameworks later. This post builds a small harness from scratch, then shows the tools professionals add on top.

The idea of a unit test

A unit test1 checks one piece of behavior in isolation: build a function with known inputs, compare the result to what you expect, and fail loudly if they differ. Tests live in their own .c file, call your functions, and report pass or fail. The main function returns 0 only if everything passed, which is exactly what continuous integration and make test expect.

A tiny harness, no dependencies

Start with a function to test, in math_utils.c:

int add(int a, int b) {
    return a + b;
}

double average(const int *values, int n) {
    int total = 0;
    for (int i = 0; i < n; i++) {
        total += values[i];
    }
    return (double)total / n;
}

Now a test file, test_math_utils.c, that counts failures:

#include <stdio.h>
#include <stdlib.h>
#include <math.h>

int add(int a, int b);
double average(const int *values, int n);

static int failures = 0;

#define CHECK(expr)                                        \
    do {                                                   \
        if (!(expr)) {                                     \
            printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #expr); \
            failures++;                                    \
        } else {                                           \
            printf("ok   %s\n", #expr);                    \
        }                                                  \
    } while (0)

int main(void) {
    CHECK(add(2, 3) == 5);
    CHECK(add(-1, 1) == 0);
    CHECK(add(0, 0) == 0);

    int values[] = {2, 4, 6};
    CHECK(fabs(average(values, 3) - 4.0) < 1e-9);

    if (failures > 0) {
        printf("\n%d test(s) failed\n", failures);
        return EXIT_FAILURE;
    }
    printf("\nall tests passed\n");
    return EXIT_SUCCESS;
}

The CHECK macro is the whole framework: it evaluates an expression, prints ok or FAIL with the file and line, and increments a counter. #expr turns the expression into its own text so the output explains itself. Running main returns a non-zero exit code when anything failed, which is all a build pipeline needs.

Compile and run both files together:

gcc -Wall -Wextra -o test_math math_utils.c test_math_utils.c -lm
./test_math

Note -lm: the math library (fabs) needs to be linked explicitly on many systems. Comparing floats with a tolerance (< 1e-9) instead of == follows the rule from the data types post: decimal arithmetic is never exact.

Table-driven tests

When a function has many input/output pairs, write them as data and loop. This keeps one piece of test logic and dozens of cases:

typedef struct {
    int a;
    int b;
    int expected;
} AddCase;

int main(void) {
    AddCase cases[] = {
        {2, 3, 5},
        {-1, 1, 0},
        {0, 0, 0},
        {100, 200, 300},
    };

    for (int i = 0; i < 4; i++) {
        int got = add(cases[i].a, cases[i].b);
        if (got != cases[i].expected) {
            printf("FAIL add(%d, %d): expected %d, got %d\n",
                   cases[i].a, cases[i].b, cases[i].expected, got);
            return EXIT_FAILURE;
        }
    }
    printf("all %d cases passed\n", 4);
    return EXIT_SUCCESS;
}

Adding a case is one line, and structs plus arrays (from the earlier posts) pay off immediately here.

Test what is easy to test

The functions above are pure: same inputs, same output, no side effects. They are trivial to test. Functions that print, read input, allocate, or touch files are harder, so experienced C programmers isolate the pure logic from the I/O:

// hard to test: mixes parsing and printing
void print_upper(const char *s);

// easy to test
int to_upper(char *dst, const char *src, size_t cap);

Push printf, scanf, and fopen to the edges of the program; keep the middle in pure functions that take values and return values. This is the single biggest factor in whether a C project is testable.

Catching memory bugs with sanitizers

Tests prove behavior; sanitizers find undefined behavior. Compile the test binary with the address and undefined-behavior sanitizers and the tool runtime checks every memory access and arithmetic operation, reporting file and line on failure:

gcc -Wall -Wextra -g -fsanitize=address,undefined \
    -o test_math math_utils.c test_math_utils.c -lm
./test_math

AddressSanitizer2 catches buffer overflows, use-after-free, and double frees; UndefinedBehaviorSanitizer catches integer overflow, invalid shifts, and null dereferences. Run the whole test suite under them in CI; they turn silent corruption into a named error. For leaks specifically, valgrind is the classic tool:

valgrind --leak-check=full ./test_math

Neither is a substitute for tests, and tests are not a substitute for them. Use both.

A real framework, when the harness grows

The hand-rolled CHECK harness is fine for a small project. When you want test discovery, setup/teardown, and nicer reports, the lightweight C frameworks all follow the same shape:

  • Unity - tiny, popular in embedded, one .c and one .h.
  • CMocka - supports mocks and setup/teardown.
  • Criterion - compiles tests into separate processes, so a crash in one test does not kill the run.
  • Check - the older, unit-test classic.

Because they are just C libraries, you add the files, link them into your test binary, and run it. Nothing about your understanding of the harness changes; the framework only saves typing.

One test, one behavior

A few habits make the difference between a suite you trust and one you ignore:

  • Test the boundaries: zero, one, negative, the maximum, and the empty case.
  • Test the error paths too, not only the happy path. Feed malloc a size it can refuse, pass a NULL pointer, an out-of-range value.
  • Keep tests fast and independent. A test that depends on another test’s leftovers is worse than no test.
  • Run the tests automatically on every change. A test you have to remember to run is a test you will forget.

The big picture

Testing C needs no magic: a separate test file, a CHECK macro, a failure counter, and an exit code. Table-driven cases keep many inputs cheap; pure functions keep testing easy; sanitizers and valgrind catch the memory crimes that tests cannot see; and real frameworks only automate this same loop. In the final post we put the whole series together and build one small program from scratch, tests included.

Footnotes

  1. Unit testing - Wikipedia

  2. AddressSanitizer - Wikipedia