Test Targets
Part 12 of 16 — GNU Make Mastery Series. A build system that can't verify the software it builds is incomplete. In this part, we embed a full quality toolchain — unit tests, coverage reports, and memory/concurrency sanitizers — directly into the Makefile.
1
Build Systems Foundations
Why Make, compilation pipeline, basics
2
Targets, Prerequisites & Execution
Rule anatomy, PHONY, build workflows
3
Variables, Expansion & Scope
= vs :=, ?=, +=, CLI overrides
4
Automatic Variables & Pattern Rules
$@, $<, $^, $?, %.o: %.c patterns
5
Built-in Functions & Make Language
subst, wildcard, foreach, $(shell)
6
Conditionals & Configurable Builds
ifeq, ifdef, platform detection, flags
7
Automatic Dependency Generation
-M, -MM, -MD, .d files, header deps
8
Compilation Workflow & Libraries
Static/shared libs, ar, -fPIC, SONAME
9
Project Architecture & Multi-Directory
Recursive make, include, out-of-source
10
Cross-Compilation & Toolchains
Toolchain prefixes, sysroots, embedded
11
Parallel Builds & Performance
make -j, jobserver, race conditions
12
Testing, Coverage & Debug Tooling
Test targets, gcov/lcov, sanitizers
You Are Here
13
Make as Automation & DevOps Tool
Task runner, Docker, install, packaging
14
CI/CD Integration
Deterministic builds, GitHub Actions, cache
15
Advanced Make & Debugging
--debug, dynamic rules, evaluation traps
16
Ecosystem, Alternatives & Mastery
CMake, Ninja, Meson, Bazel, production
Basic test / check Target
CC := gcc
CFLAGS := -Wall -Wextra -g # -g enables debug info for gdb/sanitizers
SRCS := $(wildcard src/*.c)
OBJS := $(patsubst src/%.c,build/%.o,$(SRCS))
# Test sources and binaries live in test/
TEST_SRCS := $(wildcard test/*.c)
TEST_BINS := $(patsubst test/%.c,build/test_%,$(TEST_SRCS))
.PHONY: test check
test check: $(TEST_BINS)
@echo "Running test suite..."
@PASS=0; FAIL=0; \ # shell vars ($$=escape $ from Make)
for t in $^; do \ # $^ = all test binary paths
if $$t; then PASS=$$((PASS+1)); echo " [PASS] $$t"; \
else FAIL=$$((FAIL+1)); echo " [FAIL] $$t"; fi; \
done; \
echo "Results: $$PASS passed, $$FAIL failed"; \
[ $$FAIL -eq 0 ] # exit 1 if any test failed → Make sees error
# Link each test binary against the project objects
build/test_%: test/%.c $(OBJS) | build # | build = order-only (create dir first)
$(CC) $(CFLAGS) $^ -o $@ # $^ = test source + all object files
build:
mkdir -p $@
Per-test Targets
# Run a single test by name: make run-test_parser
# Useful for focused debugging during development
run-%: build/%
@echo "--- Running $< ---"
$<
Code Coverage
gcov Basics
gcov is GCC's built-in coverage tool. You enable it at compile time with --coverage (or -fprofile-arcs -ftest-coverage). When the instrumented binary runs, it writes .gcda data files beside the source. gcov reads the data and the .gcno notes files to produce annotated .c.gcov files.
# Coverage-enabled build flags
COV_FLAGS := --coverage -fprofile-arcs -ftest-coverage
build/cov/%.o: src/%.c | build/cov
$(CC) $(CFLAGS) $(COV_FLAGS) -c $< -o $@
build/cov:
mkdir -p $@
lcov HTML Reports
# After building with --coverage and running tests:
lcov --capture --directory build/cov --output-file coverage.info
lcov --remove coverage.info '/usr/*' '*/test/*' --output-file coverage.info
genhtml coverage.info --output-directory coverage-html
# Open coverage-html/index.html in browser
Coverage Makefile Target
COV_SRCS := $(wildcard src/*.c)
COV_OBJS := $(patsubst src/%.c,build/cov/%.o,$(COV_SRCS))
COV_TESTS := $(patsubst test/%.c,build/cov/test_%,$(wildcard test/*.c))
.PHONY: coverage cov-clean
coverage: cov-clean $(COV_TESTS)
@echo "Running instrumented tests..."
@for t in $(COV_TESTS); do $$t || true; done
lcov --capture --directory build/cov --output-file coverage.info --quiet
lcov --remove coverage.info '/usr/*' '*/test/*' \
--output-file coverage.info --quiet
genhtml coverage.info --output-directory coverage-html --quiet
@echo "Coverage report: coverage-html/index.html"
build/cov/test_%: test/%.c $(COV_OBJS) | build/cov
$(CC) $(CFLAGS) --coverage $^ -o $@
build/cov/%.o: src/%.c | build/cov
$(CC) $(CFLAGS) --coverage -c $< -o $@
cov-clean:
rm -rf build/cov coverage.info coverage-html
Sanitizer Builds
AddressSanitizer (ASan)
# ── AddressSanitizer flags ──
# -fsanitize=address : instrument heap/stack/global buffer overflows
# -fno-omit-frame-pointer : keep frame pointers for readable stack traces
# -g : debug symbols for file:line info in reports
ASAN_FLAGS := -fsanitize=address -fno-omit-frame-pointer -g
ASAN_OBJS := $(patsubst src/%.c,build/asan/%.o,$(SRCS))
ASAN_TESTS := $(patsubst test/%.c,build/asan/test_%,$(wildcard test/*.c))
build/asan/%.o: src/%.c | build/asan
$(CC) $(CFLAGS) $(ASAN_FLAGS) -c $< -o $@
build/asan/test_%: test/%.c $(ASAN_OBJS) | build/asan
$(CC) $(CFLAGS) $(ASAN_FLAGS) $^ -o $@
build/asan:
mkdir -p $@
.PHONY: asan
asan: $(ASAN_TESTS)
@for t in $^; do echo "ASan: $$t"; ASAN_OPTIONS=abort_on_error=1 $$t; done
UndefinedBehaviorSanitizer (UBSan)
# ── UndefinedBehaviorSanitizer flags ──
# Catches signed overflow, null deref, misaligned access, type confusion, etc.
UBSAN_FLAGS := -fsanitize=undefined -fno-omit-frame-pointer -g
UBSAN_OBJS := $(patsubst src/%.c,build/ubsan/%.o,$(SRCS))
UBSAN_TESTS := $(patsubst test/%.c,build/ubsan/test_%,$(wildcard test/*.c))
build/ubsan/%.o: src/%.c | build/ubsan
$(CC) $(CFLAGS) $(UBSAN_FLAGS) -c $< -o $@
build/ubsan/test_%: test/%.c $(UBSAN_OBJS) | build/ubsan
$(CC) $(CFLAGS) $(UBSAN_FLAGS) $^ -o $@
build/ubsan:
mkdir -p $@
.PHONY: ubsan
ubsan: $(UBSAN_TESTS)
@for t in $^; do echo "UBSan: $$t"; UBSAN_OPTIONS=halt_on_error=1 $$t; done
ThreadSanitizer (TSan)
# TSan is incompatible with ASan — must be a separate build variant
# -fPIE -pie : position-independent executable (required by TSan on some platforms)
TSAN_FLAGS := -fsanitize=thread -fno-omit-frame-pointer -g -fPIE -pie
TSAN_OBJS := $(patsubst src/%.c,build/tsan/%.o,$(SRCS))
TSAN_TESTS := $(patsubst test/%.c,build/tsan/test_%,$(wildcard test/*.c))
build/tsan/%.o: src/%.c | build/tsan
$(CC) $(CFLAGS) $(TSAN_FLAGS) -c $< -o $@
build/tsan/test_%: test/%.c $(TSAN_OBJS) | build/tsan
$(CC) $(CFLAGS) $(TSAN_FLAGS) $^ -o $@
build/tsan:
mkdir -p $@
.PHONY: tsan
tsan: $(TSAN_TESTS)
@for t in $^; do echo "TSan: $$t"; TSAN_OPTIONS=halt_on_error=1 $$t; done
Named Sanitizer Pattern (DRY)
# Generate sanitizer targets dynamically to avoid code duplication.
# $(1) = sanitizer name (asan, ubsan, tsan)
# $(2) = compiler flags for that sanitizer
#
# Double-dollar ($$) delays expansion until $(eval) processes the text.
# Inside define blocks, Make expands $(...) immediately but $$(...)
# is deferred — necessary so $(SRCS) etc. aren't empty at define time.
define MAKE_SANITIZER_RULES
SAN_$(1)_FLAGS := $(2)
SAN_$(1)_OBJS := $$(patsubst src/%.c,build/$(1)/%.o,$$(SRCS))
SAN_$(1)_TESTS := $$(patsubst test/%.c,build/$(1)/test_%,$$(wildcard test/*.c))
build/$(1)/%.o: src/%.c | build/$(1)
$$(CC) $$(CFLAGS) $(2) -c $$< -o $$@ # $$< = first prerequisite (source file)
build/$(1)/test_%: test/%.c $$(SAN_$(1)_OBJS) | build/$(1)
$$(CC) $$(CFLAGS) $(2) $$^ -o $$@ # $$^ = all prerequisites
build/$(1):
mkdir -p $$@
.PHONY: $(1)
$(1): $$(SAN_$(1)_TESTS)
@for t in $$^; do echo "$(1): $$$$t"; $$$$t; done # $$$$ = literal $$ in shell
endef
# Instantiate the template for each sanitizer:
$(eval $(call MAKE_SANITIZER_RULES,asan,-fsanitize=address -g))
$(eval $(call MAKE_SANITIZER_RULES,ubsan,-fsanitize=undefined -g))
Unified make check
# master quality gate — runs all checks in sequence
.PHONY: check
check: test asan ubsan coverage
@echo "========================================="
@echo " All quality checks passed!"
@echo "========================================="
CI Recommendation: Run make check as the CI gate target. Local developers can skip slow checks with make test (fast, no instrumentation) and run make coverage or make asan only when needed.
Valgrind Integration
VALGRIND := valgrind
VGFLAGS := --leak-check=full \ # report every leaked allocation
--error-exitcode=1 \ # exit 1 on memory errors (→ Make fails)
--quiet # suppress info banners, show errors only
.PHONY: valgrind
valgrind: $(TEST_BINS)
@for t in $^; do \
echo "Valgrind: $$t"; \
$(VALGRIND) $(VGFLAGS) $$t; \
done
Note: Prefer ASan/UBSan over Valgrind for regular CI — they're compiled into the binary, run at near-native speed, and catch more bug categories than Valgrind alone. Use Valgrind when you need heap profiling (--tool=massif) or when targeting platforms without compiler sanitizer support.
Try It — Test & Sanitizer Build
Create a mini project with a test target and AddressSanitizer integration:
mkdir -p src tests
cat > src/math_ops.c << 'EOF'
int add(int a, int b) { return a + b; }
int multiply(int a, int b) { return a * b; }
EOF
cat > src/math_ops.h << 'EOF'
#ifndef MATH_OPS_H
#define MATH_OPS_H
int add(int a, int b);
int multiply(int a, int b);
#endif
EOF
cat > tests/test_math.c << 'EOF'
#include <stdio.h>
#include <assert.h>
#include "math_ops.h"
int main(void) {
assert(add(2, 3) == 5);
assert(multiply(4, 5) == 20);
printf("All tests passed\n");
return 0;
}
EOF
cat > Makefile << 'EOF'
CC := gcc
CFLAGS := -Wall -Wextra -Isrc
SRCS := src/math_ops.c
OBJS := $(SRCS:.c=.o)
TEST_BIN := build/test_math
.PHONY: all test asan clean info
all: $(OBJS)
test: $(TEST_BIN)
@echo "Running tests..."
./$(TEST_BIN)
$(TEST_BIN): tests/test_math.c $(SRCS)
@mkdir -p build
$(CC) $(CFLAGS) $^ -o $@
asan: CFLAGS += -fsanitize=address,undefined -g
asan: LDFLAGS += -fsanitize=address,undefined
asan:
@mkdir -p build
$(CC) $(CFLAGS) tests/test_math.c $(SRCS) $(LDFLAGS) -o build/test_math_asan
./build/test_math_asan
clean:
rm -rf build $(OBJS)
info:
@echo "CC = $(CC)"
@echo "CFLAGS = $(CFLAGS)"
@echo "SRCS = $(SRCS)"
@echo "TEST_BIN = $(TEST_BIN)"
EOF
make info
make test
make asan # runs tests under AddressSanitizer
make clean
Next in the Series
In Part 13: Make as Automation & DevOps Tool, we go beyond compiling C code: use Make as a general-purpose task runner, integrate Docker builds, write install/uninstall targets, and leverage .ONESHELL for reproducible scripted workflows.
Continue the Series
Part 13: Make as Automation & DevOps Tool
Task runner, Docker integration, make install, reproducible builds, and .ONESHELL for scripted workflows.
Read Article
Part 11: Parallel Builds & Performance
Maximize throughput with make -j$(nproc), the jobserver protocol, and techniques for detecting parallel race conditions.
Read Article
Part 14: CI/CD Integration
CI-friendly targets, deterministic builds, GitHub Actions / GitLab CI YAML examples, and build caching strategies.
Read Article