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.

A Simple Test Runner

Start by including the header file of the module or system under test

/* 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 run_test(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.

# Makefile

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 need to be tunable, so stay away from 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;

Shortcuts

An easy, but helpful habit is to assign some shortcuts for cleaning, building, or running tests. These are some shortcuts I use in my .vimrc file:

map <C-C> :! make clean<enter>
map <C-M> :! make<enter>

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 dependant 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

I think it's especially cool to add a shell script 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

References

MinUnit -- a minimal unit testing framework for C by John Brewer

$ Thu Apr 01 14:08:13 -0500 2010 $