Eric Radman : a Journal

Test-Driven Development in C

The Internet is now littered with frameworks writing unit tests for C, which is unfortunate because they suggest that writing test code for C requires a kind of parallel project that is bolted on the side. Worse yet, papers and presentations on the subject suggest adapting your project to a C++ framework along with it's clumsy idioms. A dogged insistence on simplicity when structuring a project written in C enables you to pick the techniques that fit particular designs. The business of writing tests requires some scafolding, but not much.

A Simple Test Runner

Start by including the header file of the module or system under test. The following is based on MinUnit, a minimal unit testing framework for C by John Brewer

/* runner.c */

#include <stdio.h>
#include "sut.h"

int tests_run = 0;

Next define some simple macros that will print a meaningful error and return

1 if the test condition is not true. do { ... } while(0) is a standard way to include multiple statements in a macro, and the block gives variable declarations local storage.
#define FAIL() printf("\nfailure in %s() line %d\n", __func__, __LINE__)
#define _assert(test) do { if (!(test)) { FAIL(); return 1; } } while(0)
#define _verify(test) do { int r=test(); tests_run++; if(r) return r; } while(0)

The test runner itself is responsible for calling the run_test macro, which simply run each test in turn until one fails.

int square_01() {
    int x=5;
    _assert(square(x) == 25);
    return 0;
}

int all_tests() {
    _verify(square_01);
    return 0;
}

int main(int argc, char **argv) {
    int result = all_tests();
    if (result == 0)
        printf("PASSED\n");
    printf("Tests run: %d\n", tests_run);

    return result != 0;

Link-level Stubbing

Among the techniques available for faking functions in C, link-level stubs are a great place to begin because they require no changes to production code! One of the first functions you may want to replace is main(), so that the test runner is the new entry point for the resulting binary.

runner: runner.o sut.o
        strip -N main sut.o _sut.o
        ${CC} -o $@ runner.o _sut.o ${LDFLAGS}

Here we create a stripped version of the object code (utility.o) , which allows the linker to connect the new main from runner.o. Of course the test runner should always be executed as part of the build.

all: utility runner test

test:
        ./runner

Faking Interfaces with the C Preprocessor

Linking a test runner only works if functions have a global scope (e.g., don't use the static storage class). It may be sensible to inject the test code into each module using the C preprocessor. This is a very powerful technique, but it means maintaining separate build configurations, which has several implications.

Everything needs to be built twice, so it really helps to use a fast compiler, such as PCC. Build scripts and makefiles are making very strategic substitutions here, so I would strongly recommend staying clear of auto-tools. Instead factor out common elements and include them in platform-specific build configurations.

# config.mk
CC = pcc
INCS =
LIBS =
CFLAGS = -O -g -std=c99 -pedantic -Wall ${INCS}
LDFLAGS = -static ${LIBS}

Compile each source file with the -D flag to set macros that the preprocessor can use.

include config.mk

sut.o:
        @echo CC $<
        @${CC} -c -DTEST ${CFLAGS} $<

Preprocessor instructions can be placed anywhere, but one way of organizing code is to break it into two distinct segments. The first is the system under test, and should contain all of the core functionality of the module, the second will include main() and other definitions that may be replaced by fake functions in the specification header.

/* sut.c */

/* functions to be tested */

#ifdef TEST
#include "sut_spec.h"
#else

/* functions to be faked */

#endif

Dynamic Mocks Using Global Function Pointers

If you don't mind one step of indirection, functions can be aliased with a function pointer which the test runner can then redefine at runtime.

/* sut.c */

int (*myfunc)(int);

int myfunc_IO(int x) {
    /* ... */
}
int (*myfunc)(int) = myfunc_IO;
/* runner.c */

int myfunc_fake(int x) {
    /* ... */
}

void redefine_func_pointers() {
    myfunc = myfunc_fake;
}

Since globals in C are initialized to 0, an if statement can be used in corner cases where you might want to determine if function pointer has been defined but not initialized. Replacing main() is an example of when you might need to do this.

/* sut.c */

int (*test_runner_main)(int, char **);

int main(int argc, char **argv) {
    if((*test_runner_main))
        return(test_runner_main(argc, argv));
    /* ... */
}
/* runner.c */

#include "sut.c"

int test_main(int argc, char **argv) {
    /* run tests */
}
int (*test_runner_main)(int argc, char **argv) = test_main;

Locating Assertions that Trigger a Segfaults

Many system calls are designed to be fast, not safe. If a function such as

strcmp(3) is given a NULL pointer it will trigger a segfault. Fortunately, it's possible to catch SIGSEGV and generate a core dump at the same time.
const char* func;
int line;

#define _() func=__func__; line=__LINE__;
#define ok(test) do { _(); if (!(test)) { fail(); return 1; } } while(0)
#define run(test) do { reset_state(); tests_run++; test(); } while(0)
void fail() { printf("test failure in %s() line %d\n", func, line); }

void sighandler(int signum) {
        fail();
        /* generate core dump */
        signal(signum, SIG_DFL);
        kill(getpid(), signum);
}
/* main() */
    signal(SIGSEGV, sighandler)

In this test runner each assert macro records the line number and function name to global variables, which the signal handler uses to print the location of the assertion that failed.

Compile-time Checks and Debugging

One valuable assurance is that all of your tests are run. Adding -Wall to CFLAGS will cause GCC to warn you if a function is defined but never called.

runner.c:81: warning: `test_env_subset' defined but not used

It's often said that test-first programming makes us less dependent on the debugger, and that's true, but a test runner also creates an environment where a debugger can be easily employed because the runner systematically exercises one function at a time. Make sure that debugging is turned on, and that your linker is not striping executables (-s).

CFLAGS += -g

It is also sometimes handy to add a shell script to the build that automatically prints out a backtrace of any core dumps that occurred.

#!/bin/sh

cat <<EOF > /tmp/report.gdb
bt full
quit
EOF

for I in `ls *.core 2> /dev/null`; do
        echo "---"
        gdb -q -x /tmp/report.gdb ${I%.core} -c ${I}
done

rm /tmp/report.gdb

Last updated on November 26, 2016