102 Build and test — how a project is put together by many hands
What to know first
make and gitLooking back
chapter 18 wrote a single Makefile and every example in this book has run from it. So why do real projects put so much into the build — if it compiles, is that not enough?
A. Because “it compiles” and “there is a build” are different statements. Compiling is source becoming machine code this once; a build is a promise about when what gets remade. With three files you need no such promise — remake the lot. With several hundred, remaking the lot costs minutes, and then people stop remaking. From that moment nobody knows whether the executable in hand came from the source in hand.
★ The first demonstration in this chapter is the accident that happens right there. And it shows up not as “slow and annoying” but as a buffer overflow.
The need for this chapter, and its context
make and git. This chapter puts scale on top of them. So far the book has assumed one person’s desk, but things built in C are mostly built by several people over a long time. How to split the files, what to test, what to let the machine refuse — these are not matters of taste but structure that scale forces on you.By the end of this chapter
make and CMake syntax shifts from release to release, and what this chapter means to leave behind is not syntax but why things are laid out as they are. The grounds are four real projects.The questions this chapter answers
- Are more tests always better?
102.1 What changes when there is a build#
A build tool takes three jobs off your hands.
| What | What happens without it |
|---|---|
| Incremental | remake only what changed. Without it a full rebuild takes minutes and people skip remaking |
| Dependency | know what must be remade when something changes. Without it stale artefacts quietly survive |
| Reproducible | the same source yields the same thing. Without it “it works on my machine” becomes an argument |
Table 102.1 — The jobs a build takes on
The middle one is this section’s subject. What happens without it is worth causing on purpose.
examples-en/ch102/stale/run.sh
#!/bin/sh
# Reproduce the stale-artifact accident.
#
# Widen GREET_MAX from 16 to 32 in the header and touch only greet.c.
# The rule with no dependencies rebuilds greet.o alone: greet now writes
# trusting 32 while main.o still reserves the old 16. The buffer overflows.
set -eu
cd "$(dirname "$0")"
for kind in broken fixed; do
printf '== Makefile.%s\n' "$kind"
make -s -f "Makefile.$kind" clean >/dev/null
make -s -f "Makefile.$kind" CFLAGS="-std=c23 -Wall -Wextra -g -fsanitize=address$([ $kind = fixed ] && printf ' -MMD -MP')" >/dev/null 2>&1
printf ' first : %s\n' "$(./demo 2>&1 | head -1)"
sed -i 's/#define GREET_MAX 16/#define GREET_MAX 32/' greet.h
touch greet.c
make -s -f "Makefile.$kind" CFLAGS="-std=c23 -Wall -Wextra -g -fsanitize=address$([ $kind = fixed ] && printf ' -MMD -MP')" >/dev/null 2>&1
out=$(./demo 2>&1 | head -3 || true)
printf ' after widening : %s\n' "$(printf '%s' "$out" | head -1)"
printf '%s' "$out" | grep -q 'stack-buffer-overflow' && printf ' → ASan: stack-buffer-overflow\n' || true
sed -i 's/#define GREET_MAX 32/#define GREET_MAX 16/' greet.h
make -s -f "Makefile.$kind" clean >/dev/null
done
Output
== Makefile.broken
first : GREET_MAX=16 len=15
after widening : =================================================================
→ ASan: stack-buffer-overflow
== Makefile.fixed
first : GREET_MAX=16 len=15
after widening : GREET_MAX=32 len=31
Take apart what happened. The size of the container lives in the header.
#define GREET_MAX 16
void greet(char *out);greet.c fills that many, and main.c reserves that many. Both files trust the same number. And yet the rule in Makefile.broken says only this.
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<Nowhere in it does it say “this object file also depends on greet.h”. So widen the header from 16 to 32 and touch only greet.c, and make rebuilds greet.o alone. The new greet writes 31 characters and the closing NUL — 32 bytes — while the old main.o still reserves a 16-byte container.
★ The result is not slowness but a stack buffer overflow. The demonstration catches it on the spot under the sanitizer (chapter 18). Uncaught, this program would have become the kind that “sometimes gives odd values”.
A common misconception. “Then why not always make clean?”
It does work. And it is precisely throwing the incremental part away. With three files the cost is zero; with several hundred it is minutes every time, and those minutes add up until people skip clean as well. Leave discipline to human will and scale defeats it.
The answer is not to write the dependencies by hand. The compiler already knows — which headers were opened is settled while compiling, so have it write that down.
CFLAGS = ... -MMD -MP
-include main.d greet.d-MMD leaves, for each object file, a rule saying “this file depends on these headers” in a .d file, and -include feeds it into the next build. The The Makefile.fixed of the demonstration merely adds those two words to CFLAGS and one -include line, and the same manoeuvre causes no accident.
102.2 How files are divided — the header is the contract, the source the circumstances#
Files are not divided because one grew long. The criterion is keep together what changes together, and separate what changes separately. Split what changes together and every fix walks across several files; join what changes separately and one side keeps forcing the other to be rebuilt.
On top of that sits one circumstance peculiar to C — the header is the contract and the source the circumstances (chapter 53). What stands in the header is the promise the caller leans on; what stands in the source is how that promise is kept. The how may change; change the promise and other people break.
| What | Where | Why |
|---|---|---|
| prototypes, public types, public macros | header | promises the caller must know |
| internal helpers | static in the source | not promises. The name does not leak either (chapter 56) |
| the innards of a struct | the source, where possible | expose the members and the layout becomes contract — unchangeable thereafter |
| global variables | avoid them | nobody can tell where the thing is changed (chapter 58) |
definitions of static functions | not in a header | every including file gets its own copy — but see below |
Table 102.2 — What belongs in a header and what does not
★ That last row has one widely used exception: writing a very short function in a header as static inline (from C99 on). Copies are still made, but the compiler usually folds them in and an unused one raises no warning. Even so, the body of that function is now visible in the header, so anything still likely to change belongs in the source.
★ The third row is the most expensive at scale. Write a struct’s members into the header and that layout becomes a public promise, so adding one member becomes rebuilding every caller. Large projects therefore often publish the name and hide the innards.
/* header: a name, and no innards */
typedef struct config config;
config *config_open(const char *path);
int config_get_int(const config *c, const char *key, int fallback);
void config_close(config *);The caller does not know how many bytes config is. So it need not know, and does not break when it changes. The price is that it cannot be used as a value, only through a pointer.
102.3 The boundary between public and internal#
As a project grows its headers fall into two kinds — those put outside and those used only within. The best-known case of pinning that boundary down through file layout is the Linux kernel. It split the declarations that go out to user space into include/uapi/, with the inner headers including those. Two reasons are given for the split — reducing the tangle between headers, and making what is a public promise plain to the eye.
In practice. What happens with no boundary
The program runs perfectly well with no boundary written down. The trouble comes later.
Someone includes an internal header and starts calling an internal function. That function was circumstance, not contract, so its name and arguments were things that could change — and now it has a user. The next person who wants to fix it has no ground to say “but this is internal”, because it is written nowhere.
So it is better to draw the boundary with directories. Splitting into include/ and src/ is far stronger than a comment saying “internal use”. People do not read comments, but a path is visible, and a build rule can enforce it.
102.4 Folder layout — what the conventions are for#
The names differ a little from project to project but the kinds are much the same. The point is not a list to memorise but what each one is for.
| Place | What is in it | Why it is kept apart |
|---|---|---|
include/ | public headers | what is here is the promise — the boundary is visible as a path |
src/ | sources and internal headers | circumstances with circumstances |
tests/ | tests and their data | kept out of the body so they are easy to leave out when shipping |
build/ | artefacts | ★ never mixed with source. It must be deletable whole and easy to keep out of version control |
docs/ | documents | they mean something only when versioned with the code |
scripts/ | what the machine does | procedures that used to rest on human memory, frozen into files |
Table 102.3 — Common folder kinds and their reasons
★ Keeping build/ apart repays more than it looks. With artefacts scattered beside the source, nobody can say “which configuration made this .o”, and you cannot build under two configurations at once. Separate the place and build/debug and build/release can live side by side.
102.5 Kinds of test — what each one catches#
“Writing a test” is not one activity. Each kind catches a different accident.
| Kind | What it catches | Value and price |
|---|---|---|
| unit | one function breaking its contract — boundary values, failure paths | fast and run often. But it cannot see where the parts mesh |
| integration | the mismatch that appears once parts are joined | close to the real thing. Slow, and hard to locate when it breaks |
| regression, golden | that “it used to come out like this” has changed — a defect that came back is a regression | catches something else changing besides what you meant to fix |
| fuzz | inputs nobody thought of — overflow, null, broken formats | sweeps outside human imagination. It only means something alongside a sanitizer |
Table 102.4 — Kinds of test and what each catches
This book has already shown two of them. The sanitizer of chapter 18 is the partner of fuzzing, and the “failing shell” of chapter 92 is the technique of making a unit test deliberately walk the path that rarely runs.
102.6 Golden tests — freezing the answer in a file#
Of the kinds, golden repays most as things grow, because any program with output can have one almost for free. Keep the input in a file, freeze the output you have checked to be right as an answer file, and from then on the machine compares with diff.
PostgreSQL uses this on a large scale. Under src/test/regress/ it keeps sql/ for the inputs, expected/ for the answers and results/ where this run’s output lands, and leaves the mismatches in regression.diffs. The tool doing the comparison is nothing special: it is diff.
The same thing can be stood up in a dozen or so lines.
examples-en/ch102/golden/run.sh
#!/bin/sh
# A golden test - freeze the right answer in a file and let the machine compare.
# Feed in/X.txt, then diff what comes out against expected/X.txt.
# --accept freezes the current result as the answer (the first time, and after
set -eu
cd "$(dirname "$0")"
cc -std=c23 -Wall -Wextra -o wordcount wordcount.c
mkdir -p results
fail=0 n=0
for input in in/*.txt; do
name=$(basename "$input" .txt)
./wordcount < "$input" > "results/$name.txt"
n=$((n + 1))
if [ "${1:-}" = "--accept" ]; then
cp "results/$name.txt" "expected/$name.txt"
continue
fi
if ! diff -u "expected/$name.txt" "results/$name.txt" > "results/$name.diff"; then
printf ' ✗ %s\n' "$name"
sed -n '3,6p' "results/$name.diff" | sed 's/^/ /'
fail=$((fail + 1))
else
printf ' ✓ %s\n' "$name"
rm -f "results/$name.diff"
fi
done
[ "${1:-}" = "--accept" ] && { printf 'froze %d answers\n' "$n"; exit 0; }
[ "$fail" -eq 0 ] && printf '%d golden tests passed\n' "$n" || { printf '%d mismatched\n' "$fail"; exit 1; }
Output
✓ empty
✓ spaces
✓ two-lines
3 golden tests passed
Inputs and answers frozen like this are called fixtures — the provisions a test stands on. Their properties and traps are taken up separately in chapter 103.
Three things are worth noticing.
- The answer is not written by hand.
--acceptfreezes the current result. Written by hand it comes out wrong, and a wrong answer turns the test backwards. - A failure shows only the lines that differ.
diffalready does that work. - So this test earns its keep even when you do not know “what is right” — because what it asks is has it changed since yesterday.
Counter-example. Freezing an answer without looking at it
--accept is dangerous because it is convenient. Freeze again without looking at why the test broke and you have made the bug the answer. From then on that test guards nothing.
The discipline is one line — press --accept only when you have read the mismatch and can say “this change was intended”. This book’s own example verification runs on the same discipline.
102.7 How much, and what not to test#
Is there an answer to “how much should I test”? There is one case that shows the upper end.
SQLite states the numbers in its own testing document1 — as of 3.42.0 (May 2023), the library itself is about 155.8 KSLOC while the test code and scripts come to 92,053.1 KSLOC, that is 590 times the library. The frames that run the tests — the harnesses — are not one but four (the TCL tests, TH3, the SQL Logic Test and dbsqlfuzz), and it states that the core, in its default configuration, holds 100% branch coverage under TH3 — the qualification is in the original, and it is not “the whole library, always”.
★ What is to be learned here is not “do 590 times”. It is two things.
- Why keep several frames — where one approach shuts its eyes, another looks. Ten tests written from different thinking beat a hundred written from the same.
- The quantity of tests is a result, not a goal — SQLite tests that much because it claims to be “a database that runs anywhere”. A different goal gives a different answer.
And the question on the other side matters more.
Q. Are more tests always better?
A. No. A test is code too, and code is debt. Add one test and the work of maintaining it is added as well. A test that does not earn its keep is a burden.
The ones that do not pay tend to look alike. The test that copies the implementation (it writes down again exactly what the function does, so fixing the implementation always breaks it), the test that tests the language (checking that 1 + 1 is 2), and the test that does not say what went wrong when it breaks (one five-hundred-line integration test).
The criterion for picking the useful ones reduces to a single question — does it tell me what to fix when it breaks?
102.8 Working together — what the machine refuses and what people look at#
Once several people are working, one more question appears. Who stops what has gone wrong?
The answer divides in two. What the machine refuses (a gate) and what people look at (a review). Mix them and both get worse — give a person what a machine can count and the person tires; give a machine what a person must judge and the rule refuses the wrong things.
| The machine refuses | People look at |
|---|---|
| does it build; do the tests run | is this design pointed the right way |
| formatting and naming rules | does the name match the meaning |
| are the sanitizer and static analysis quiet | is this contract natural for the caller |
| do the numbers written in the docs match reality | does this explanation reach a reader |
Table 102.5 — What to hand the machine and what people look at
In practice. This book runs that way
This book’s repository has twenty-three such gates (scripts/check-*.py). A few of them: whether the two editions (Korean and English) point at the same chapters; whether a cited clause of the standard exists at all, down to the paragraph number; whether “this is the last chapter of the part” really is the last; whether an acronym is spelled out where each chapter first uses it.
★ Every one of those is something human eyes cannot hold. In practice, each time a chapter was inserted several statements that pointed at numbers went quietly stale, and each time it was a checker, not a person, that found them. What a gate is worth is not “strictness” but sparing human attention.
And gates have a price. A check that cries wolf teaches people to ignore it, and then the real thing is missed. This repository built one such check and did not ship it — run against the manuscript, all twenty-four of the things it caught were false positives. A gate that cries wolf is worse than none.
102.9 What has to be written down#
Last, the work of making a place for others to come into. Documents earn their keep not by being many but when each answers a different question.
| Document | The question it answers |
|---|---|
| README | what this is, how to build it, how to use it |
| contributing guide | what must be kept for my change to be accepted — the list of gates goes here |
| changelog | what changed since the last release — especially breaking changes |
| design record | why it was settled that way. Code says “what” but cannot say “why” |
Table 102.6 — Which document answers which question
★ The last row is the one most often missing and the most expensive. Months later, the person who wants to undo that decision does not know what was weighed to reach it, and so walks again a road already examined and discarded.
Recap
| What to remember | In brief |
|---|---|
| what a build does | incremental, dependency, reproducible. Without them people skip remaking |
| stale artefacts | write no dependencies and one side alone is rebuilt — the buffer overflows |
| the cure | do not write them by hand — let the compiler with -MMD -MP |
| splitting files | what changes together. Header is contract, source is circumstance |
| a struct’s innards | expose them in the header and the layout becomes contract — hide them where you can |
| folders | draw the boundary as a path. Keep artefacts apart in build/ |
| kinds of test | unit, integration, golden and fuzz each catch a different accident |
| golden | freeze the answer and compare with diff. Read the mismatch before freezing again |
| how much | not quantity but independence. And a test that does not earn its keep is debt |
| working together | what a machine can count becomes a gate; what must be judged becomes a review |
Table 102.7 — Build and test — what to remember
That was what goes where. The next chapter looks at how to work on top of it — the vocabulary and procedure used when several people keep a thing turning, and how to tell whether a check really bites.
Notes
- SQLite. How SQLite Is Tested.
sqlite.org/testing.html— the figures are from that document’s section as of 3.42.0. ↩