It disappoints me to see messy, procedural, assertion-based testing in C codebases. Here’s an example from the wild:
const char *key = "404";
const int values[3] = { 0, 1, 2 };
plan_no_plan();
Map *map = map_new();
ok(map && map_empty(map), "Creation");
ok(map_first(map, &key) == NULL && strcmp(key, "404") == 0, "First on empty map");
ok(map_empty(map_prefix(map, "404")), "Empty prefix map");
In assertion-based testing, it’s common to test an entire data structure in a single function, instead of testing one of its operations. This groups together tests for unrelated behaviors. Assertions tend to contain difficult boolean expressions that mutate state silently, coupling test cases together. They use magic to hide the test framework implementation, and reimplement features that your programming language already has.
There’s often a better way.
The C programming language relies heavily on convention to get things done. One convention I find productive is table-driven testing. C has several features that facilitate this convention: anonymous structs, variadic functions, function pointers, and arrays. Macros can be useful too, in moderation.
To test buf.c
, create buf_test.c
in the same directory. I’ll admit that
I’m perplexed by the practice of putting tests in a different directory,
or even a different repository.
#include "buf.c"
The test file includes a source file, not a header. This keeps static declarations in scope, making it possible to test the entire translation unit, not just whatever the header file (if any) declares.
It’s required that the translation unit doesn’t define main
. If it does, I’d
guess in most cases it can be factored out into a separate file.
int
test_add()
{
...
}
A tester function
int
and takes no argumentstest_
and includes the testee’s nameHere we combine two of the nice, orthogonal C features I mentioned at the beginning: arrays and anonymous structs.
A tester function iterates over a statically initialized array of test
cases. The test case type can be an anonymous struct; if it isn’t used
elsewhere, there’s no need for a named struct declaration or typedef
. The
struct holds given input values and wanted output values.
struct {
int a, b;
int want;
} t, test[] = {
{
.a = 0,
.b = 0,
.want = 0,
},
{
.a = 3,
.b = 4,
.want = 7,
},
{
.a = 1,
.b = -1,
.want = 0,
},
};
For brevity, you can put each case on a single line
{ .a = 1, .b = -1, .want = 0 },
or omit the designators, but be careful of ordering.
/* a b want */
{ 1, -1, 0 },
On failure, print out a message specifying
__func__
)When a test fails, don’t quit. Run the rest of the tests to observe the whole picture. And count and return the number of failed cases.
int fails = 0;
const char *format = "%s: {a=%d, b=%d, want=%d}: got %d\n";
for (unsigned i = 0; i < len(test); i++) {
t = test[i];
int got = add(t.a, t.b);
if (got != t.want) {
fails++;
printf(format, __func__, t.a, t.b, t.want, got);
}
}
return fails;
When your code breaks, you’ll get a pithy message:
test_add: {a=0, b=1, want=1}: got 42
Don’t try to force all of your test cases into one tester function. It’s OK
to have test_buf
, test_buf_empty
, and test_buf_full
if each of these
require significantly different logic in the loop body. Shared code can
often be factored out into helper functions.
C’s equality operator doesn’t work for more dynamic structures like trees and lists. Even if your application never has to test for equality between these data types, you can still write a comparison function to use in tests. Doing so will simplify observing your code’s behavior!
Many compound data structures, like stacks, are constructed imperatively, through a sequence of statements. This makes them difficult to use in tables. Avoid constructing these values imperatively before the table declaration. Instead, write a constructor that makes it possible to set up elaborate test cases in a single expression, like initializing a stack with a certain sequence of members.
{
.s = newStack(42),
.push = 68,
.want = newStack(42, 68),
},
This keeps your code looking declarative, even if it’s highly imperative under the covers.
In assertion-based testing, data construction is interleaved with operation
testing. For imperative data structures, data construction itself is an
operation, and thus presents an opportunity for testing. But this mixes
concerns; it’s more intelligible to write a separate tester for newStack()
,
for instance, than to test it while it’s being used for another purpose.
The main
function follows the same pattern: it iterates over an array of
test cases and tallies up the number of failed cases. But here, the cases are
tester functions.
As you write new testers, don’t forget to add them to the array.
#define len(x) (sizeof(x)/sizeof(x[0]))
int
main()
{
int (*test[])() = {
test_newbuf,
test_append,
test_free,
test_read,
};
int fails = 0;
for (unsigned i = 0; i < len(test); i++)
fails += test[i]();
return fails;
}
Be vigilant about memory errors.
valgrind --error-exitcode=1 --leak-check=full -q ./buffer_test
While there’s some amount of boilerplate in this approach, the use of data to specify test cases makes the code easier to read by factoring out repetition and state mutation. C can be a decent language for writing declarative code, if you adopt effective conventions to that end. And you can use C without integrating your project with another third-party dependency.
Discuss this page by emailing my public inbox. Please note the etiquette guidelines.
© 2024 Karl Schultheisz