Back to Technology

GNU Make Mastery Part 12: Testing, Coverage & Debug Tooling

February 27, 2026 Wasil Zafar 20 min read

Add test, coverage, and sanitize targets to your Makefile. Integrate gcov/lcov for HTML coverage reports, wire in AddressSanitizer, UndefinedBehaviorSanitizer, and ThreadSanitizer, and run the full quality suite with a single make check.

Table of Contents

  1. Test Targets
  2. Code Coverage
  3. Sanitizer Builds
  4. Unified make check
  5. Valgrind Integration
  6. Next Steps

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.

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.

Technology