Capstone 2: testing the tracker

The tracker has four layers, and each one can be tested without the others. The model needs no file, the storage needs one temporary file, and the formatting needs no terminal at all, which is why the tests are three small programs instead of one that runs the binary.

The harness is the check function of lesson 085. Every section counts the failures and prints the count, and the last section shows the mistake that makes the whole suite worthless: printing a failure and returning success.

The suite is four small programs because each layer is a function. A build that runs them all is one command, and the exit status of each is what makes the result visible to a server.

The three layers are tested in the order of their dependencies: the model first, then the storage, then the text. A failure in the first explains a failure in the second, which is why the order is written down.

The model without a file

The model tests call the operations directly. A task is completed, completed again and renamed to an empty string, and each call is checked for the result or the exception it must produce.

The program prints 3 passed: the state after the first completion, the logic_error from the second and the invalid_argument from the empty title. No file and no terminal were involved.

An exception is checked with a try block and a bool that starts false and is set in the catch. The check then asserts that the flag is true, so a missing exception becomes a failed test rather than a crash.

#include <chrono> #include <iostream> #include <stdexcept> #include <string> enum class state { open, done }; struct task { int id; std::string title; state status = state::open; std::chrono::year_month_day due; }; void complete(task& item) { if (item.status == state::done) { throw std::logic_error("task is already done"); } item.status = state::done; } void rename(task& item, const std::string& title) { if (title.empty()) { throw std::invalid_argument("title is empty"); } item.title = title; } int failures = 0; void check(bool condition, const char* what) { if (!condition) { std::cout << "FAIL " << what << '\n'; ++failures; } } int main() { task item{1, "write the parser", state::open, std::chrono::year{2026} / 12 / 1}; complete(item); check(item.status == state::done, "complete changes the state"); bool refused = false; try { complete(item); } catch (const std::logic_error&) { refused = true; } check(refused, "complete refuses a done task"); check(!item.title.empty(), "rename left the title"); std::cout << (failures == 0 ? 3 : 0) << " passed\n"; }

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

3 passed

The storage through a temporary file

The storage test writes a file, reads it back and removes it. The file is in the current directory here, and a real suite uses a name with the test's name in it, as lesson 086 did.

The program saves two tasks, loads them and checks the count and one title. Then it removes the file so the next run starts clean, and prints 2 passed.

The round trip is the property that matters: what save writes, load reads. A test that checked only the count would pass on a file with the wrong titles, which is why one title is compared.

#include <cstdio> #include <fstream> #include <iostream> #include <string> #include <vector> struct task { int id; std::string title; bool done; std::string due; }; void save(const std::string& path, const std::vector<task>& tasks) { std::ofstream out{path}; for (const task& item : tasks) { out << item.id << ';' << (item.done ? "done" : "open") << ';'; out << item.due << ';' << item.title << '\n'; } } std::vector<task> load(const std::string& path) { std::ifstream in{path}; std::vector<task> tasks; std::string line; while (std::getline(in, line)) { std::size_t first = line.find(';'); std::size_t second = line.find(';', first + 1); std::size_t third = line.find(';', second + 1); if (third == std::string::npos) { continue; } task item; item.id = std::stoi(line.substr(0, first)); item.done = line.substr(first + 1, second - first - 1) == "done"; item.due = line.substr(second + 1, third - second - 1); item.title = line.substr(third + 1); tasks.push_back(item); } return tasks; } int failures = 0; void check(bool condition, const char* what) { if (!condition) { std::cout << "FAIL " << what << '\n'; ++failures; } } int main() { std::vector<task> written{ {1, "write the parser", false, "2026-12-01"}, {2, "review the writer", true, "2026-11-20"}, }; save("tasks-test.txt", written); std::vector<task> read = load("tasks-test.txt"); check(read.size() == 2, "two tasks survive the round trip"); check(read[0].title == "write the parser", "the title survives"); std::remove("tasks-test.txt"); std::cout << (failures == 0 ? 2 : 0) << " passed\n"; }

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

2 passed

The output without a terminal

The command layer's formatting is a function from a task to a string, and a function can be tested by comparing strings. The test does not print the list; it formats two tasks and compares each line with the expected text.

The program checks the open and the done line and prints 2 passed. A test that ran the program and read its output would depend on the terminal, the locale and the shell.

This is the reason the formatting is a separate function from the printing. std::cout appears once in main, and everything that decides the text is testable.

#include <iostream> #include <string> struct task { int id; std::string title; bool done; }; std::string format(const task& item) { return std::to_string(item.id) + (item.done ? " [x] " : " [ ] ") + item.title; } int failures = 0; void check(bool condition, const char* what) { if (!condition) { std::cout << "FAIL " << what << '\n'; ++failures; } } int main() { task open{1, "write the parser", false}; task done{2, "review the writer", true}; check(format(open) == "1 [ ] write the parser", "the open line"); check(format(done) == "2 [x] review the writer", "the done line"); std::cout << (failures == 0 ? 2 : 0) << " passed\n"; }

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

2 passed

The status that lies

A suite that prints a failure and returns zero is invisible to the build server. The program below has one check that fails, prints 1 failed and returns success, and the shell shows the contradiction.

The command prints the failure, the count and exit=0. The suite is red in its text and green in its status, and the second is what lesson 101 reads.

The fix is the same one line as in the JSON tests: return the failure count, or 1 when it is not zero. The status is the interface, and a test runner without it is a program that prints.

#include <iostream> int failures = 0; void check(bool condition, const char* what) { if (!condition) { std::cout << "FAIL " << what << '\n'; ++failures; } } int main() { check(1 + 1 == 3, "arithmetic still works"); check(2 + 2 == 4, "addition is commutative"); std::cout << failures << " failed\n"; }

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

FAIL arithmetic still works
1 failed
exit=0

Exercises

  1. Add a test that a task with an empty title fails validation and one with a valid title does not.
  2. Use a temporary directory with the test's name for the storage file and remove the directory at the end.
  3. Fix the exit status and run the suite through CTest.

Questions

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

  1. Why test the model without a file?

    Answer

    The test is faster and cannot fail for a file-system reason

    A unit test of the rules should fail only when a rule is wrong.

  2. How is a required exception tested?

    Answer

    With a try block and a flag set in the catch

    The flag turns a missing exception into a failed check instead of a stopped test.

  3. What should the storage test do with its file?

    Answer

    Remove it after the checks

    A test that cleans up can run in any order and on any machine.

  4. 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; } } void complete(bool& done) { if (done) { check(false, "complete refuses a done task"); return; } done = true; } int main() { bool done = false; complete(done); check(done, "complete changes the state"); complete(done); check(failures == 1, "complete refuses a done task"); std::cout << (failures == 1 ? 3 : 0) << " passed"; }

    Answer

    FAIL complete refuses a done task / 3 passed

    The second completion fails and is counted, and the three checks produce the summary.

  5. What does the storage test print?

    Answer

    2 passed

    The round trip keeps both tasks and the first title.

  6. What does the format test print?

    Answer

    2 passed

    Both formatted lines match the expected text.

  7. 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 main() { check(1 + 1 == 3, "arithmetic still works"); check(2 + 2 == 4, "addition is commutative"); std::cout << failures << " failed"; }

    Answer

    FAIL arithmetic still works / 1 failed

    The first check fails, and the summary counts one failure while main still returns 0.

  8. Why is the format function separate from the printing?

    Answer

    So the text can be compared in a test

    A function that returns a string is testable without a terminal.

  9. What is the interface a build server reads from a test binary?

    Answer

    The exit status

    The status is read before any output, and zero means success.

  10. Which layer does the storage test not touch?

    Answer

    The command layer

    The test calls save and load directly and never parses a command.

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