Unit testing without a framework

A test is a program that checks another program. No framework is needed for the first one: a macro that prints the line of a failed check, a function per case, and a main that counts the failures and returns a non-zero status when there are any.

That is enough for a library of a few functions, and it is the shape every framework wraps. This lesson builds the harness, runs it, makes it fail on purpose and adds the two things a real suite needs: a test that touches the file system and a table of edge cases.

A CHECK macro

The macro prints the expression and the line when a check fails, and counts the failure. The do-while wrapper makes it a single statement, so it can be used in an if without braces.

The program checks add() twice and prints the number of failures, which is 0. A passing run says nothing except the count, which is the useful part.

The macro is the only place that knows how to report. A real suite adds the file name and the values, and prints them to stderr so that a passing program's output stays clean.

#include <iostream> int failures = 0; #define CHECK(expression) \ do { \ if (!(expression)) { \ std::cout << "FAIL line " << __LINE__ << ": " #expression << '\n'; \ ++failures; \ } \ } while (false) int add(int a, int b) { return a + b; } void test_add() { CHECK(add(2, 3) == 5); CHECK(add(-1, 1) == 0); } int main() { test_add(); std::cout << failures << " failures\n"; return failures == 0 ? 0 : 1; }

g++ -std=c++23 -Wall -Wextra check.cpp -o check
./check
./check; echo "exit $?"

0 failures
0 failures
exit 0

A runner with names

A table of function pointers gives each test a name and lets main run them in order. The runner records the failure count before each test and prints ok or failed next to the name.

The program's two tests both pass, and the runner prints their names. When one fails, the line says which, and the CHECK output above it says where.

This is the smallest useful runner. A framework adds fixtures, ordering, filtering by name and a machine-readable report; the loop over a table is the part every one of them has.

#include <iostream> int failures = 0; #define CHECK(expression) \ do { \ if (!(expression)) { \ std::cout << "FAIL line " << __LINE__ << ": " #expression << '\n'; \ ++failures; \ } \ } while (false) int add(int a, int b) { return a + b; } int half(int value) { return value / 2; } void test_add() { CHECK(add(2, 3) == 5); } void test_half() { CHECK(half(10) == 5); CHECK(half(7) == 3); } struct test_case { const char* name; void (*run)(); }; int main() { test_case tests[]{{"add", test_add}, {"half", test_half}}; for (const auto& test : tests) { int before = failures; test.run(); std::cout << test.name << (failures == before ? " ok" : " failed") << '\n'; } std::cout << failures << " failures\n"; return failures == 0 ? 0 : 1; }

g++ -std=c++23 -Wall -Wextra runner.cpp -o runner
./runner

add ok
half ok
0 failures

A test that fails

A test suite is only useful if a failure is visible and the process reports it. The program below expects the wrong value on purpose, and the two outputs show it: the CHECK line, then the summary and the exit status.

The shell prints the status of the last command, which is 1. A build script or a continuous integration job reads that number, so a failing test stops the pipeline without anyone parsing the text.

The exit status is the contract between the tests and everything that runs them. That is why main returns the number of failures in a small suite, or a plain 1 when there are any.

#include <iostream> int failures = 0; #define CHECK(expression) \ do { \ if (!(expression)) { \ std::cout << "FAIL line " << __LINE__ << ": " #expression << '\n'; \ ++failures; \ } \ } while (false) int add(int a, int b) { return a + b; } void test_add() { CHECK(add(2, 2) == 5); } int main() { test_add(); std::cout << failures << " failures\n"; return failures == 0 ? 0 : 1; }

g++ -std=c++23 -Wall -Wextra failure.cpp -o failure
./failure; echo "exit $?"

FAIL line 20: add(2, 2) == 5
1 failures
exit 1

A test with a file

A test that writes a file has to clean up after itself and must not depend on a file that is already there. The program writes a small file, checks its size with the filesystem of lesson 069, and removes it.

The test checks that the size is 5 and that the file is gone afterwards. The second check is as important as the first: a test that leaves files behind fills the runner's directory and changes the next run.

A suite with several file tests gives each one its own directory, which is the subject of the next lesson. The pattern is the same: create, check, remove, and check that the removal worked.

#include <filesystem> #include <fstream> #include <iostream> namespace fs = std::filesystem; int failures = 0; #define CHECK(expression) \ do { \ if (!(expression)) { \ std::cout << "FAIL line " << __LINE__ << ": " #expression << '\n'; \ ++failures; \ } \ } while (false) void test_file() { { std::ofstream out{"test_output.txt"}; out << "12345"; } CHECK(fs::file_size("test_output.txt") == 5); fs::remove("test_output.txt"); CHECK(!fs::exists("test_output.txt")); } int main() { test_file(); std::cout << failures << " failures\n"; return failures == 0 ? 0 : 1; }

g++ -std=c++23 -Wall -Wextra file.cpp -o file_test
./file_test
ls test_output.txt 2>&1 || echo 'removed'

0 failures
ls: cannot access 'test_output.txt': No such file or directory
removed

A table of edge cases

The interesting tests are the edge cases: empty input, zero, the maximum, the value that is one off. A table of input and expected output is the shortest way to cover them, and it makes a missing case visible.

The program tests a function that clamps a value to a range with five pairs: below, at the low bound, inside, at the high bound and above. The CHECK reports the input when a case fails, because the line alone would not say which row it was.

A table also documents the contract. When the function changes, the table is the specification a reviewer reads first, and a failing row names the input that broke.

#include <iostream> int failures = 0; #define CHECK(expression, input) \ do { \ if (!(expression)) { \ std::cout << "FAIL line " << __LINE__ << " for " << input << '\n'; \ ++failures; \ } \ } while (false) int clamp(int value, int low, int high) { if (value < low) { return low; } if (value > high) { return high; } return value; } int main() { struct case_row { int value; int expected; }; case_row rows[]{{-5, 0}, {0, 0}, {5, 5}, {10, 10}, {15, 10}}; for (const auto& row : rows) { CHECK(clamp(row.value, 0, 10) == row.expected, row.value); } std::cout << failures << " failures\n"; return failures == 0 ? 0 : 1; }

g++ -std=c++23 -Wall -Wextra edge.cpp -o edge
./edge

0 failures

Exercises

  1. Add a test for a function that reverses a string, including the empty string and a palindrome.
  2. Make the CHECK macro print to stderr and run the suite with stdout redirected to a file.
  3. Add a row to the table in edge.cpp where the low bound is greater than the high bound and decide what the function should do.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does a test program need to report?

    Answer

    The failures and a non-zero exit status

    The status is what a build server reads, and the messages say what failed.

  2. What does a CHECK macro add to a hand-written test?

    Answer

    The file and line of the failed check

    The macro captures the location, which is what makes the failure findable.

  3. Why does the runner call each test in its own function?

    Answer

    So a failure names the case and the others still run

    Each case reports its own name and counts its own failures.

  4. What should a test that uses a file do?

    Answer

    Create the file with a unique name and remove it

    The test stays isolated and leaves nothing behind.

  5. What does this program print? #include <iostream> int failures = 0; void check(bool condition, const char* what) { if (!condition) { std::cout << "FAIL " << what << '\n'; ++failures; } } int add(int a, int b) { return a + b; } int main() { check(add(2, 3) == 5, "add(2, 3) == 5"); check(add(2, 2) == 4, "add(2, 2) == 4"); std::cout << failures << " failures"; }

    Answer

    0 failures

    Both checks hold, so only the summary is printed.

  6. What does this program print, and what status does it return? #include <iostream> int failures = 0; void check(bool condition, const char* what) { if (!condition) { std::cout << "FAIL " << what << '\n'; ++failures; } } int add(int a, int b) { return a + b; } int main() { check(add(2, 2) == 5, "add(2, 2) == 5"); std::cout << failures << " failures\n"; return failures == 0 ? 0 : 1; }

    Answer

    FAIL add(2, 2) == 5 / 1 failures / status 1

    The check fails, the summary counts one, and main returns 1.

  7. What does this program print? #include <iostream> #include <string> int checks = 0; void check(bool condition, const std::string& name) { ++checks; std::cout << name << (condition ? " ok\n" : " failed\n"); } int main() { check(2 + 2 == 4, "add"); check(10 / 2 == 5, "half"); std::cout << checks << " checks"; }

    Answer

    add ok / half ok / 2 checks

    Each check prints its name and result, and the counter reports two checks.

  8. What does this program print after it removes its file? #include <cstdio> #include <fstream> #include <iostream> #include <string> int main() { { std::ofstream out{"test_output.txt"}; out << "data"; } std::ifstream in{"test_output.txt"}; std::string text; in >> text; std::cout << (text == "data") << ' '; in.close(); std::remove("test_output.txt"); std::ifstream again{"test_output.txt"}; std::cout << static_cast<bool>(again); }

    Answer

    1 0

    The written text is read back, and the file does not exist after the remove.

  9. How do you test a function that can fail?

    Answer

    Call it with a bad input and check the failure path

    The failure path is code too, and it needs a test that reaches it.

  10. Why does the test runner return the failure count's zero/non-zero rather than the count?

    Answer

    The shell status is a small number, and zero must mean success

    The exit status is read as success or failure, and a count above 255 would wrap.

The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev

Banner image