Skip to content
C++
Analysis & Formatting
Updated 2026-05-01T00:00:00.000Z

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.

cpp
#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

cmake
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

bash
vcpkg install catch2
cmake
find_package(Catch2 3 REQUIRED)
target_link_libraries(myapp_tests Catch2::Catch2WithMain)

Assertions

cpp
// 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

cpp
#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

cpp
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

cpp
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

cpp
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)

cpp
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

bash
# 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 -V

Catch2 vs GoogleTest

Catch2GoogleTest
SyntaxNatural, BDD-friendlyxUnit (Java-style)
MockingVia trompeloeil (separate)gMock built-in
SetupZero boilerplateSome boilerplate
SECTION patternYes (unique)No (use fixtures)
ParameterizedGENERATE (flexible)TestWithParam
MatchersBuilt-in, extensibleBuilt-in, extensible
AdoptionWide (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.

cpp
#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 floats

A 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:

cpp
// 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.

cpp
#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:

cpp
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.

cpp
#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:

cpp
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:

bash
# 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 12345

Results 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.

cpp
#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:

bash
./myapp_tests -r dashboard

You 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.

cpp
#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.

bash
# 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-tests

Exit 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:

yaml
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
Edit on GitHubUpdated 2026-05-01T00:00:00.000Z