Testing C code
A test is a program that checks another program and reports through its exit status. C needs no framework for that: assert from assert.h is one tool, and a counter plus a comparison is another. The examples here run with a plain compiler command, which is what a test script in lesson 65 will call.
assert stops the program
assert(expression) does nothing while the expression is true. When it is false, the macro prints the expression, the file and the line, and calls abort, so the process ends with status 134. That is the point: a failed invariant stops the program before it writes wrong data.
#include <assert.h>
#include <stdio.h>
int main(void)
{
int value = 2 + 2;
assert(value == 4);
printf("first check passed\n");
assert(value == 5);
printf("never reached\n");
return 0;
}
gcc -Wall -Wextra asserts.c -o asserts
./asserts; echo "status: $?"
first check passed
Assertion failed: value == 5 (asserts.c: main: 10)
status: 134
The message form comes from this C library: the expression, the file, the function and the line. The status 134 is 128 plus the signal number of SIGABRT, which is how a shell reports an aborted process. The line after the failed assertion never runs.
A tiny harness
A failing assertion stops at the first problem, which is wrong when you want the whole report. A CHECK macro prints the condition, counts the failures, and lets the program finish. The exit status is the summary: zero when every check passed, one otherwise.
#include <stdio.h>
static int failures = 0;
#define CHECK(condition) do { \
if (condition) { \
printf("ok: %s\n", #condition); \
} else { \
printf("failed: %s\n", #condition); \
failures++; \
} \
} while (0)
static int add(int a, int b)
{
return a + b;
}
int main(void)
{
CHECK(add(2, 2) == 4);
CHECK(add(-1, 1) == 0);
CHECK(add(2, 2) == 5);
printf("failures: %d\n", failures);
return failures == 0 ? 0 : 1;
}
gcc -Wall -Wextra harness.c -o harness
./harness; echo "status: $?"
ok: add(2, 2) == 4
ok: add(-1, 1) == 0
failed: add(2, 2) == 5
failures: 1
status: 1
The text after the colon is the macro argument stringified, so the report shows the condition without a second string to maintain. The do { } while (0) wrapper is the one from lesson 43, which keeps the macro a single statement.
Table-driven tests
When the same function is checked with many inputs, the cases belong in an array and the loop does the work. Adding a case is one more line, and a failure names the index and the expected value. This is the shape of the test suites in larger projects.
#include <stdio.h>
struct Case {
int a;
int b;
int expected;
};
static int add(int a, int b)
{
return a + b;
}
int main(void)
{
struct Case cases[4] = {
{2, 2, 4},
{-1, 1, 0},
{0, 0, 0},
{100, -50, 50},
};
int failures = 0;
for (int i = 0; i < 4; i++) {
int got = add(cases[i].a, cases[i].b);
if (got != cases[i].expected) {
printf("case %d: got %d, expected %d\n", i, got, cases[i].expected);
failures++;
}
}
printf("failures: %d\n", failures);
return failures == 0 ? 0 : 1;
}
gcc -Wall -Wextra table.c -o table
./table; echo "status: $?"
failures: 0
status: 0
A silent run is a passing run in this style: only failures print. The exit status still carries the answer, so a script does not need to parse the line. The array is a plain struct array from lesson 36, with no test framework involved.
Boundary values
The cases that break a function are usually at the edges, not in the middle. A clamp is tested at the two limits, one step inside and one step outside each. The table below has five inputs and five expected values, and every one sits on a boundary or inside it.
#include <stdio.h>
static int clamp(int value, int low, int high)
{
if (value < low) {
return low;
}
if (value > high) {
return high;
}
return value;
}
int main(void)
{
int inputs[5] = {-10, 0, 50, 100, 110};
int expected[5] = {0, 0, 50, 100, 100};
int failures = 0;
for (int i = 0; i < 5; i++) {
int got = clamp(inputs[i], 0, 100);
if (got != expected[i]) {
printf("input %d: got %d, expected %d\n", inputs[i], got, expected[i]);
failures++;
}
}
printf("failures: %d\n", failures);
return failures == 0 ? 0 : 1;
}
gcc -Wall -Wextra clamp.c -o clamp
./clamp; echo "status: $?"
failures: 0
status: 0
A clamp is easy to get wrong at exactly 0 and 100: a strict comparison would push those values out. The test catches that because it checks the limits, not only a value in the middle. The same idea applies to empty arrays, zero counts and the largest representable value.
assert is removed by NDEBUG
Defining NDEBUG turns the assertion macro into nothing, which is how a release build removes the checks. The argument is removed with it, so an expression with a side effect stops running. The program below counts the calls and prints a different number in the two builds:
#include <assert.h>
#include <stdio.h>
int main(void)
{
int calls = 0;
assert(++calls == 1);
printf("calls: %d\n", calls);
return 0;
}
gcc -Wall -Wextra ndebug.c -o ndebug
./ndebug
gcc -DNDEBUG -Wall -Wextra ndebug.c -o ndebug_off
./ndebug_off
calls: 1
calls: 0
The fix is to keep the side effect outside the assertion: increment first, then assert the value. A test that must run in every build belongs in the harness from the second section, not in an assertion that the release build removes.
Exercises
- Add tests for a function that returns the average of an array and the empty-array case.
- Make the table-driven test print the total number of cases as well as the failures.
- Compile the harness with -DNDEBUG and explain which part of the report changes and which part stays.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What does a failed
assertdo?Answer
It prints the expression and aborts the process
The macro reports the expression, file and line, then calls
abort, which ends the process with status 134. -
Why does the
CHECKharness continue after a failure?Answer
It counts failures and reports all of them
A failing check increments the counter, so the run reaches the summary and the exit status reports the total.
-
What does a table-driven test add for a new case?
Answer
One more row in the case array
The loop walks the array, so a case is one row with its inputs and expected value.
-
Why are boundary values tested for a clamp?
Answer
The limits are where strict comparisons go wrong
A clamp fails exactly at the low and high ends, so the tests include both limits and one step outside each.
-
What exit status does the shell report for the code shown?
#include <assert.h> #include <stdio.h> int main(void){ int value=2+2; assert(value==4); printf("first check passed\n"); assert(value==5); return 0; }Answer
134
The second assertion aborts, and 128 plus the signal number gives 134.
-
What does the
CHECKharness report for its three conditions?Answer
Two ok lines, one failed line and failures: 1
Two conditions are true and the third is false, so the summary counts one failure and the status is 1.
-
What does the table-driven test print for its four cases?
Answer
failures: 0 and status 0
All four additions match their expected values, so only the summary prints and the status is zero.
-
What does the clamp test report?
Answer
failures: 0 and status 0
The five inputs are clamped to the five expected values, including both limits, so no failure is counted.
-
When
NDEBUGis defined, what happens to the expression insideassert(++calls == 1)?Answer
The whole expression disappears with the macro
NDEBUGturns the assertion into nothing, so the increment inside it never runs. -
Why is a side effect inside
asserta mistake?Answer
The release build removes the side effect too
NDEBUGturns the macro into nothing, so an increment or call inside it disappears from the release build.
The code on this page was run with gcc (Alpine 15.2.0) 15.2.0 on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev