Writing unit tests for C code in Python

The C language combines all the power of assembly language with all the ease-of-use of assembly language.

Unit Tests with C

pros

  • the same language everywhere
  • tests might be able to run on the target device/simulator

cons

  • limited to what C has to offer
  • limited to what the framework has to offer (e.g. mocking?)
  • limited to what the ecosystem has to offer (e.g. crypto?)

Examples

Example 1

In [3]:
%cat add.h
int add(int a, int b);
In [4]:
%cat add.c
#include "add.h"

int add(int a, int b)
{
	return a + b;
}
In [6]:
class AddTest(unittest.TestCase):
    def test_addition(self):
        module = load('add')
        self.assertEqual(module.add(1, 2), 1 + 2)

run(AddTest)
test_addition (__main__.AddTest) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.131s

OK
In [7]:
def load(filename):
    # load source code
    source = open(filename + '.c').read()
    includes = open(filename + '.h').read()
    
    # pass source code to CFFI
    ffibuilder = cffi.FFI()
    ffibuilder.cdef(includes)
    ffibuilder.set_source(filename + '_', source)
    ffibuilder.compile()
    
    # import and return resulting module
    module = importlib.import_module(filename + '_')
    return module.lib

Example 2

In [8]:
%cat sum.h
int sum(int a);
In [9]:
%cat sum.c
#include "sum.h"

static int _sum = 0;

int sum(int a)
{
	_sum += a;
	return _sum;
}
In [11]:
class SumTest(unittest.TestCase):
    def setUp(self):
        self.module = load('sum')
        
    def test_zero(self):
        self.assertEqual(self.module.sum(0), 0)

    def test_one(self):
        self.assertEqual(self.module.sum(1), 1)

    def test_multiple(self):
        self.assertEqual(self.module.sum(2), 2)
        self.assertEqual(self.module.sum(4), 2 + 4)

run(SumTest)
test_multiple (__main__.SumTest) ... ok
test_one (__main__.SumTest) ... ok
test_zero (__main__.SumTest) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.263s

OK
In [12]:
def load(filename):
    source = open(filename + '.c').read()
    includes = open(filename + '.h').read()
    
    ffibuilder = cffi.FFI()
    ffibuilder.cdef(includes)
    ffibuilder.set_source(filename + '_', source)
    ffibuilder.compile()
    
    module = importlib.import_module(filename + '_')
    return module.lib
In [13]:
def load(filename):
    # generate random name
    name = filename + '_' + uuid.uuid4().hex
    
    source = open(filename + '.c').read()
    includes = open(filename + '.h').read()

    ffibuilder = cffi.FFI()
    ffibuilder.cdef(includes)
    ffibuilder.set_source(name, source)
    ffibuilder.compile()
    
    module = importlib.import_module(name)
    return module.lib

Example 3

In [14]:
%cat types.h
typedef struct {
	float real;
	float imaginary;
} complex;
In [15]:
%cat complex.h
#include "types.h"

complex add(complex a, complex b);
In [16]:
%cat complex.c
#include "complex.h"

complex add(complex a, complex b)
{
	a.real += b.real;
	a.imaginary += b.imaginary;
	return a;
}
In [18]:
class ComplexTest(unittest.TestCase):
    def setUp(self):
        self.module = load('complex')
        
    def test_addition(self):
        result = self.module.add([0, 1], [2, 3])
        self.assertEqual(result.real, 2)
        self.assertEqual(result.imaginary, 4)

run(ComplexTest)
test_addition (__main__.ComplexTest) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.105s

OK
In [19]:
def load(filename):
    name = filename + '_' + uuid.uuid4().hex
    
    source = open(filename + '.c').read()
    includes = open(filename + '.h').read()

    ffibuilder = cffi.FFI()
    ffibuilder.cdef(includes)
    ffibuilder.set_source(name, source)
    ffibuilder.compile()
    
    module = importlib.import_module(name)
    return module.lib
In [20]:
def load(filename):
    name = filename + '_' + uuid.uuid4().hex

    source = open(filename + '.c').read()
    # handle preprocessor directives
    includes = preprocess(open(filename + '.h').read())
    
    ffibuilder = cffi.FFI()
    ffibuilder.cdef(includes)
    ffibuilder.set_source(name, source)
    ffibuilder.compile()
    
    module = importlib.import_module(name)
    return module.lib
In [21]:
def preprocess(source):
    return subprocess.run(['gcc', '-E', '-P', '-'],
                          input=source, stdout=subprocess.PIPE,
                          universal_newlines=True, check=True).stdout

Example 4

In [22]:
%cat gpio_lib.h
int read_gpio0(void);
int read_gpio1(void);
In [23]:
%cat gpio.h
int read_gpio(int number);
In [24]:
%cat gpio.c
#include "gpio.h"
#include "gpio_lib.h"

int read_gpio(int number)
{
	switch (number)
	{
		case 0:
			return read_gpio0();
		case 1:
			return read_gpio1();
		default:
			return -1;
	}
}
In [26]:
class GPIOTest(unittest.TestCase):
    def setUp(self):
        self.module, self.ffi = load('gpio')
        
    def test_read_gpio0(self):
        @self.ffi.def_extern()
        def read_gpio0():
            return 42
        self.assertEqual(self.module.read_gpio(0), 42)
        
    def test_read_gpio1(self):
        read_gpio1 = unittest.mock.MagicMock(return_value=21)
        self.ffi.def_extern('read_gpio1')(read_gpio1)
        self.assertEqual(self.module.read_gpio(1), 21)
        read_gpio1.assert_called_once_with()
        
run(GPIOTest)
test_read_gpio0 (__main__.GPIOTest) ... ok
test_read_gpio1 (__main__.GPIOTest) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.222s

OK
In [27]:
def load(filename):
    name = filename + '_' + uuid.uuid4().hex

    source = open(filename + '.c').read()
    includes = preprocess(open(filename + '.h').read())
    
    ffibuilder = cffi.FFI()
    ffibuilder.cdef(includes)
    ffibuilder.set_source(name, source)
    ffibuilder.compile()
    
    module = importlib.import_module(name)
    return module.lib
In [28]:
def load(filename):
    name = filename + '_' + uuid.uuid4().hex

    source = open(filename + '.c').read()
    # preprocess all header files for CFFI
    includes = preprocess(''.join(re.findall('\s*#include\s+.*', source)))

    # prefix external functions with extern "Python+C"
    local_functions = FunctionList(preprocess(source)).funcs
    includes = convert_function_declarations(includes, local_functions)

    ffibuilder = cffi.FFI()
    ffibuilder.cdef(includes)
    ffibuilder.set_source(name, source)
    ffibuilder.compile()

    module = importlib.import_module(name)
    # return both the library object and the ffi object
    return module.lib, module.ffi
In [29]:
class FunctionList(pycparser.c_ast.NodeVisitor):
    def __init__(self, source):
        self.funcs = set()
        self.visit(pycparser.CParser().parse(source))
        
    def visit_FuncDef(self, node):
        self.funcs.add(node.decl.name)
In [30]:
class CFFIGenerator(pycparser.c_generator.CGenerator):
    def __init__(self, blacklist):
        super().__init__()
        self.blacklist = blacklist
        
    def visit_Decl(self, n, *args, **kwargs):
        result = super().visit_Decl(n, *args, **kwargs)
        if isinstance(n.type, pycparser.c_ast.FuncDecl):
            if n.name not in self.blacklist:
                return 'extern "Python+C" ' + result
        return result

def convert_function_declarations(source, blacklist):
    return CFFIGenerator(blacklist).visit(pycparser.CParser().parse(source))

Conclusion

Drawbacks

  • Crashes in the C code also kill the test process
  • Debugging gets harder

Takeaways

  • Writing test cases is simple
  • Complexity is hidden inside CFFI and its wrapper