Makefiles & Automated Testing
Automating compilation with make — rules, dependencies, variables, and writing test scripts.
Why bother with make?
Think of a Makefile as a recipe book for your project. Each recipe (rule) says: "to make this dish (target), you need these ingredients (dependencies), and here are the steps (commands)." The chef (make) is smart: if an ingredient hasn't changed since you last cooked, it skips that step. Cooking only what is necessary saves enormous time on large projects.
In Python and Java you typically run one command to compile or interpret a file. When you write a Python script you type python myfile.py; in Java you type javac MyClass.java. For tiny programs this is fine. But C projects often consist of dozens of .c files. Recompiling every file every time you change one line wastes minutes on large codebases.
make solves this by tracking which source files have changed (by comparing timestamps) and only recompiling those. It also standardises exactly how your project is built, so anyone who clones your repo can reproduce your build with one command: make.
The COMP2017 assignments all include a Makefile. The marker runs make to build your submission — if your Makefile is broken, nothing compiles and you lose marks.
# Python: just run the file
python main.py
# Java: compile then run
javac Main.java
java Main
# No incremental rebuild —
# everything reprocessed every time
# Build everything (first time)
make
# Only recompile changed .c files
make # incremental
# Remove generated files
make clean
# Run automated tests
make test
The recipe line in a Makefile rule must start with a real TAB character, not spaces. This is the single most common Makefile mistake. If you copy-paste from a website or your editor converts tabs to spaces, make will print Makefile:5: *** missing separator. Stop. Check with cat -A Makefile — you should see ^I (tab) before each recipe line.
A Makefile is a directed graph. Each target depends on zero or more files. make walks the graph from the requested target downward, rebuilding any node whose file is older than its dependencies. If nothing changed, nothing is rebuilt. If you ask for a target that doesn't exist as a file (a phony target), it always runs.
Rules, variables, automatic variables, and pattern rules
Basic rule structure
target: dependency1 dependency2 recipe_command # <-- MUST be a TAB, not spaces # Example: program: main.o utils.o gcc -o program main.o utils.o
clean).
dependencies — files that must exist and be up-to-date first.
recipe — shell command(s) to produce the target; tab-indented.
Variables
CC := gcc # compiler CFLAGS := -Wall -Wextra -g # flags TARGET := program # Use with $(VAR_NAME) program: main.o utils.o $(CC) $(CFLAGS) -o $(TARGET) main.o utils.o
CC once to switch compilers.
Automatic variables
| Variable | Meaning | Example use |
|---|---|---|
$@ | The target name | gcc -o $@ ... |
$< | The first dependency | gcc -c $< |
$^ | All dependencies (space-separated) | gcc -o $@ $^ |
$* | The stem (matched by %) | echo $* |
Pattern rules
# Generic rule: compile any .c file into a .o file %.o: %.c $(CC) $(CFLAGS) -c $< # ^^ first dep = the .c file # make finds main.o is needed → looks for main.c → applies rule
%.o: %.c means "to build anything.o, compile anything.c". This eliminates writing one rule per source file.
Phony targets
.PHONY: all clean test clean: rm -f program *.o test: program ./run_tests.sh
clean exists, make clean would do nothing (the "file" already exists and has no deps). Declaring phony ensures the recipe always runs.
Important compiler flags
| Flag | Purpose |
|---|---|
-Wall | Enable common warnings (uninitialized vars, unused vars, etc.) |
-Wextra | Extra warnings beyond -Wall |
-Werror | Treat all warnings as errors — forces clean code |
-g | Include debug symbols (required for gdb, valgrind) |
-O2 | Optimise for speed (release builds) |
-std=c11 | Use the C11 standard |
-fsanitize=address | AddressSanitizer: detect buffer overflows and memory errors at runtime |
Complete Makefiles you can use
CC := gcc
CFLAGS := -Wall -Wextra -g -std=c11
TARGET := hello
# Default target: build the executable
all: $(TARGET)
$(TARGET): hello.c
$(CC) $(CFLAGS) -o $@ $^
.PHONY: clean
clean:
rm -f $(TARGET)
make clean # removes hello
CC := gcc
CFLAGS := -Wall -Wextra -Werror -g -std=c11
TARGET := program
SRCS := main.c utils.c parser.c
OBJS := $(SRCS:.c=.o) # replace .c with .o in SRCS list
all: $(TARGET)
# Link all object files into the final executable
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) -o $@ $^
# Generic pattern rule: compile any .c to .o
%.o: %.c
$(CC) $(CFLAGS) -c $<
.PHONY: clean test
clean:
rm -f $(TARGET) $(OBJS)
test: $(TARGET)
./test_script.sh
main.o and parser.o are NOT recompiled — timestamps unchanged.
#!/bin/bash
# test_script.sh — run program and compare output to expected
PASS=0
FAIL=0
run_test() {
local test_name="$1"
local input_file="$2"
local expected_file="$3"
# Run program with input, capture output
actual=$(./program < "$input_file" 2>/dev/null)
expected=$(cat "$expected_file")
if [ "$actual" = "$expected" ]; then
echo "PASS: $test_name"
PASS=$((PASS + 1))
else
echo "FAIL: $test_name"
echo " Expected: $expected"
echo " Got: $actual"
FAIL=$((FAIL + 1))
fi
}
run_test "basic_input" tests/input1.txt tests/expected1.txt
run_test "empty_input" tests/input2.txt tests/expected2.txt
run_test "large_input" tests/input3.txt tests/expected3.txt
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ $FAIL -eq 0 ] # exit 0 on success, nonzero on failure
PASS: empty_input
FAIL: large_input
Expected: 42
Got: 41
Results: 2 passed, 1 failed
Practice problems with solutions
You have two source files: main.c (which includes math_utils.h) and math_utils.c. Write a Makefile that: compiles both with -Wall -g, links them into an executable called calc, and has a clean target. Use variables for CC and CFLAGS.
CC := gcc
CFLAGS := -Wall -g
all: calc
calc: main.o math_utils.o
$(CC) $(CFLAGS) -o $@ $^
main.o: main.c math_utils.h
$(CC) $(CFLAGS) -c $<
math_utils.o: math_utils.c math_utils.h
$(CC) $(CFLAGS) -c $<
.PHONY: clean
clean:
rm -f calc main.o math_utils.o
calc depends on both .o files. Each .o lists both the .c file and the header — if math_utils.h changes, both .o files are rebuilt. $@ expands to the target name. $^ expands to all dependencies.
This Makefile produces the error Makefile:4: *** missing separator. Stop.. Why? Fix it.
CC := gcc
program: main.c
gcc -o program main.c
CC := gcc
program: main.c
gcc -o program main.c
\t). Many text editors silently convert tabs to spaces. Fix: replace the leading spaces with a single tab character. In vim: :set noexpandtab then use Tab key.
Extend this Makefile to add: (a) a debug target that compiles with -fsanitize=address, and (b) a test target that builds the program and runs ./run_tests.sh.
CC := gcc
CFLAGS := -Wall -g
program: main.c
$(CC) $(CFLAGS) -o $@ $^
CC := gcc
CFLAGS := -Wall -g
all: program
program: main.c
$(CC) $(CFLAGS) -o $@ $^
debug: main.c
$(CC) $(CFLAGS) -fsanitize=address -o program_asan $^
.PHONY: all debug clean test
test: program
./run_tests.sh
clean:
rm -f program program_asan
-fsanitize=address instruments the binary to detect out-of-bounds accesses, use-after-free, and stack buffer overflows. It adds runtime overhead (about 2x slower), so keep it as a separate debug target rather than in your default build. The test target depends on program — make will build the program first if it doesn't exist.
You have a Makefile with targets: program (depends on main.o utils.o), main.o (depends on main.c utils.h), utils.o (depends on utils.c utils.h). All files exist and were last built 1 hour ago. You then edit only utils.h. When you run make, which targets are rebuilt and in what order?
main.o — because utils.h (a dependency) is newer than main.o. 2. utils.o — because utils.h is newer than utils.o. 3. program — because both .o files are now newer than program.Why this matters: Header files often declare structs and function signatures used across many
.c files. If a header changes, every .o that includes it must be recompiled to pick up the new declarations. This is why dependencies list both .c files and the headers they include.
cmocka — C unit testing framework
cmocka is a lightweight C unit testing framework — the C equivalent of JUnit (Java) or pytest (Python). You write test functions, each verifying one small behaviour, and cmocka runs them all and reports pass/fail.
| Language | Framework | Assert example |
|---|---|---|
| Python | pytest | assert result == expected |
| Java | JUnit | assertEquals(expected, result) |
| C | cmocka | assert_int_equal(result, expected) |
#include order matters: <stdarg.h>, <stddef.h>, <setjmp.h> MUST come before <cmocka.h>. Getting this wrong causes cryptic compile errors that have nothing to do with your actual test code.
/* IMPORTANT: these three includes must come before cmocka.h */
#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>
/* Test function — must have this exact signature */
static void test_add(void **state) {
(void)state; /* unused */
assert_int_equal(add(2, 3), 5);
assert_int_equal(add(-1, 1), 0);
assert_int_equal(add(0, 0), 0);
}
int main(void) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_add),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
Key assert macros
| Macro | Purpose |
|---|---|
assert_int_equal(a, b) | integers equal |
assert_string_equal(a, b) | strings equal (like strcmp==0) |
assert_non_null(ptr) | pointer is not NULL |
assert_null(ptr) | pointer is NULL |
assert_true(cond) | condition is true |
assert_false(cond) | condition is false |
assert_memory_equal(a, b, n) | n bytes equal |
my_strlen
cmocka practice
Write a cmocka test suite for int my_strlen(const char *s). Test: empty string returns 0, single char returns 1, normal string returns correct length.
#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>
static void test_my_strlen(void **state) {
(void)state;
assert_int_equal(my_strlen(""), 0);
assert_int_equal(my_strlen("a"), 1);
assert_int_equal(my_strlen("hello"), 5);
}
int main(void) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_my_strlen),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}
static void name(void **state). Cast state to void to suppress unused-parameter warnings. Each assert_int_equal call checks one specific case — empty string, single character, and a multi-character string. Register with cmocka_unit_test() and run with cmocka_run_group_tests().
Key concepts to memorize
Test your understanding
$@ expand to?LO7.PHONY ensures its recipe always runs, even if a file with that name exists.LO7%.o: %.c mean?LO7utils.c and run make. Which files does make rebuild (assuming a standard multi-file Makefile)?LO7