Catch2
Modern, header-friendly C++ test framework with natural BDD-style syntax, powerful matchers, and zero-boilerplate test registration.
TL;DR
Catch2 v3 is a modern C++ testing framework with a natural English-like syntax. Tests self-register — no main() boilerplate. BDD-style SCENARIO/GIVEN/WHEN/THEN blocks make intent clear. Prefer Catch2 for new projects that don't need gMock; prefer GoogleTest when you need heavyweight mocking.
#include <catch2/catch_test_macros.hpp>
TEST_CASE("Vector can be sized and resized", "[vector]") {
std::vector<int> v(5);
REQUIRE(v.size() == 5);
REQUIRE(v.capacity() >= 5);
SECTION("resizing bigger changes size and capacity") {
v.resize(10);
REQUIRE(v.size() == 10);
REQUIRE(v.capacity() >= 10);
}
SECTION("resizing smaller changes size but not capacity") {
v.resize(0);
REQUIRE(v.size() == 0);
REQUIRE(v.capacity() >= 5);
}
}Setup
CMake FetchContent
include(FetchContent)
FetchContent_Declare(
Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.7.1
)
FetchContent_MakeAvailable(Catch2)
add_executable(myapp_tests tests/my_test.cpp)
target_link_libraries(myapp_tests Catch2::Catch2WithMain)
include(CTest)
include(Catch)
catch_discover_tests(myapp_tests)vcpkg
vcpkg install catch2find_package(Catch2 3 REQUIRED)
target_link_libraries(myapp_tests Catch2::Catch2WithMain)Assertions
// REQUIRE — fatal: stops the test case on failure
REQUIRE(x == 42);
REQUIRE(vec.size() == 3);
REQUIRE_FALSE(str.empty());
REQUIRE_THROWS(risky());
REQUIRE_THROWS_AS(risky(), std::invalid_argument);
REQUIRE_NOTHROW(safe());
// CHECK — non-fatal: records failure but continues
CHECK(x == 42);
CHECK(str == "hello");
CHECK_FALSE(error);Prefer REQUIRE for preconditions (the test is meaningless if they fail), CHECK for independent assertions.
Matchers
#include <catch2/matchers/catch_matchers_all.hpp>
using namespace Catch::Matchers;
REQUIRE_THAT(str, StartsWith("Hello"));
REQUIRE_THAT(str, EndsWith("world"));
REQUIRE_THAT(str, ContainsSubstring("ello"));
REQUIRE_THAT(str, Equals("Hello world"));
// Floating point
REQUIRE_THAT(3.14, WithinAbs(3.14159, 0.01));
REQUIRE_THAT(3.14, WithinRel(3.14159, 0.001)); // 0.1% tolerance
// Ranges
REQUIRE_THAT(vec, Contains(42));
REQUIRE_THAT(vec, UnorderedEquals(std::vector{3, 1, 2}));
REQUIRE_THAT(vec, SizeIs(3));
REQUIRE_THAT(vec, IsEmpty());
REQUIRE_THAT(vec, AllMatch(Predicate<int>([](int x){ return x > 0; })));Custom matchers
struct IsEvenMatcher : Catch::Matchers::MatcherBase<int> {
bool match(const int& val) const override { return val % 2 == 0; }
std::string describe() const override { return "is even"; }
};
auto IsEven() { return IsEvenMatcher{}; }
TEST_CASE("Even numbers") {
CHECK_THAT(4, IsEven());
}SECTION — shared setup without fixtures
TEST_CASE("Stack operations") {
Stack<int> s; // created fresh for each SECTION
SECTION("push increases size") {
s.push(1);
REQUIRE(s.size() == 1);
}
SECTION("pop on empty throws") {
REQUIRE_THROWS_AS(s.pop(), std::underflow_error);
}
SECTION("nested sections") {
s.push(1);
s.push(2);
SECTION("top returns last pushed") {
REQUIRE(s.top() == 2);
}
SECTION("pop reduces size") {
s.pop();
REQUIRE(s.size() == 1);
}
}
}Each SECTION runs as a separate test — the outer code re-runs for each. This replaces fixture SetUp for simple cases.
BDD style
SCENARIO("User logs in", "[auth]") {
GIVEN("a registered user") {
User user{"alice", "hashed_pass"};
AuthService auth;
WHEN("they provide correct credentials") {
auto result = auth.login("alice", "password");
THEN("login succeeds") {
REQUIRE(result.success);
REQUIRE(result.token != "");
}
}
WHEN("they provide wrong password") {
auto result = auth.login("alice", "wrong");
THEN("login fails") {
REQUIRE_FALSE(result.success);
REQUIRE(result.error == "Invalid credentials");
}
}
}
}Parameterized tests (GENERATE)
TEST_CASE("Integer parsing") {
auto [input, expected] = GENERATE(table<std::string, int>({
{"0", 0},
{"42", 42},
{"-1", -1},
{"9999", 9999},
}));
REQUIRE(parseInt(input) == expected);
}
// Simple value generation
TEST_CASE("Positive numbers") {
int n = GENERATE(1, 2, 5, 10, 100);
REQUIRE(n > 0);
}
// Range generation
TEST_CASE("Range") {
int n = GENERATE(range(1, 11)); // 1..10
REQUIRE(n >= 1);
REQUIRE(n <= 10);
}Running tests
# Run all
./myapp_tests
# List all tests
./myapp_tests --list-tests
# Run by tag
./myapp_tests "[vector]"
./myapp_tests "[vector],[stack]" # OR
# Run by name (substring match)
./myapp_tests "User logs in"
# Verbose output
./myapp_tests -v
# JUnit XML for CI
./myapp_tests -r junit -o results.xml
# Via CTest
ctest --test-dir build -VCatch2 vs GoogleTest
| Catch2 | GoogleTest | |
|---|---|---|
| Syntax | Natural, BDD-friendly | xUnit (Java-style) |
| Mocking | Via trompeloeil (separate) | gMock built-in |
| Setup | Zero boilerplate | Some boilerplate |
| SECTION pattern | Yes (unique) | No (use fixtures) |
| Parameterized | GENERATE (flexible) | TestWithParam |
| Matchers | Built-in, extensible | Built-in, extensible |
| Adoption | Wide (especially open-source) | Very wide (Google ecosystem) |
Choose Catch2 when: you prefer readable test descriptions and BDD syntax, or for open-source library testing.
Choose GoogleTest when: you need gMock's extensive mocking capabilities, or your team comes from a Java/JUnit background.
Floating-Point Matchers
Floating-point equality is never a simple ==. Catch2 ships three distinct matchers covering the three ways "close enough" can be defined, and combining them with AllOf / || lets you express intent precisely.
#include <catch2/matchers/catch_matchers_floating_point.hpp>
using namespace Catch::Matchers;
// WithinAbs — absolute margin: |actual - expected| <= margin
REQUIRE_THAT(result, WithinAbs(0.0, 1e-9)); // good for values near zero
// WithinRel — relative tolerance: |actual - expected| / |expected| <= tol
REQUIRE_THAT(result, WithinRel(1e6, 1e-6)); // 1 ppm tolerance, good for large values
// WithinULP — ULPs (Units in Last Place): machine-epsilon steps between floats
REQUIRE_THAT(result, WithinULP(expected, 4)); // within 4 representable floatsA ULP is the gap between one floating-point representable value and the next. WithinULP(x, 1) accepts the single nearest representable value — useful when verifying that a computation is bit-exact after refactoring. Use WithinAbs when the result might legitimately be zero (relative tolerance blows up there); use WithinRel for scale-independent comparisons on non-zero values.
You can compose matchers with || (logical OR) to handle both the near-zero and large-value cases in a single assertion:
// Accept if either condition holds — handles the zero-crossing edge case
REQUIRE_THAT(val, WithinAbs(0.0, 1e-9) || WithinRel(expected, 1e-6));
// AllOf requires both (intersection, for tightening bounds)
REQUIRE_THAT(val, AllOf(WithinAbs(expected, 0.01), WithinRel(expected, 0.001)));GENERATE Deep Dive
GENERATE turns a single test case into a parameterised suite without any extra syntax. Each generator expression produces a sequence of values; Catch2 re-runs the entire test body for each combination.
#include <catch2/generators/catch_generators_all.hpp>
// Inline value list
TEST_CASE("Powers of two are positive") {
int n = GENERATE(1, 2, 4, 8, 16, 32);
REQUIRE(n > 0);
}
// Contiguous range [0, 10)
TEST_CASE("Range generator") {
int n = GENERATE(range(0, 10));
REQUIRE(n >= 0);
REQUIRE(n < 10);
}
// Random sample — reproducible with a fixed seed via --rng-seed
TEST_CASE("Random sample") {
int n = GENERATE(take(5, random(0, 100)));
REQUIRE(n >= 0);
REQUIRE(n <= 100);
}
// Filter — keep only even numbers from a range
TEST_CASE("Even numbers only") {
int n = GENERATE(filter([](int x){ return x % 2 == 0; }, range(0, 20)));
REQUIRE(n % 2 == 0);
}
// Table — structured pairs / tuples (preferred for multi-column input)
TEST_CASE("String to int parsing") {
auto [input, expected] = GENERATE(table<std::string, int>({
{"0", 0},
{"42", 42},
{"-1", -1},
{"9999", 9999},
}));
REQUIRE(parseInt(input) == expected);
}Two GENERATE calls in the same test case produce a Cartesian product — every combination of both sequences is executed. This is powerful but can multiply run counts quickly, so apply filter or take to keep the suite tractable:
TEST_CASE("Cartesian product") {
int base = GENERATE(2, 8, 16);
int shift = GENERATE(range(0, 8));
REQUIRE((base << shift) > 0);
// 3 × 8 = 24 test executions
}Catch2 encodes the generator values in the test name reported on the console (TEST_CASE - base=2, shift=3), making failures easy to reproduce by re-running with -# <seed> or passing the exact test name.
Benchmarking with Catch2
Catch2 ships a micro-benchmarking harness that lives alongside your tests — no separate binary or tool required. Benchmarks are tagged [!benchmark] and skipped by default; pass [!benchmark] on the command line to opt in.
#include <catch2/catch_test_macros.hpp>
#include <catch2/benchmark/catch_benchmark.hpp>
TEST_CASE("std::sort benchmarks", "[!benchmark]") {
BENCHMARK("sort 1 000 ints") {
std::vector<int> v(1000);
std::iota(v.begin(), v.end(), 0);
std::shuffle(v.begin(), v.end(), std::mt19937{42});
std::sort(v.begin(), v.end());
return v; // return value prevents dead-code elimination
};
}The return value from a BENCHMARK block is essential — without it the optimiser may eliminate the entire body. For cases where setup cost must be excluded from the measurement, use BENCHMARK_ADVANCED with a Chronometer:
TEST_CASE("Advanced benchmark with setup", "[!benchmark]") {
BENCHMARK_ADVANCED("sort pre-shuffled")(Catch::Benchmark::Chronometer meter) {
// Setup runs once, outside the timed region
std::vector<int> v(1000);
std::iota(v.begin(), v.end(), 0);
std::shuffle(v.begin(), v.end(), std::mt19937{42});
// Only the lambda body is measured, invoked meter.runs() times
meter.measure([&v]() {
auto copy = v;
std::sort(copy.begin(), copy.end());
return copy;
});
};
}Run benchmarks from the command line and tune the sample count:
# Run only benchmarks (skips all regular tests)
./myapp_tests "[!benchmark]"
# Increase statistical samples for tighter confidence intervals
./myapp_tests "[!benchmark]" --benchmark-samples 200
# Set a fixed RNG seed so random generators are reproducible
./myapp_tests "[!benchmark]" --rng-seed 12345Results are printed in a table with mean, standard deviation, and outlier counts. Pipe through -r console (the default) for human-readable output or -r json to feed into a perf-tracking dashboard.
Custom Reporters
The default console reporter is optimised for humans; CI pipelines often need structured machine-readable output. Writing a custom reporter lets you emit whatever format your tooling expects — Slack webhooks, internal dashboards, or a bespoke XML schema — without forking Catch2.
#include <catch2/reporters/catch_reporter_bases.hpp>
#include <catch2/catch_reporter_registrars.hpp>
class TeamDashboardReporter : public Catch::ReporterBase {
public:
using ReporterBase::ReporterBase;
static std::string getDescription() {
return "Sends results to the team dashboard";
}
void testCaseEnded(Catch::TestCaseStats const& stats) override {
// Called once per TEST_CASE with pass/fail totals
std::cout << "[CASE] " << stats.testInfo->name
<< " — " << (stats.totals.assertions.failed == 0 ? "PASS" : "FAIL")
<< '\n';
}
void sectionEnded(Catch::SectionStats const& stats) override {
// Called for each SECTION (including the implicit top-level section)
if (stats.assertions.failed > 0) {
std::cout << " [SECTION] " << stats.sectionInfo.name << " FAILED\n";
}
}
void benchmarkEnded(Catch::BenchmarkStats<> const& stats) override {
std::cout << "[BENCH] " << stats.info.name
<< " mean=" << stats.mean.point.count() << "ns\n";
}
void testRunEnded(Catch::TestRunStats const& stats) override {
std::cout << "[DONE] passed=" << stats.totals.assertions.passed
<< " failed=" << stats.totals.assertions.failed << '\n';
}
};
CATCH_REGISTER_REPORTER("dashboard", TeamDashboardReporter)Register the translation unit in your test executable and activate at runtime:
./myapp_tests -r dashboardYou can stack reporters with multiple -r flags — e.g., -r console -r dashboard — so humans and machines each see their preferred format from the same run.
Event Listeners
Listeners observe the test run without replacing the reporter. They are ideal for side-effects: seeding a test database, recording metrics, or posting results to a monitoring endpoint — while leaving the user's chosen reporter intact.
#include <catch2/catch_test_macros.hpp>
#include <catch2/interfaces/catch_interfaces_reporter.hpp>
#include <catch2/catch_reporter_registrars.hpp>
struct MetricsListener : Catch::EventListenerBase {
using EventListenerBase::EventListenerBase;
void testRunStarting(Catch::TestRunInfo const& info) override {
// Called once before any test case runs
// Good place to open a DB connection or start a timer
std::cerr << "[metrics] run starting: " << info.name << '\n';
}
void testCaseStarting(Catch::TestCaseInfo const& info) override {
// Called before each TEST_CASE — record start timestamp here
}
void assertionEnded(Catch::AssertionStats const& stats) override {
if (!stats.assertionResult.isOk()) {
// Ship failed assertion details to your monitoring system
sendAlert(stats.assertionResult.getExpressionInParens());
}
}
void testRunEnded(Catch::TestRunStats const& stats) override {
// Flush metrics, close connections, post summary
std::cerr << "[metrics] total assertions: "
<< stats.totals.assertions.total() << '\n';
}
private:
void sendAlert(std::string const& expr) { /* ... */ }
};
CATCH_REGISTER_LISTENER(MetricsListener)Unlike reporters, listeners are always active — they don't need a -r flag. Every listener registered via CATCH_REGISTER_LISTENER runs for every test execution automatically. This makes them the right hook for infrastructure concerns that must be transparent to the developer running tests locally.
CI Integration
Catch2 speaks JUnit XML natively, which is accepted by every major CI platform. Combine it with CTest's --output-on-failure flag to get full failure details without drowning in passing-test noise.
# JUnit XML — consumed by Jenkins, GitLab, Azure DevOps, CircleCI
./myapp_tests -r junit -o results.xml
# TeamCity service messages (auto-detected in TC builds)
./myapp_tests -r teamcity
# Allow pipeline to succeed even when no tests matched the filter
./myapp_tests "[smoke]" --allow-running-no-testsExit codes follow POSIX conventions: 0 = all tests passed, 1 = one or more test failures, 2 = bad command-line arguments or internal error. Gate your pipeline on exit code 0 and parse results.xml for detailed reporting.
A minimal GitHub Actions matrix that builds and tests against two compilers:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-24.04
strategy:
matrix:
compiler: [gcc-14, clang-18]
steps:
- uses: actions/checkout@v4
- name: Install compiler
run: sudo apt-get install -y ${{ matrix.compiler }}
- name: Configure
run: |
cmake -B build -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_COMPILER=${{ matrix.compiler == 'gcc-14' && 'g++-14' || 'clang++-18' }}
- name: Build
run: cmake --build build --parallel
- name: Test
run: |
./build/myapp_tests -r junit -o results.xml
continue-on-error: false
- name: Publish results
uses: mikepenz/action-junit-report@v4
if: always()
with:
report_paths: results.xml
check_name: "Catch2 (${{ matrix.compiler }})"For pipelines that gate on a tag filter, --allow-running-no-tests prevents a false-negative exit code when no tests match the selected tag on a given branch (e.g., running [integration] tests only on main).
See Also
- GoogleTest — xUnit-style framework with built-in gMock; prefer when you need heavyweight mocking
- doctest — single-header, compilation-speed-optimised alternative to Catch2 with nearly identical syntax
- Boost.Test — mature framework from Boost; useful when you are already pulling in Boost as a dependency
- CMake — build system integration via
catch_discover_tests()and CTest