Chapter 18 of 24 | C++
Errors, debugging, and testing
Errors happen at different stages. A syntax or type error prevents compilation; an unresolved symbol is a link error; a crash, incorrect output, or hang is a runtime or logic error. Correct diagnosis starts by identifying the stage and reproducing the smallest failing case.
Check assumptions at boundaries
Use conditions and return values for expected failures, such as invalid user input or a missing file. Exceptions can report failures that prevent an operation from completing. Throw an exception only when the caller can respond meaningfully, and do not catch an exception merely to hide it.
#include <stdexcept>
int divide(int numerator, int denominator)
{
if (denominator == 0) {
throw std::invalid_argument{"denominator must not be zero"};
}
return numerator / denominator;
}
At a program boundary, catch exceptions when you can add context, recover, or report a clear error. Resource-owning objects clean themselves up during stack unwinding. Avoid broad empty catches that turn failure into apparent success.
Debug systematically
- Reproduce the problem with the same input and build settings.
- Read the earliest relevant compiler or runtime diagnostic.
- Form one hypothesis and inspect values at the point where they become wrong.
- Make one focused change and rerun the reproduction.
- Keep a regression test so the fix remains checked.
A debugger can pause at a breakpoint, step through statements, inspect variables, and show a call stack. Sanitizers can help reveal memory errors and undefined behavior during testing; use the options supported by your compiler and platform.
Write tests around behavior
A unit test checks a small component; an integration test checks cooperating parts. Test normal cases, boundary values, empty inputs, and failures. Assertions should express expected behavior, not duplicate implementation details. Keep tests repeatable and independent of the current time, machine-specific paths, or network availability where possible.
For pure functions, test input-output pairs directly. For file or stream operations, use controlled temporary data and clean it up. A failing test should explain what behavior was expected.
Separate expected failures from programmer mistakes
Expected failures include a file that cannot be opened, invalid user input, or a search with no match. The interface should represent these outcomes so callers can respond. A programming error such as violating an internal invariant may need a clear exception or assertion during development. Do not turn either kind of failure into a misleading default result.
Exceptions propagate up the call stack until a matching handler is found. Catching an exception is useful when the current layer can recover, add meaningful context, or report a clear message to the user. If it cannot do any of those things, allowing the exception to propagate is often better than catching it and pretending the operation succeeded.
Build a reproducible debugging case
A report such as “sometimes the total is wrong” is difficult to investigate. Record the input, expected result, actual result, compiler settings, and steps needed to reproduce it. Reduce the input until only the smallest case that still fails remains. A small reproduction eliminates unrelated code and often reveals an incorrect assumption.
Use a debugger to pause where a value changes unexpectedly. Inspect the call stack to learn how execution arrived there, and step over or into calls to follow the data. A breakpoint should help answer a question—such as “which branch ran?”—rather than invite aimless browsing.
Use tests at different levels
Unit tests cover a small function or type quickly. Integration tests check that components work together—for example, that a file parser produces the records expected by the rest of the application. End-to-end tests exercise user-visible workflows, but are generally slower and more sensitive to environment. A healthy project uses a suitable mix.
Tests should be deterministic: the same inputs should produce the same expected result. Isolate time, randomness, network access, and filesystem dependencies where practical. When testing random behavior, use controlled seeds or test deterministic logic separately from the random-number source.
Assertions and sanitizers
An assertion records a condition that should always hold at a particular point in a program. It is valuable for catching a broken internal assumption during development, but it is not a substitute for validating user input. Assertions can be disabled in some build configurations, so never rely on them to perform required work or enforce a security boundary.
Address sanitizers and undefined-behavior sanitizers can catch classes of memory and arithmetic mistakes while a test runs. They only detect problems that the executed tests trigger, so they complement rather than replace code review and careful reasoning. Use the sanitizer options documented for your compiler and platform.
Make each test failure useful
Tests should state the behavior under examination and identify the expected and actual values when they differ. Avoid tests that duplicate every implementation detail: when the design changes but observable behavior remains correct, a good test should usually continue to pass. Keep regression tests for fixed bugs so a future edit cannot quietly reintroduce them.
Practice
- Write tests for a function that computes a safe average, including an empty input.
- Trigger a failed file open and verify the error is reported.
- Compile with warnings and, if available, a sanitizer; investigate one diagnostic.
- Reduce a failing input to the smallest case that still reproduces the problem.
- Write one unit test and one integration test for the same feature and explain what each covers.
- Make a test report a useful message when an expected and actual value differ.
Key idea: reproduce, inspect, explain, fix, and test. Reliable debugging is a method, not a guessing contest.
Property-based and metamorphic testing
Example-based tests cover chosen inputs; property-based tests generate many inputs and check general laws. A sort can be tested for ordered output and preservation of the input multiset. A parser and serializer can be tested for round-trip behavior on valid values. A numeric function may have a symmetry property. State the property's domain carefully: a mathematically true statement may fail at machine limits or under floating-point rounding.
Metamorphic testing is useful when there is no simple expected answer for each input. Transform an input in a way that should predictably transform the output, then compare. For example, translating every point by the same vector should preserve pairwise distances, subject to the numeric representation and tolerance. Property tests complement examples but do not replace boundary cases or an independent oracle.
Fuzzing and minimal counterexamples
A fuzzer generates unusual input to exercise a parser, decoder, or state machine. A useful fuzz target is deterministic, fast, and able to process arbitrary bytes without requiring a full application environment. Sanitizers can help turn memory errors into immediate reports. When a failure is found, reduce the input to a small reproducer and add it as a regression test.
Fuzzing explores only inputs the harness reaches and cannot establish correctness by itself. Define invariants such as “parsing never crashes” or “successful results satisfy validation,” and instrument the build to catch violations. Avoid fuzz harnesses that swallow failures or misclassify expected rejection as a crash.
Static analysis, dynamic analysis, and evidence
Static analysis inspects code without executing every path; dynamic tools observe actual executions. Compiler warnings, linters, sanitizers, race detectors, and formal methods cover different properties. A clean tool report means only that the tool found no issue within its model, settings, and exercised paths.
For a difficult defect, preserve the exact source revision, compiler version, flags, input, and tool output. This makes the problem reproducible and helps distinguish a language defect from a compiler bug, environment issue, or program error. When reporting an implementation bug, provide a minimal standards-conforming example and avoid assuming that one observed result is required by the standard.
Formal specification and verification
For high-assurance components, state invariants and pre/postconditions precisely. Model checking can explore a finite state machine's transitions; theorem proving can establish properties under formal assumptions; static analyzers can reason about selected classes of defects. Each method depends on the accuracy and completeness of its model. A proof about a model is only useful if the implementation corresponds to that model.
Advanced exercise
- Define a property for a parser or sorting function and implement generated tests around it.
- Create a fuzz target for one input boundary and use a sanitizer-enabled build.
- Reduce a failing random input to a minimal case and keep it as a deterministic regression test.
- Write preconditions and postconditions for a safety-critical operation, then identify which can be tested and which need stronger verification.