kdsch.org · about · rss

2022 Apr 11

Evolving a technique for unit testing in C

I expect to be writing a lot more lately, because today is my son’s first day of daycare. My stint as a full-time parent is officially over, and now it’s time to get back in the groove. I plan to write more on that soon.


In a previous article, I described a way to do table-driven testing in C. Since then, experience with this technique (and others) has suggested an evolution.

A disadvantage of the table-driven approach manifests when something unexpected happens during the test, such as a segmentation fault. You immediately want to locate the error. But because the test cases are an array of data, not a sequence of statements, each case executes at the same location: in the body of a loop. Locating the error requires identifying the case by some other means, often by counting test cases, or perhaps by giving each case a description string and printing it before executing.

The ethos of table-driven testing is to prefer data over behavior, but in a language such as C, this is possible only to a limited extent. Table-driven testing might be a great fit for a more declarative language, but C needs a bit more nuance.

Procedural testing handles tracing naturally. Every test case executes at a different location, so you can immediately find the case that segfaults.

A disadvantage of procedural testing (which is what might prompt your interest in table-driven testing) is that it permits inconsistency by allowing different test subjects to be intermixed in the same sequence, and tends not to name the parameters of the test cases, which harms readability.

The two approaches can be combined to attain the traceability of procedural testing with the consistency and readability of table-driven testing. We can adopt a convention in which similar tests are grouped sequentially, and require test cases to be supplied as data. It looks like this, if you’re testing a function f (which may be static):

#include "f.c"

Yes, you really include the .c file. The test program is the only place you’re allowed to do that, which ensures that functions won’t be multiply defined. Think of it as simply extending the compilation unit to include some other stuff, and incidentally a main function, which allows it to be compiled as an executable.

struct test_case_f {
	const char *desc;
	... input;
	... want;
};

void
test_f(int *fails, struct test_case_f *c)
{
	... got = f(c->input);
	if (got != want) {
		++*fails;
		printf("%s (%s): input=%... want=%... got=%...\n",
			__func__, c->desc, c->input, c->want, got);
	}
}

That takes care of test parameters, logic, and diagnostic messages. Then you use it to create a sequence of test cases:

int
test_f_cases(void)
{

	int fails = 0;

	test_f(&fails, &(struct test_case_f){
		.desc = "test case 1",
		.input = ...,
		.want = ...,
	});

	test_f(&fails, &(struct test_case_f){
		.desc = "test case 2",
		.input = ...,
		.want = ...,
	});

	return fails;
}

int
main(void)
{
	int fails = 0;
	fails += test_f_cases();
	fails += test_g_cases();
	fails += test_h_cases();
	return fails;
}

The consistency of this approach clarifies how and where to add new tests. The last thing you want, when you are fixing a bug or refactoring, is to bumble around searching for a place in your infrastructure to put the test.

Testing isn’t the only effective quality strategy, so I don’t intend to sell this as a universal solution. My goal, as before, is to show how the C programming language is sufficient to write tests for code written in C. This approach attains a transparency that is absent from third-party testing frameworks or home-grown macros.


Feedback

Discuss this page by emailing my public inbox. Please note the etiquette guidelines.

© 2024 Karl Schultheisz — source