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
- Add a test that a task with an empty title fails validation and one with a valid title does not.
- Use a temporary directory with the test's name for the storage file and remove the directory at the end.
- 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.
-
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.
-
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.
-
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.
-
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.
-
What does the storage test print?
Answer
2 passed
The round trip keeps both tasks and the first title.
-
What does the format test print?
Answer
2 passed
Both formatted lines match the expected text.
-
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.
-
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.
-
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.
-
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