11. Testing with Catch2¶
What & why¶
Tests let you change code without fear. In C++, where a small mistake can corrupt memory silently, they matter even more — especially when run under sanitizers. Catch2 is a popular, easy-to-read testing framework. (GoogleTest is the other common choice; the concepts transfer directly.)
Setup with CMake¶
tests/CMakeLists.txt:
include(FetchContent)
FetchContent_Declare(
Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.7.1
)
FetchContent_MakeAvailable(Catch2)
add_executable(tests test_stats.cpp)
target_link_libraries(tests PRIVATE stats_lib Catch2::Catch2WithMain)
include(Catch)
catch_discover_tests(tests) # registers each TEST_CASE with CTest
Catch2WithMain supplies main() for you.
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
# or run the test binary directly for Catch2's own output:
./build/tests/tests
Your first tests¶
Say stats.hpp declares:
#pragma once
#include <optional>
#include <vector>
std::optional<double> mean(const std::vector<double>& values);
double median(std::vector<double> values); // throws std::invalid_argument on empty
test_stats.cpp:
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <stdexcept>
#include "stats/stats.hpp"
using Catch::Matchers::WithinAbs;
TEST_CASE("mean of an empty vector is empty") {
REQUIRE_FALSE(mean({}).has_value());
}
TEST_CASE("mean of values") {
auto m = mean({1.0, 2.0, 3.0, 4.0});
REQUIRE(m.has_value());
REQUIRE_THAT(*m, WithinAbs(2.5, 1e-12));
}
TEST_CASE("median", "[median]") {
CHECK(median({3, 1, 2}) == 2);
CHECK(median({4, 1, 3, 2}) == 2.5);
CHECK_THROWS_AS(median({}), std::invalid_argument);
}
REQUIRE vs. CHECK¶
REQUIRE(expr)— on failure, stops this test case.CHECK(expr)— on failure, records it and keeps going.
Use REQUIRE when later lines depend on it (e.g. has_value() before *m); CHECK otherwise.
On failure Catch2 shows the expanded values:
Other assertions¶
REQUIRE_FALSE(expr);
REQUIRE_THROWS(expr);
REQUIRE_THROWS_AS(expr, ExceptionType);
REQUIRE_THROWS_WITH(expr, "message");
REQUIRE_NOTHROW(expr);
REQUIRE_THAT(value, matcher); // floating point, strings, ranges...
Never compare floating-point results with == unless they're exactly representable (like 2.5). Use WithinAbs / WithinRel.
Sections: shared setup¶
Each SECTION runs the test case from the top, so every section gets fresh setup:
TEST_CASE("Stack behavior") {
Stack<int> s;
s.push(1);
s.push(2);
SECTION("pop returns the last pushed value") {
REQUIRE(s.pop() == 2);
REQUIRE(s.size() == 1);
}
SECTION("popping everything empties it") {
s.pop();
s.pop();
REQUIRE(s.empty());
REQUIRE_THROWS_AS(s.pop(), std::out_of_range);
}
}
Data-driven tests: generators¶
#include <catch2/generators/catch_generators.hpp>
TEST_CASE("is_prime") {
auto [n, expected] = GENERATE(table<int, bool>({
{0, false}, {1, false}, {2, true}, {3, true},
{4, false}, {17, true}, {21, false}, {97, true},
}));
CAPTURE(n); // printed if the assertion fails
REQUIRE(is_prime(n) == expected);
}
Tags and filtering¶
./tests "[median]" # only tests tagged median
./tests "~[slow]" # everything except slow
./tests "mean*" # by name wildcard
./tests --list-tests
Testing code with side effects¶
Design for testability: keep logic in pure functions, and push I/O to the edges.
Inject dependencies¶
Instead of a function that reads std::cin and writes std::cout, pass streams:
TEST_CASE("run echoes uppercase") {
std::istringstream in{"hello\n"};
std::ostringstream out;
run(in, out);
REQUIRE(out.str() == "HELLO\n");
}
Files: use a temporary directory¶
#include <filesystem>
namespace fs = std::filesystem;
struct TempDir {
fs::path path = fs::temp_directory_path() / ("test-" + std::to_string(std::rand()));
TempDir() { fs::create_directories(path); }
~TempDir() { fs::remove_all(path); } // RAII cleanup
};
TEST_CASE("save and load round-trip") {
TempDir dir;
auto file = dir.path / "tasks.txt";
save_tasks(file, {{1, "Buy milk"}});
auto loaded = load_tasks(file);
REQUIRE(loaded.size() == 1);
REQUIRE(loaded[0].text == "Buy milk");
}
Interfaces for the outside world¶
For clocks, networks, and random numbers, depend on an interface (lesson 7) or template parameter, and pass a fake in tests.
Run tests under sanitizers¶
The best thing you can do for C++ test quality:
cmake -S . -B build-asan -DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer"
cmake --build build-asan
ctest --test-dir build-asan --output-on-failure
A test that "passes" while reading freed memory is a time bomb; AddressSanitizer turns it into a clear failure.
Coverage (GCC/Clang)¶
cmake -S . -B build-cov -DCMAKE_CXX_FLAGS="--coverage"
cmake --build build-cov && ctest --test-dir build-cov
gcovr -r . build-cov # pip install gcovr
Coverage shows what isn't tested; high coverage doesn't prove correctness.
What to test¶
- Normal cases — the typical inputs.
- Edge cases — empty, one element, maximum size, zero, negative, duplicates.
- Error cases — invalid input throws or returns an error.
- Regressions — every bug you fix gets a test that would have caught it.
Good tests are fast, isolated (no shared state, no network), deterministic (seed your RNGs), and readable (the test name says what behavior is expected).
Common mistakes¶
- Putting logic in
main.cppso tests can't reach it. ==on floating-point results.- Tests that depend on each other or on execution order.
- Tests that touch real files in the working directory, the network, or the wall clock.
- Only testing the happy path.
- Never running tests under sanitizers.
Exercises¶
- Add Catch2 to your CMake project from lesson 10 and write tests for
mean,median, andstddev, including edge cases. - Write tests for the
Fractionclass: normalization, arithmetic, comparisons, and zero-denominator errors. UseGENERATEfor arithmetic cases. - Refactor the beginner capstone so the command functions take
std::ostream&and a data-file path, then write tests foradd,done, andremovein a temporary directory. - Introduce a use-after-free bug on purpose, confirm the normal test run passes, then confirm the ASan build fails.
- Measure coverage with
gcovrand add tests until every branch inFractionis covered.
Recap¶
- Catch2:
TEST_CASE,SECTION,REQUIRE/CHECK, matchers,GENERATE, tags. - Wire tests into CMake with
FetchContent+catch_discover_tests, run withctest. - Design for testability: pure functions, injected streams/paths/interfaces.
- Test normal, edge, error, and regression cases.
- Always run the suite under AddressSanitizer + UBSan too.
Next: Capstone: link checker →