← Software Craft
Guided lesson 6 questions · 6 languages

Assertions, tests, and counterexamples

Write down what a function should do, then look for the input that proves it wrong.

A test is a call plus an expected value taken from what the function should do. Passing tests show only that those inputs work; one input that gives a wrong result, a counterexample, shows that the function does not do what you believed. Look for it at the edges and in inputs the code was not designed around.

Before you begin

Why this matters

A function can pass every check you wrote and still be wrong for an input you did not think of. Write down what the function should do before you check it. Then look for the input that proves it wrong, and you find the bug before someone else does.

What this helps you reason about

  • Write a check whose expected value comes from what the function should do, not from what it does.
  • Choose the inputs where code is most often wrong: empty, one, equal, first and last, zero, and negative.
  • Tell a check that could fail from one that always passes.

A team app shows each team’s worst game of a season. The function behind it has three tests, and all three pass. This lesson asks what those passing tests prove, and how to find the input that proves the function wrong.

Already know this? The short version
  • Write down what the function should return, and for which inputs, before you choose inputs; every expected value comes from that sentence, worked out by hand, never from running the code.
  • Passing checks prove that sentence only for their inputs; one input that gives a wrong result disproves it, so try inputs the code was not designed around.
  • Try the edges first: empty, one element, equal values, first and last place, zero, negative, and no negatives at all.
  • A check with no comparison, or whose expected value comes from the code it checks, cannot fail.
  • assert from assert.h prints the failed condition and calls abort; defining NDEBUG turns every assert into ((void)0), so never put needed work inside one, and return an error code for bad input.

Go to the final check· Go to the practice tickets

Words in this lesson (7)
test
Code that calls a function with a chosen input, compares the result with an expected value worked out in advance, and reports pass or fail.
counterexample
One input for which a belief about a function is false. A single counterexample is enough to show that the belief is wrong.
regression test
A check, kept with the code, that fails while a bug is present and passes once it is fixed, so the bug cannot come back unnoticed.
boundary
An input at the edge of what a function handles, such as an empty list, a single element, equal values, the first or last element, zero, or a negative number.
assertion
A check written into the program that a condition is true at that point. assert(condition) from assert.h stops the program with a message when the condition is false, and does nothing when NDEBUG is defined.
precondition
Something that must be true when a function is called, such as “the list is not empty”. The code that calls the function is responsible for it.
macro
A name that the preprocessor replaces with text before the program is compiled. #define NDEBUG defines one, and assert from assert.h is one.

Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.

01 / The belief

What do you believe the function does?

A test checks a belief about a function, so write the belief down first. The belief is one sentence that says what the function returns, and for which inputs. Then write the tests. A test is a call with a chosen input, plus the value that the belief says the call should return, called the expected value. Work out each expected value from the belief, by hand, before you run the code. The test then compares two things made separately: what the code returns and what the belief says.

For each game, the app stores a score: the team’s points minus the other team’s points. So -1 means the team lost by one point, 0 means a draw, and 2 means the team won by two points. The belief: lowest returns the smallest score in a list that has at least one score. The tests use a small function, check(name, got, want), where got is the value lowest returned and want is the expected value. It prints pass when got equals want, and FAIL with both values when they differ. Its full code is in the experiment of the next section. check returns nothing, so it is declared void. count is the number of scores in the array.

main.c
/* lowest should return the smallest score of a list
   that has at least one score (count > 0). */
int lowest(const int scores[], int count) {
    int low = 0;
    for (int i = 0; i < count; i++) {
        if (scores[i] < low) {
            low = scores[i];
        }
    }
    return low;
}

/* Each line prints pass. */
check("mixed", lowest(mixed, 3), -1);
check("a draw", lowest(draw, 2), 0);
check("one game", lowest(one, 1), -2);

Write down your belief and each expected value before you run the code.

Precisely: in a real project

C has no standard test framework. Projects use a library such as Unity or Check, or a plain test program: a main that calls the code and asserts each result, built from the same source files as the program. A Makefile target named test builds and runs it, so make test runs every test. Build the test program without -DNDEBUG, or its asserts disappear.

This lesson uses a plain check function instead, because the stepper runs one file with no test tool.

main.c
/* test_lowest.c, built with lowest.c and run by make test */
#include <assert.h>

int lowest(const int scores[], int count);

int main(void) {
    int wins[] = {2, 1};
    assert(lowest(wins, 2) == 1);
    int mixed[] = {1, -1, 2};
    assert(lowest(mixed, 3) == -1);
    return 0;
}

02 / Counterexamples

Which input would prove the belief wrong?

Passing tests show that the belief holds for their inputs. They say nothing about other inputs. To learn more, look for a counterexample: one input for which the belief is false. One counterexample is enough to show that a belief is wrong, however many tests pass.

Look first at inputs the code was not designed around. Every list in the tests has a game the team lost or drew. A strong team can win every game.

Here lowest for {2, 1} returns 0, a draw the team never played, so {2, 1} is a counterexample. low starts at 0, and when every score is above 0, no score is below low. Keep the counterexample as a test. After the repair it passes, and it stays as a regression test, the kind of check that Minimal reproductions ends with.

main.c
/* The belief gives 1. */
int wins[] = {2, 1};
check("all wins", lowest(wins, 2), 1);
/* FAIL all wins: got 0, want 1 */

Passing tests show that the belief holds for their inputs; one counterexample shows that it is false.

Precisely: letting a tool find counterexamples

Property-based testing states a belief as a property, such as “the result is one of the scores and no score is lower”. A tool then generates hundreds of inputs and looks for a counterexample. When it finds one, it shrinks the input, as in Minimal reproductions, before reporting it. For C, fuzzers such as libFuzzer, built into clang, generate inputs looking for crashes; combined with assert, they look for counterexamples too.

Experiment

Add a team that won every game

low starts at 0, not at a score from the list. A score replaces low only when it is below low. When every score is above 0, nothing replaces it, and lowest returns 0: a draw the team never played. Each of the three tests had a score at or below 0, where starting at 0 does no harm.

Why it matters Passing tests show that the belief holds for their inputs. To find a bug, try an input the code was not designed around, and keep the counterexample as a test after the repair.

All three tests print pass. Predict whether lowest is right for every list. Then add int wins[] = {2, 1}; and then check("all wins", lowest(wins, 2), 1); after the last check and run again.

#include <stdio.h>

int lowest(const int scores[], int count) {
    int low = 0;
    for (int i = 0; i < count; i++) {
        if (scores[i] < low) {
            low = scores[i];
        }
    }
    return low;
}

void check(const char *name, int got, int want) {
    if (got == want) {
        printf("pass %s\n", name);
    } else {
        printf("FAIL %s: got %d, want %d\n", name, got, want);
    }
}

int main(void) {
    /* print each line at once, even under the stepper */
    setvbuf(stdout, NULL, _IOLBF, 0);
    int mixed[] = {1, -1, 2};
    int draw[] = {0, 3};
    int one[] = {-2};
    check("mixed", lowest(mixed, 3), -1);
    check("a draw", lowest(draw, 2), 0);
    check("one game", lowest(one, 1), -2);
    return 0;
}
What to look for

Step into lowest for the first test. scores shows an address and the value stored there, 1: it points to the first score of mixed. low is 0 and stays 0 while i is 0, because 1 is not below 0; it becomes -1 when i is 1. After the edit, in the fourth call the stepper stops on the if line with i = 0 and i = 1, never on low = scores[i], and low stays 0. The check frame shows got = 0 and want = 1, and name as an address followed by its text, "all wins". The last line of output is FAIL all wins: got 0, want 1.

Expected output

pass mixed
pass a draw
pass one game

03 / Boundaries

Where do counterexamples usually hide?

Counterexamples are most often found at boundaries: inputs at the edge of what the code handles. Try these first: an empty input; a single element; equal values; the answer in the first place and in the last; zero; negative numbers, and no negative numbers at all. A loop that starts or stops one place too early, or a starting value such as 0, is wrong at a boundary and right everywhere else.

For each boundary, work out the expected value from the belief before you run it. The belief may say nothing about an input. This belief says nothing about an empty list. That is a finding too: decide what should happen, and add the decision to the belief.

The repair starts low at the first score, scores[0]. The table in this section runs the repaired lowest on one input for each boundary. C has no empty array, so the empty list is a count of 0.

main.c
int lowest(const int scores[], int count) {
    int low = scores[0];
    /* ... the loop as before */
    return low;
}

Test the competing explanations

Predict first. These are recorded outcomes of the example, not tests running in your browser.

  1. 01One game: [3]

    The belief gives 3.

    Reveal the observation

    Observed 3: pass.

    What this rules in or out With one score, the start value is the answer, and the loop finds nothing lower.

  2. 02Equal scores: [1, 1]

    The belief gives 1.

    Reveal the observation

    Observed 1: pass.

    What this rules in or out A tie for the lowest score gives that score once.

  3. 03Lowest score last: [2, 0, -1]

    The belief gives -1.

    Reveal the observation

    Observed -1: pass.

    What this rules in or out The loop reaches the last score.

  4. 04Every game won: [2, 1]

    The belief gives 1.

    Reveal the observation

    Observed 1: pass.

    What this rules in or out The start value now comes from the list, not from 0.

  5. 05No games: an empty list

    The belief covers lists with at least one score, so it predicts nothing.

    Reveal the observation

    Observed lowest returns 5, the value stored in scores[0], although the count of scores is 0. Nothing stops the program.

    What this rules in or out Decide whether an empty list is a mistake in the calling code or an input to handle. The assertions section takes this up.

Try the edges first: empty, one, equal, first and last, zero, and negative.

Precisely: reading scores[0] with count 0

The table passed an array holding 5 with a count of 0, so scores[0] was a real element and lowest returned it. If the array really had no elements, reading scores[0] would be undefined behavior: the program may crash, print a wrong value, or seem to work.

04 / Tests that check nothing

Can a test pass without checking anything?

A test that cannot fail still prints pass. It tells you nothing. Three kinds are common.

The first kind has no comparison. It calls the function and prints the result. Nothing in it can fail.

The second kind gets its expected value from the code under test. It calls the same function again, or it repeats the same formula. So it compares the code with itself.

The third kind has an expected value copied from the output of one run. It checks that the output does not change. If that output was wrong, the test keeps the bug.

The rule from the first section prevents all three: the expected value comes from the belief, worked out without the code. To check a new test, break the code on purpose. For example, make the function return 0 at once, and run the test again. If the test still passes, it does not test that code.

The test below gets want from lowest itself. The experiment runs it on the counterexample from the earlier section.

main.c
int wins[] = {2, 1};
int want = lowest(wins, 2);
int got = lowest(wins, 2);
/* got == want, whatever lowest returns */

A test must be able to fail: its expected value comes from the belief, not from the code.

Precisely: breaking the code on purpose

Mutation testing tools automate the check in this section. They make many small changes to the code, such as < to <=, run the tests after each, and report every change that no test noticed. For C, Mull, built on clang, is such a tool.

Experiment

Take the expected value from the function itself

want and got are both results of lowest for the same list. Whatever lowest returns, the two are equal, so the comparison is always true. This test passes with the wrong lowest, and it would pass with a correct one. It cannot tell them apart.

Why it matters Work out the expected value from the belief, by hand. Then a wrong function gives a different value, and the test can fail.

You know that lowest for {2, 1} is wrong. Predict what this test prints. Then replace int want = lowest(wins, 2); with int want = 1; and run again.

#include <stdio.h>

int lowest(const int scores[], int count) {
    int low = 0;
    for (int i = 0; i < count; i++) {
        if (scores[i] < low) {
            low = scores[i];
        }
    }
    return low;
}

int main(void) {
    /* print each line at once, even under the stepper */
    setvbuf(stdout, NULL, _IOLBF, 0);
    int wins[] = {2, 1};
    int want = lowest(wins, 2);
    int got = lowest(wins, 2);
    const char *verdict = "pass";
    if (got != want) {
        verdict = "FAIL";
    }
    printf("%s: got %d, want %d\n", verdict, got, want);
    return 0;
}
What to look for

After the two calls to lowest, main’s frame shows wins = {2, 1}, want = 0, and got = 0, and the output is pass: got 0, want 0. After the edit, want is 1, got is still 0, and the output is FAIL: got 0, want 1.

Expected output

pass: got 0, want 0

05 / Assertions

When should the program stop itself?

A test calls the code from outside, with inputs you chose. An assertion sits inside the code. It states that a condition is true at that point, every time the program reaches it. When the condition is false, the code itself is wrong, and the program should stop with a message.

Use assertions for programmer mistakes. A common one is a broken precondition: something that must be true when a function is called, such as “the list has at least one score”. The code that calls the function is responsible for it. Do not use assertions for problems that come from outside the program, such as a missing file, a score typed as text, or an empty list sent by another program. Those can happen when every line of the code is right. Handle them as errors and tell the user.

In C, assert(condition) comes from assert.h. It is a macro: a name the preprocessor replaces with code before compiling. When the condition is false, assert prints the file, the line, the function, and the condition to standard error, and stops the program with abort. When NDEBUG is defined before assert.h is included, every assert is replaced with ((void)0), an expression that does nothing, and the condition is not evaluated. Many release builds define NDEBUG, for example by passing -DNDEBUG to the compiler. For bad input, return an error code and let the caller decide.

main.c
#include <assert.h>

int lowest(const int scores[], int count) {
    assert(count > 0);
    int low = scores[0];
    /* ... the loop as before */
    return low;
}

Assert what only a programmer mistake can break, and build your tests without NDEBUG. Return an error code for everything else.

Coming from Python: python -O removes assert statements much as NDEBUG removes C’s asserts; in both, an assert is not a check that must always run.

Coming from Java: Java’s asserts are off unless the JVM starts with -ea; C’s are on unless NDEBUG is defined when the program is compiled.

Precisely: work inside assert

Because NDEBUG removes the whole condition, assert(fclose(file) == 0) closes the file only in builds that keep asserts. Do the work first, store the result, and assert on the stored value. assert.h checks NDEBUG again each time it is included, so one file can switch asserts on and off in different parts.

C11 and C17 also have static_assert, a macro from assert.h that expands to the keyword _Static_assert. The compiler checks it while compiling, and NDEBUG does not affect it. In C23, static_assert is itself a keyword.

Experiment

Switch the assert back on

assert is a macro: before compiling, the preprocessor replaces it with code. With NDEBUG defined, it is replaced with ((void)0), an expression that does nothing, so total > 0 is never checked. Without NDEBUG, a false condition prints the message to standard error and calls abort, which stops the program at once.

Why it matters An assert protects only the builds that keep it. Build and test without NDEBUG, and never put work the program needs inside an assert, because a release build may remove it.

The first line defines NDEBUG. Predict whether the assert stops the program for game 3, whose length is 0. Then delete the line #define NDEBUG and run again.

#define NDEBUG
#include <assert.h>
#include <stdio.h>

int minutes_left(int played, int total) {
    assert(total > 0);
    return total - played;
}

int main(void) {
    /* print each line at once, even under the stepper */
    setvbuf(stdout, NULL, _IOLBF, 0);
    /* each game's length in minutes; game 3's was left at 0 */
    int totals[] = {40, 60, 0};
    printf("%d\n", minutes_left(30, totals[2]));
    return 0;
}
What to look for

The stepper goes from the call in main straight to return total - played: it never stops on the assert line, and the output is -30. After the edit, it stops on the assert line, now line 5, with total = 0. The run ends with Program stopped with SIGABRT. and program: main.c:5: minutes_left: Assertion `total > 0' failed. Nothing is printed.

Expected output

-30

06 / Try it

Can you find the counterexample?

Predict the output, then look for a counterexample before you reveal the answer. A team’s score sheet shows the top scorer of a game, by position in the team list. The belief is the comment at the top of the program.

main.c
#include <stdio.h>

/* top_scorer should return the position of the highest score;
   on a tie, the earlier player's position. */
int top_scorer(const int points[], int count) {
    int top = 0;
    for (int i = 1; i < count; i++) {
        if (points[i] >= points[top]) {
            top = i;
        }
    }
    return top;
}

void check(const char *name, int got, int want) {
    if (got == want) {
        printf("pass %s\n", name);
    } else {
        printf("FAIL %s: got %d, want %d\n", name, got, want);
    }
}

int main(void) {
    /* print each line at once, even under the stepper */
    setvbuf(stdout, NULL, _IOLBF, 0);
    int second[] = {8, 12, 5};
    int first[] = {20, 3};
    int alone[] = {4};
    check("second", top_scorer(second, 3), 1);
    check("first", top_scorer(first, 2), 0);
    check("alone", top_scorer(alone, 1), 0);
    return 0;
}

What does this print, which input is a counterexample to the belief, and which boundary does it sit on?

Reveal the reasoning

It prints pass second, pass first, and pass alone. The belief is still false: for {9, 9}, top_scorer returns 1 where the belief gives 0. The counterexample sits on the equal-values boundary: >= moves top to a later player with the same score. A tie below the top, such as {3, 3, 8}, would not fail, because 8 wins alone. The repair is > in place of >=, and the tie stays as a regression test.

One more boundary: with a count of 0, top_scorer returns 0, the position of a player who does not exist. The belief needs a decision for that case too.

Practice tickets

  1. TEST-101 · Long games never finish on the score screen → Free
  2. TEST-102 · A relay team with one swimmer entered twice was accepted → Sign in · Pro
  3. TEST-103 · A screen that shows the wrong lift wait passes the checker → Sign in · Pro

Keep exploring

Configuration precedence →

Primary references