C++ / Intermediate / Lesson 12
Build, test, and debug systematically
Create a repeatable C++20 build, test observable contracts, and use diagnostics to investigate failures without mistaking tools for a correctness proof.
On this page
Lesson goals
- Separate headers, implementation files, and linked targets.
- Write boundary-oriented tests that report failure reliably.
- Use warnings, sanitizers, debug iterators, and a debugger appropriately.
Build the program you intend to test
Each source file and its included headers form a translation unit. Headers usually declare ordinary functions and define templates or inline facilities; source files provide ordinary definitions. Include guards prevent repeated inclusion within one translation unit, but do not solve duplicate non-inline definitions across files. The linker needs every required object or library. Rebuild the target after editing, and record the compiler version, standard mode, include paths, and link dependencies. Keep generated build output separate from source so stale artifacts and accidental generated-file edits are easier to identify.
Test a boundary without reproducing the implementation
A useful test checks a stated behavior through inputs and results. The batch-count function uses quotient and remainder instead of count + width - 1, avoiding an unnecessary overflow-prone addition. Its contract accepts a nonnegative count and a positive width. The first program uses explicit comparisons and return codes so checks remain active even if NDEBUG disables assert. It checks empty work, an exact division, a remainder, and rejection. A real test suite can name these cases individually; the compact executable keeps the example self-contained.
#include <iostream>
#include <stdexcept>
int batches(int count, int width) {
if (count < 0 || width <= 0) { throw std::invalid_argument("invalid batch input"); }
return count / width + (count % width != 0);
}
int main() {
if (batches(0, 4) != 0 || batches(8, 4) != 2 || batches(9, 4) != 3) { return 1; }
bool rejected{false};
try { (void)batches(2, 0); }
catch (const std::invalid_argument&) { rejected = true; }
if (!rejected) { return 1; }
std::cout << "4 cases passed\n";
}
4 cases passed
Three result cases and one rejection case are checked. Any mismatch returns a failing process status before the success message.
Reproduce, reduce, inspect
Debugging starts with a reproducible failing case and an expected result. Reduce inputs and isolate the smallest function that still fails. Build with debug information and modest optimization while learning to step through variables, then confirm fixes in the normal build too. The second example tests membership at an insertion point; its important condition is equality after lower_bound. Looking only at whether the returned iterator differs from end would report six as present. A debugger helps inspect that iterator, but a boundary test preserves the discovery for future changes.
#include <algorithm>
#include <iostream>
#include <vector>
bool contains(const std::vector<int>& sorted, int target) {
const auto position = std::lower_bound(sorted.begin(), sorted.end(), target);
return position != sorted.end() && *position == target;
}
int main() {
const std::vector<int> values{2, 5, 8};
if (contains(values, 6) || !contains(values, 5) || contains({}, 1)) { return 1; }
std::cout << "3 searches passed\n";
}
3 searches passed
A missing interior value, a present value, and an empty range cover distinct branches without dereferencing end. The caller supplies sorted input.
Use each diagnostic for its actual scope
With GCC on supported targets, AddressSanitizer and UndefinedBehaviorSanitizer can be enabled with -fsanitize=address,undefined in a separate debug build. They detect many executed memory and arithmetic errors, not every possible defect. ThreadSanitizer is a separate configuration and is not combined with AddressSanitizer. libstdc++ debug mode adds iterator checks with -D_GLIBCXX_DEBUG; keep ABI-sensitive container usage consistent across linked components. Warnings such as -Wall -Wextra -Wpedantic find suspicious code early. None of these tools proves that unexecuted paths are safe or that the application implements its intended policy.
Declare requirements on the target
A build system should attach language requirements and dependencies to the target that needs them. CMake can request cxx_std_20 through target_compile_features, and CXX_EXTENSIONS OFF avoids relying on compiler extensions for that target. A multi-file build snippet depends on an existing project and therefore is not marked as a standalone runnable example. Run tests from a clean build directory at least once before trusting a repair. Keep compiler diagnostics, failing inputs, and expected outputs together so another person can reproduce the issue without guessing your editor settings.
Common errors and fixes
Tests report success after assertions were disabled.
- Why it happens
- All checks relied on assert in a NDEBUG build.
- How to fix it
- Use a test framework or explicit failure checks.
- Prevent it next time
- Verify test behavior in the actual build configuration.
A duplicate symbol appears at link time.
- Why it happens
- A non-inline function definition is in a multiply included header.
- How to fix it
- Move the definition to one source file or use valid inline design.
- Prevent it next time
- Test headers in a multi-translation-unit build.
An instrumented run is mistaken for a proof.
- Why it happens
- Only executed paths were checked.
- How to fix it
- Add boundary cases and review ownership and preconditions.
- Prevent it next time
- Combine tests, analysis, and contract reasoning.
Check your understanding
1. Add a regression case for batches(1, 4) and explain why an exact-multiple-only suite would miss an incorrect floor-division implementation.
Show worked solution
The expected result is one because one item still needs a batch. An implementation returning count / width alone would produce zero and fail this case, while batches(8, 4) would still pass. Test a remainder near both ends, such as one and seven for width four, to exercise rounding up explicitly.
2. A bug occurs only in an optimized build. Outline a practical investigation that preserves the failing evidence and avoids declaring the optimizer broken immediately.
Show worked solution
Keep the failing input, compiler version, complete flags, and exact source revision. Reproduce in a clean build, run appropriate sanitizers in separate configurations, and review lifetimes, uninitialized reads, races, and violated preconditions. Reduce the example while retaining the failure. Optimization often exposes undefined behavior that a debug build happened to mask.
Key takeaways
- Make builds and failing cases reproducible.
- Test contracts at boundaries and keep checks active.
- Diagnostics provide evidence within documented limits.