Testing Methodologies
Testing ensures C code behaves correctly under normal use, edge cases, and failure conditions. This chapter focuses on practical unit testing in C on Linux/POSIX: small harnesses you can compile with gcc, table-driven tests, TDD, boundary analysis, coverage with gcov/lcov, and sanitizers.
Build flags used throughout this chapter:
gcc -std=c17 -Wall -Wextra -o program program.cAdd -g for debugging symbols, and sanitizer flags when hunting memory/UB bugs (covered below).
Why Testing Matters
- Bug detection early: cheaper than production incidents
- Documentation: tests show intended API contracts
- Safe refactoring: change internals without fear if tests pass
- Regression control: catch breakages when you touch old code
Types of Testing (C-focused)
| Level | What you test | Typical tools in C |
|---|---|---|
| Unit | One function / small module | assert, custom macros, Unity, CUnit |
| Integration | Several .c files / libraries together |
Same harness + real I/O stubs |
| System | Whole binary (CLI, server) | Shell scripts, golden files |
| Regression | “This used to work” cases | Any automated suite in CI |
Unit testing characteristics
- Small and focused — one behavior per test when possible
- Fast — milliseconds, not minutes
- Independent — no order dependence
- Repeatable — same result every run
- Automated — no human clicks
Style 1: assert.h Unit Tests
<assert.h> is enough for tiny libraries. Failed asserts abort the process (good for local runs; less pretty for large suites).
/* file: test_add.c */
#include <assert.h>
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
void test_add(void) {
assert(add(2, 3) == 5);
assert(add(-1, 1) == 0);
assert(add(0, 0) == 0);
assert(add(1000000, 2000000) == 3000000);
}
int main(void) {
test_add();
printf("all asserts passed\n");
return 0;
}gcc -std=c17 -Wall -Wextra -o test_add test_add.c
./test_addNote: In release builds, NDEBUG disables asserts. Keep tests in a separate binary that never defines NDEBUG.
Style 2: Minimal Custom TEST Macro Harness
A few macros give you named tests, a pass/fail counter, and a non-zero exit code — without external libraries.
/* file: mini_test.h */
#ifndef MINI_TEST_H
#define MINI_TEST_H
#include <stdio.h>
#include <string.h>
static int g_tests_run;
static int g_tests_failed;
#define TEST_BEGIN() \
do { g_tests_run = 0; g_tests_failed = 0; } while (0)
#define TEST_END() \
do { \
printf("\n%d tests, %d failed\n", g_tests_run, g_tests_failed); \
return g_tests_failed ? 1 : 0; \
} while (0)
#define RUN_TEST(fn) \
do { \
g_tests_run++; \
printf(" RUN %s ... ", #fn); \
fflush(stdout); \
if ((fn)() == 0) { \
printf("OK\n"); \
} else { \
g_tests_failed++; \
printf("FAIL\n"); \
} \
} while (0)
/* Return 0 on success, 1 on failure from these macros */
#define EXPECT_EQ_INT(actual, expected) \
do { \
int _a = (actual); \
int _e = (expected); \
if (_a != _e) { \
fprintf(stderr, "\n %s:%d: expected %d, got %d\n", \
__FILE__, __LINE__, _e, _a); \
return 1; \
} \
} while (0)
#define EXPECT_TRUE(cond) \
do { \
if (!(cond)) { \
fprintf(stderr, "\n %s:%d: condition false: %s\n", \
__FILE__, __LINE__, #cond); \
return 1; \
} \
} while (0)
#define EXPECT_STREQ(actual, expected) \
do { \
const char *_a = (actual); \
const char *_e = (expected); \
if (_a == NULL || _e == NULL || strcmp(_a, _e) != 0) { \
fprintf(stderr, "\n %s:%d: expected \"%s\", got \"%s\"\n", \
__FILE__, __LINE__, \
_e ? _e : "(null)", _a ? _a : "(null)"); \
return 1; \
} \
} while (0)
#endif /* MINI_TEST_H *//* file: test_clamp.c — uses mini_test.h */
#include "mini_test.h"
int clamp(int x, int lo, int hi) {
if (x < lo) {
return lo;
}
if (x > hi) {
return hi;
}
return x;
}
static int test_clamp_inside(void) {
EXPECT_EQ_INT(clamp(5, 0, 10), 5);
return 0;
}
static int test_clamp_below(void) {
EXPECT_EQ_INT(clamp(-3, 0, 10), 0);
return 0;
}
static int test_clamp_above(void) {
EXPECT_EQ_INT(clamp(99, 0, 10), 10);
return 0;
}
int main(void) {
TEST_BEGIN();
RUN_TEST(test_clamp_inside);
RUN_TEST(test_clamp_below);
RUN_TEST(test_clamp_above);
TEST_END();
}gcc -std=c17 -Wall -Wextra -o test_clamp test_clamp.c
./test_clampStyle 3: Unity-Style Suite That Actually Builds
Unity is a popular embedded-friendly C unit framework. You can either vendor its sources or use a Unity-compatible subset that builds as a single file for learning.
Option A — Self-contained “unity-like” runner (no download)
/* file: unity_lite.h — small subset of Unity API names */
#ifndef UNITY_LITE_H
#define UNITY_LITE_H
#include <stdio.h>
#include <string.h>
static int Unity_tests;
static int Unity_failures;
static const char *Unity_current;
#define UNITY_BEGIN() \
do { Unity_tests = 0; Unity_failures = 0; printf("Unity-lite\n"); } while (0)
#define UNITY_END() \
do { \
printf("------------\n%d Tests %d Failures\n", \
Unity_tests, Unity_failures); \
return Unity_failures ? 1 : 0; \
} while (0)
#define RUN_TEST(fn) \
do { \
Unity_tests++; \
Unity_current = #fn; \
printf("TEST(%s)\n", Unity_current); \
fn(); \
} while (0)
#define TEST_ASSERT_EQUAL_INT(expected, actual) \
do { \
int _e = (int)(expected); \
int _a = (int)(actual); \
if (_e != _a) { \
Unity_failures++; \
printf(" FAIL: %s:%d expected %d was %d\n", \
__FILE__, __LINE__, _e, _a); \
} \
} while (0)
#define TEST_ASSERT_TRUE(cond) \
do { \
if (!(cond)) { \
Unity_failures++; \
printf(" FAIL: %s:%d %s is false\n", \
__FILE__, __LINE__, #cond); \
} \
} while (0)
#define TEST_ASSERT_EQUAL_STRING(expected, actual) \
do { \
const char *_e = (expected); \
const char *_a = (actual); \
if (_e == NULL || _a == NULL || strcmp(_e, _a) != 0) { \
Unity_failures++; \
printf(" FAIL: %s:%d expected \"%s\" was \"%s\"\n", \
__FILE__, __LINE__, \
_e ? _e : "(null)", _a ? _a : "(null)"); \
} \
} while (0)
#endif/* file: test_leap_unity.c */
#include "unity_lite.h"
/* Production code under test */
int is_leap_year(int year) {
if (year % 400 == 0) {
return 1;
}
if (year % 100 == 0) {
return 0;
}
if (year % 4 == 0) {
return 1;
}
return 0;
}
void test_common_year(void) {
TEST_ASSERT_EQUAL_INT(0, is_leap_year(2019));
}
void test_leap_divisible_by_4(void) {
TEST_ASSERT_EQUAL_INT(1, is_leap_year(2020));
}
void test_century_not_leap(void) {
TEST_ASSERT_EQUAL_INT(0, is_leap_year(1900));
}
void test_400_year_leap(void) {
TEST_ASSERT_EQUAL_INT(1, is_leap_year(2000));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_common_year);
RUN_TEST(test_leap_divisible_by_4);
RUN_TEST(test_century_not_leap);
RUN_TEST(test_400_year_leap);
return UNITY_END();
}gcc -std=c17 -Wall -Wextra -o test_leap test_leap_unity.c
./test_leapOption B — Real Unity (vendored)
If you clone Unity into third_party/Unity:
# Example layout
# third_party/Unity/src/unity.c
# third_party/Unity/src/unity.h
# third_party/Unity/src/unity_internals.h
gcc -std=c17 -Wall -Wextra \
-I third_party/Unity/src \
-o test_leap \
test_leap_unity_real.c third_party/Unity/src/unity.cReal Unity uses the same names (UNITY_BEGIN, RUN_TEST, TEST_ASSERT_EQUAL_INT, UNITY_END). Your tests stay almost identical to Option A.
Test-Driven Development (TDD) Walkthrough: is_leap_year()
TDD cycle: Red → Green → Refactor.
We implement Gregorian leap-year rules:
- Divisible by 400 → leap
- Else divisible by 100 → not leap
- Else divisible by 4 → leap
- Else → not leap
Step 0 — Scaffold (no real logic yet)
/* leap.c + test_leap_tdd.c evolution */
int is_leap_year(int year) {
(void)year;
return 0; /* deliberately wrong / incomplete */
}Step 1 — RED: first failing test
/* test_leap_tdd.c */
#include "unity_lite.h"
int is_leap_year(int year); /* or #include "leap.h" */
void test_2020_is_leap(void) {
TEST_ASSERT_EQUAL_INT(1, is_leap_year(2020));
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_2020_is_leap);
return UNITY_END();
}gcc -std=c17 -Wall -Wextra -o test_leap_tdd test_leap_tdd.c leap.c
./test_leap_tdd
# FAIL: expected 1 was 0Step 2 — GREEN: minimal code that passes
/* leap.c — first green */
int is_leap_year(int year) {
return year % 4 == 0;
}./test_leap_tdd
# OK for 2020Step 3 — RED: century rule
void test_1900_not_leap(void) {
TEST_ASSERT_EQUAL_INT(0, is_leap_year(1900));
}
/* main: also RUN_TEST(test_1900_not_leap); */./test_leap_tdd
# FAIL for 1900 (1900 % 4 == 0 with naive rule)Step 4 — GREEN: handle centuries
int is_leap_year(int year) {
if (year % 100 == 0) {
return 0;
}
return year % 4 == 0;
}Step 5 — RED: year 2000 must be leap
void test_2000_is_leap(void) {
TEST_ASSERT_EQUAL_INT(1, is_leap_year(2000));
}Step 6 — GREEN: full Gregorian rule
int is_leap_year(int year) {
if (year % 400 == 0) {
return 1;
}
if (year % 100 == 0) {
return 0;
}
if (year % 4 == 0) {
return 1;
}
return 0;
}Step 7 — REFACTOR: keep tests green
int is_leap_year(int year) {
return (year % 400 == 0) || (year % 4 == 0 && year % 100 != 0);
}Re-run the suite after every refactor. All four (or more) tests should still pass.
Alternate TDD target: clamp()
Same cycle with fewer domain rules:
| Phase | Test idea | Implementation |
|---|---|---|
| Red | clamp(5,0,10)==5 |
return 0; fails |
| Green | return x if in range |
return x; (too simple) |
| Red | clamp(-1,0,10)==0 |
fails |
| Green | lower bound | if (x < lo) return lo; return x; |
| Red | clamp(99,0,10)==10 |
fails |
| Green | upper bound | full clamp |
| Refactor | assert lo <= hi or document |
keep tests green |
Boundary Value Testing
Bugs cluster at edges. Design cases for just inside, on, and just outside valid ranges.
Integers
#include "unity_lite.h"
#include <limits.h>
int clamp(int x, int lo, int hi) {
if (x < lo) return lo;
if (x > hi) return hi;
return x;
}
void test_clamp_boundaries(void) {
/* lo / hi themselves */
TEST_ASSERT_EQUAL_INT(0, clamp(0, 0, 10));
TEST_ASSERT_EQUAL_INT(10, clamp(10, 0, 10));
/* just outside */
TEST_ASSERT_EQUAL_INT(0, clamp(-1, 0, 10));
TEST_ASSERT_EQUAL_INT(10, clamp(11, 0, 10));
/* extreme ints */
TEST_ASSERT_EQUAL_INT(INT_MIN, clamp(INT_MIN, INT_MIN, INT_MAX));
TEST_ASSERT_EQUAL_INT(INT_MAX, clamp(INT_MAX, INT_MIN, INT_MAX));
TEST_ASSERT_EQUAL_INT(0, clamp(INT_MIN, 0, 10));
TEST_ASSERT_EQUAL_INT(10, clamp(INT_MAX, 0, 10));
}Strings
#include <string.h>
#include <stddef.h>
/* Returns length, or (size_t)-1 on NULL; never walks past maxn */
size_t safe_strnlen(const char *s, size_t maxn) {
size_t i;
if (s == NULL) {
return (size_t)-1;
}
for (i = 0; i < maxn; i++) {
if (s[i] == '\0') {
return i;
}
}
return maxn;
}
void test_safe_strnlen_boundaries(void) {
TEST_ASSERT_EQUAL_INT((int)(size_t)-1, (int)safe_strnlen(NULL, 10));
TEST_ASSERT_EQUAL_INT(0, (int)safe_strnlen("", 10));
TEST_ASSERT_EQUAL_INT(0, (int)safe_strnlen("abc", 0));
TEST_ASSERT_EQUAL_INT(3, (int)safe_strnlen("abc", 3));
TEST_ASSERT_EQUAL_INT(3, (int)safe_strnlen("abc", 100));
TEST_ASSERT_EQUAL_INT(3, (int)safe_strnlen("abcdef", 3)); /* capped */
}Boundary checklist for strings
NULL
- empty
""
- length 1
- exact buffer size
- longer than limit
- non-ASCII / embedded
\0if relevant
Table-Driven Tests in C
Encode many cases in an array of structs. One loop runs them all — easy to extend.
/* file: test_leap_table.c */
#include <stdio.h>
int is_leap_year(int year) {
return (year % 400 == 0) || (year % 4 == 0 && year % 100 != 0);
}
struct leap_case {
int year;
int expected; /* 1 leap, 0 not */
const char *note;
};
static const struct leap_case cases[] = {
{2019, 0, "common"},
{2020, 1, "div by 4"},
{1900, 0, "century"},
{2000, 1, "div by 400"},
{1600, 1, "div by 400"},
{2100, 0, "century"},
{1, 0, "year 1"},
{4, 1, "year 4"},
};
int main(void) {
int failed = 0;
size_t i;
size_t n = sizeof cases / sizeof cases[0];
for (i = 0; i < n; i++) {
int got = is_leap_year(cases[i].year);
if (got != cases[i].expected) {
fprintf(stderr, "FAIL year=%d (%s): expected %d got %d\n",
cases[i].year, cases[i].note,
cases[i].expected, got);
failed++;
} else {
printf("OK year=%d (%s)\n", cases[i].year, cases[i].note);
}
}
printf("%zu cases, %d failed\n", n, failed);
return failed ? 1 : 0;
}gcc -std=c17 -Wall -Wextra -o test_leap_table test_leap_table.c
./test_leap_tableTable-driven style also works for clamp, parsers, and error-code matrices.
Code Coverage with gcov / lcov
Coverage shows which lines ran during tests — not that logic is correct, but that untested paths exist.
Instrument and run
# Compile with coverage (debug-friendly)
gcc -std=c17 -Wall -Wextra -g --coverage \
-o test_leap test_leap_unity.c
./test_leap
# Per-file summary
gcov test_leap_unity.c
# creates test_leap_unity.c.gcov — open in an editor
# Optional pretty HTML report (install lcov)
lcov --capture --directory . --output-file coverage.info
genhtml coverage.info --output-directory coverage_html
# open coverage_html/index.htmlTips
- Clean between runs:
rm -f *.gcda *.gcno *.gcov coverage.info
- Link with
--coverage(or-lgcovon some toolchains) when splitting objects
- Aim for high branch coverage on pure logic; 100% line coverage ≠ bug-free
Example .gcov line prefixes:
#####:— never executed
1:— executed once
branch 0 taken 0%— branch never taken (needs another case)
Sanitizers: Address and Undefined Behavior
GCC/Clang sanitizers catch classes of bugs tests alone may miss.
AddressSanitizer (ASan)
Detects heap/stack buffer overflows, use-after-free, some leaks.
/* file: bug_overflow.c */
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = malloc(4 * sizeof(int));
if (!p) {
return 1;
}
p[4] = 42; /* one past the end — undefined behavior */
printf("%d\n", p[0]);
free(p);
return 0;
}gcc -std=c17 -Wall -Wextra -g -fsanitize=address -o bug_overflow bug_overflow.c
./bug_overflow
# ASan prints stack of the bad write and the allocation siteUndefinedBehaviorSanitizer (UBSan)
/* file: bug_ub.c */
#include <limits.h>
#include <stdio.h>
int main(void) {
int x = INT_MAX;
x = x + 1; /* signed overflow — UB */
printf("%d\n", x);
return 0;
}gcc -std=c17 -Wall -Wextra -g -fsanitize=undefined -o bug_ub bug_ub.c
./bug_ubCombined (common for tests)
gcc -std=c17 -Wall -Wextra -g \
-fsanitize=address,undefined \
-o test_leap test_leap_unity.c
./test_leapCI idea: run the unit suite under ASan+UBSan on every commit.
ThreadSanitizer (-fsanitize=thread) is for races; requires pthreads and is slower.
Integration and System Testing (brief)
- Integration: link
module_a.o+module_b.oand exercise the boundary API.
- System: run the finished CLI with shell scripts:
#!/usr/bin/env bash
set -euo pipefail
./myprog --input sample.txt > out.txt
diff -u expected.txt out.txt- Regression: keep golden files under
tests/fixtures/and fail the pipeline on diff.
Black-Box vs White-Box (quick map)
| Approach | You know | Techniques |
|---|---|---|
| Black box | Spec only | Equivalence classes, boundaries, decision tables |
| White box | Source | Statement/branch/path coverage, data-flow |
| Gray box | Partial | Spec + known module structure |
In unit tests you usually do white/gray box; acceptance tests stay closer to black box.
FIRST and AAA
- FIRST: Fast, Independent, Repeatable, Self-validating, Timely
- AAA: Arrange (setup) → Act (call SUT) → Assert (check)
void test_clamp_above(void) {
/* Arrange */
int x = 99, lo = 0, hi = 10;
/* Act */
int got = clamp(x, lo, hi);
/* Assert */
TEST_ASSERT_EQUAL_INT(10, got);
}Best Practices
- Keep production code free of test-only
#ifdefnoise when possible — separate*_test.cfiles.
- Prefer pure functions for hard logic (easy to table-test).
- One clear reason to fail per test (or clearly named multi-assert tests).
- Run tests and sanitizers before every push.
- Treat flaky tests as bugs (timing, order, shared files).
- Do not commit coverage HTML; generate it in CI artifacts.
Exercises
Exercise 1 — Custom harness for clamp
Implement int clamp(int x, int lo, int hi) and a mini_test.h suite covering inside, below, above, and INT_MIN/INT_MAX. Build with:
gcc -std=c17 -Wall -Wextra -o test_clamp test_clamp.cExercise 2 — TDD int max3(int a, int b, int c)
Write failing tests first, implement, then refactor. Include cases where the max is in the first, second, and third argument, and all equal.
Exercise 3 — Table-driven string trim
Write void trim_inplace(char *s) that removes leading/trailing ASCII spaces. Drive tests from a table of {input, expected} pairs (use a writable buffer per case). Include empty string, all spaces, no spaces, and mixed.
Exercise 4 — Coverage gap hunt
Take is_leap_year and deliberately omit the year-2000 test. Run gcov and show which branch is under-tested. Add the missing case and re-check.
Exercise 5 — Sanitizer lab
Write a small program with a deliberate heap overflow. Confirm it “works” without sanitizers (undefined — may print garbage or crash). Rebuild with -fsanitize=address and capture the report. Fix the bug and re-run clean.
Exercise 6 — Boundary design
For a function int parse_port(const char *s, int *out) that accepts "1"…"65535", list at least ten boundary/equivalence cases (NULL, "", "0", "1", "65535", "65536", leading zeros, non-digits, etc.) and implement tests.
Summary
| Technique | When to use |
|---|---|
assert |
Tiny checks, throwaway scripts |
Custom TEST macros |
Portable unit suites with no deps |
| Unity-style runner | Familiar API / embeddable projects |
| TDD | New pure logic, clear contracts |
| Table-driven tests | Many similar cases |
| Boundary analysis | Ranges, sizes, parsers |
gcov/lcov |
Find untested lines/branches |
| ASan/UBSan | Memory safety and undefined behavior |
Testing in C is mostly discipline and tooling, not language magic. A small harness plus tables, boundaries, coverage, and sanitizers will catch most defects before they leave your machine.