1. Introduction
When you hear “let's write tests”, a beginner's brain often imagines two horrors: either “we'll have to test absolutely everything” (and live inside tests), or “this is only for big companies, we have a learning project here”. Reality is calmer: tests are a tool, and any tool should be used where it pays off fastest.
The problem is that time and attention are limited. If you start testing “how pretty the console output is” and “the exact number of spaces”, you'll burn out quickly and decide tests are evil. But if you start in the right places, tests begin to work like a seatbelt: you are buckled up and can drive faster without fearing every turn.
The key idea of this lecture: the first tests should protect what breaks most often and is hardest to debug manually. Usually that's not “printing the menu”, but small functions, string parsing and domain rules.
2. Simple selection criteria: “cheap to check” and “expensive to break”
To choose consciously, it's useful to have a simple mental model. A good candidate for a unit test is a piece of code that is easy to call from a test (provide input) and easy to verify (compare the result), and when it breaks it causes unpleasant consequences: incorrect data, wrong rules, weird states.
You don't need complex risk management, just a “common sense table”:
| What to test | Why it's convenient | Typical cost of failure |
|---|---|---|
| Pure functions (input → output) | Deterministic, don't require environment | Incorrect calculations/conditions |
| Parsers (string → structure/number) | Many edge cases, easy to fix with examples | Commands “not recognized”, garbage in data |
| Business logic (rules and operations) | The most “valuable behavior”, often changing | The program “runs” but does the wrong thing |
| Input/output, interactive, “menu” | Hard to check reliably without infrastructure | Usually not the most expensive mistake at the start |
It's also useful to keep a small “testability filter”. If you can answer “yes” to these questions, writing the test is usually easy: the function doesn't read std::cin, doesn't print to std::cout, doesn't depend on time/randomness, the result depends only on parameters, and you can describe the expected result with a couple of examples.
To finally demystify the process, here's a decision diagram:
flowchart TD
A["Piece of logic we want to check"] --> B{"Are there explicit inputs and an explicit result?"}
B -- "no" --> C["Refactor: move logic out of main/IO into a function"]
B -- "yes" --> D{"Depends on IO, time, randomness?"}
D -- "yes" --> E["Separate: keep IO outside, logic inside"]
D -- "no" --> F{"Are there edge cases and regression risk?"}
F -- "no" --> G["Can postpone, but test is still useful"]
F -- "yes" --> H["Write unit tests first"]
Note: even when the answer is “no”, that often means not “don't test” but “refactor the code a bit to make it testable”. That's normal engineering practice, not punishment.
3. Pure functions: the ideal “first step” of testing
A pure function is one that follows the principle “what you give is what you get”. It doesn't touch global variables, doesn't read input, doesn't print, doesn't open files and doesn't try to remember what day of the week it is. In some sense it's the most honest part of the program: easy to understand, easy to verify, and rarely requires a special environment to run.
In our learning app let's agree that we write a simple console program called Tasky — a tiny task manager. Commands will be strings: add ..., done 3, list. Today we're interested in the internal logic, not the interface.
Let's start with a function that checks if a task title is valid. This is a typical pure logic example: input — string, output — bool.
#include <string>
bool is_valid_title(const std::string& title) {
return !title.empty() && title.size() <= 40;
}
Now a minimal “test” can be written using assert. Yes, it’s not yet a test framework, but for a start it’s excellent: fast and clear.
#include <cassert>
#include <string>
bool is_valid_title(const std::string& title);
int main() {
assert(is_valid_title("Read book")); // ok
assert(!is_valid_title("")); // ok
assert(!is_valid_title(std::string(41, 'A'))); // ok
}
What's important: we checked not the “average case” (though that too), but the boundaries. Boundaries break most often: “empty string”, “too long string”, “exactly on the limit”. It's like an elevator: with 3 people everything is fine, but with 10 and one with a bicycle — adventures begin.
Another frequent scenario: “normalizing” text. For example, we want to ignore surrounding spaces: the user inputs " buy milk ", and we store "buy milk". This is also a pure function.
#include <string>
std::string trim_spaces(std::string s) {
while (!s.empty() && s.front() == ' ') s.erase(s.begin());
while (!s.empty() && s.back() == ' ') s.pop_back();
return s;
}
And a test for edge cases (note we check an empty string and a string of only spaces):
#include <cassert>
#include <string>
std::string trim_spaces(std::string s);
int main() {
assert(trim_spaces(" hi ") == "hi"); // ok
assert(trim_spaces("") == ""); // ok
assert(trim_spaces(" ") == ""); // ok
}
Why is this “the first thing to test”? Because you'll call such functions from different places, they are small, and if they are wrong everything else starts to look “weird”. And “weird” is the worst kind of bug: it doesn't crash, it just lives.
4. Parsers: string → data
A parser is where reality in the form of strings tries to become your nice structure. Reality, as we know, doesn't like to be neat: extra spaces, empty lines, done -7, done abc, add without text, add , and all of this will inevitably happen the moment you think you're “done”.
The good news: parsers are almost always deterministic. Given a string → you get a result. So they're convenient to test. Moreover, they are very profitable to test, because manually checking parsers means infinite interactive input, where you type commands each time and get tired faster than the project compiles.
Let's start with a small integer parser: string → std::optional<int>. If it fails — std::nullopt.
#include <charconv>
#include <optional>
#include <string_view>
std::optional<int> parse_int(std::string_view s) {
int value = 0;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
if (ec != std::errc{} || ptr != s.data() + s.size()) return std::nullopt;
return value;
}
Test typical and boundary cases: valid, negative, garbage, empty.
#include <cassert>
#include <optional>
#include <string_view>
std::optional<int> parse_int(std::string_view s);
int main() {
assert(parse_int("42") == std::optional<int>{42}); // ok
assert(parse_int("-7") == std::optional<int>{-7}); // ok
assert(parse_int("7x") == std::nullopt); // ok
assert(parse_int("") == std::nullopt); // ok
}
Now let's build a command parser for Tasky. We don't need std::variant yet, so we'll describe a command with a simple struct + enum class.
#include <optional>
#include <string>
enum class CommandType { Add, Done, List };
struct Command {
CommandType type;
std::string text; // for add
int id = 0; // for done
};
The simplest parser: if the line is list → List, if it starts with add → Add, if done + number → Done. Everything else — “not recognized” (std::nullopt).
#include <optional>
#include <string>
#include <string_view>
std::optional<Command> parse_command(std::string_view line) {
if (line == "list") return Command{CommandType::List, "", 0};
if (line.starts_with("add ")) return Command{CommandType::Add, std::string(line.substr(4)), 0};
if (line.starts_with("done ")) {
auto id = parse_int(line.substr(5));
if (!id) return std::nullopt;
return Command{CommandType::Done, "", *id};
}
return std::nullopt;
}
And now the best part: tests are not “one example”, but a set of typical failures. Parsers practically beg for a table.
| Input | Expect |
|---|---|
|
List |
|
Add with text |
|
Add with empty text (and this is a decision point: business logic may forbid it or the parser may) |
|
Done with id=3 |
|
|
|
|
A mini test (no loops, just to be clear what we're checking):
#include <cassert>
#include <optional>
#include <string_view>
std::optional<Command> parse_command(std::string_view line);
int main() {
assert(parse_command("list").has_value()); // ok
assert(parse_command("done 3")->id == 3); // ok
assert(parse_command("done abc") == std::nullopt); // ok
}
Note an important design point: we deliberately do not mix “command recognition” and “task rules”. The parser answers “what did the user mean syntactically?”, and business logic later decides “can this be executed?”.
5. Business logic: domain rules
“Business logic” sounds like corporate speak, but in a learning project it's simply “the rules of the game”. For example: a task cannot have an empty title, a task must have a unique id, “done” for a non‑existent id is an error, not “well, okay”. This is the behavior that matters to the user and is most likely to break when you change things.
Business logic usually causes the most painful regressions: the program keeps running but does the wrong thing. That's worse than a crash, because a crash at least is honest.
Let's describe the task model:
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
Now — the add rule: you can't add a task with an invalid title. Extract this into a function that doesn't print and returns bool. Then it's easy to test.
#include <vector>
bool add_task(std::vector<Task>& tasks, int id, std::string title) {
title = trim_spaces(title);
if (!is_valid_title(title)) return false;
tasks.push_back(Task{id, title, false});
return true;
}
And a success/failure test:
#include <cassert>
#include <vector>
bool add_task(std::vector<Task>& tasks, int id, std::string title);
int main() {
std::vector<Task> tasks;
assert(add_task(tasks, 1, "Buy milk")); // ok
assert(tasks.size() == 1); // ok
assert(!add_task(tasks, 2, " ")); // ok (after trim becomes empty)
}
Next — the “mark task done” operation. It's convenient to use optional here: if the task is found — change it and return true, otherwise false. Again: no cout, just the contract.
#include <vector>
bool mark_done(std::vector<Task>& tasks, int id) {
for (auto& t : tasks) {
if (t.id == id) { t.done = true; return true; }
}
return false;
}
Test:
#include <cassert>
#include <vector>
bool mark_done(std::vector<Task>& tasks, int id);
int main() {
std::vector<Task> tasks{{1, "A", false}};
assert(mark_done(tasks, 1)); // ok
assert(tasks[0].done); // ok
assert(!mark_done(tasks, 999)); // ok
}
Notice how “human‑readable” these tests are: like a story. Prepare data → do an action → verify the result. You haven't named it AAA yet, but you're already thinking in that style.
And now it's clear what is “profitable to test first”:
Pure functions — because they're building blocks. Parsers — because they have many input variants. Business logic — because that's the meaning of the program. Meanwhile main() and dialog loops are usually better kept thin and not tested from day one.
6. What not to test first
It's tempting (especially after the first success with assert) to start testing everything, including “how nicely the task list is printed”. This is usually a trap: output is fragile, changes with spaces, formatting, locale, and quickly turns tests into “protecting spaces”.
To put it mildly, testing std::cout from scratch is like learning to drive by reversing a truck in a narrow yard. You can do it, but why?
The right strategy: if a part of the program is hard to test, that's often a signal not to “not test it” but to “separate responsibilities”. Let main() read a line, call parse_command, then call add_task/mark_done, and only then print a message to the user. The main behavior is covered by tests, and printing remains a thin wrapper.
This idea can be represented like this:
flowchart LR
A["main(): input/output"] --> B["parse_command(line)"]
B --> C["apply_command(tasks, cmd)"]
C --> D["result/status"]
D --> A
Test B and C first (and the functions inside them), because they provide stable input/output. main() stays minimal and “rarely breaks”.
Quick start tests with assert
Sometimes it seems that “without a test framework tests don't exist”. That's a myth. At the start you can honestly create a separate file that just runs and fails if a contract is violated. CI systems (and any automated checks) understand a simple rule: exit code 0 — success, non‑zero — failure. And assert knows how to “loudly complain” when a condition is false.
An example of a simple test file for Tasky that checks the three most profitable areas: a pure function, a parser, and a business operation.
#include <cassert>
#include <vector>
int main() {
assert(is_valid_title("Ok")); // ok
assert(parse_int("12") == 12); // ok
std::vector<Task> tasks;
assert(add_task(tasks, 1, " Read ")); // ok
assert(tasks[0].title == "Read"); // ok
}
Remember the nuance: in some builds assert can be disabled (for example, if NDEBUG is defined). That's not a reason “not to add checks”; it's a reason to understand that assert is a quick start and full tests are better run in a separate test binary. We'll cover that later; today our goal is to choose the right targets for testing, not argue about testing‑framework religion.
7. Typical mistakes when choosing “what to test first”
Mistake #1: testing the shell instead of the logic.
Beginners often start by checking that the program prints “Menu: 1) add 2) list”. This gives a sense of busywork but hardly protects against real failures. Much better to extract calculations, checks and operations into functions and test them, keeping main() thin.
Mistake #2: writing one “mega‑test for everything”.
Sometimes people write one test that adds a task, then does done, then list, then something else, and at the end one assert(true). Such a test is hard to read and hard to fix: if it fails, it's unclear where and why. Better to test with small scenarios: one test — one behavior rule.
Mistake #3: ignoring edge cases.
A test that adds a normal task almost always passes. But an empty title, a string of spaces, done abc, done -1, done 999 — these are places where a real user (or you in a week) will step on a rake. Parsers and validation without boundary tests are like an umbrella with a hole: technically present, but you still get wet.
Mistake #4: mixing parser and business logic responsibilities.
If the parser starts deciding “whether this task can be added” (and business logic decides “how to recognize a command”), code becomes messy and tests unnatural. It's nicer to keep the boundary: parser does “string → command”, business logic does “command → data change”, and tests fall into place.
Mistake #5: making tests nondeterministic.
If a test depends on current time, random numbers or interactive input, it will be “green then red” without code changes. That breaks trust in tests faster than anything. The first tests should be rock‑solid: fixed inputs, fixed expected results, no magic.
GO TO FULL VERSION