CODE: C CPP catch unittest

Everything you need to write fast, readable C++ tests with Catch2 v3: setup, assertions, fixtures, BDD, parameterised tests, mocking, and more.

1. What is Catch2

Catch2 is a header-friendly C++ testing framework that gives you:

  • A test runner that discovers and executes tests automatically
  • Expressive assertions via REQUIRE / CHECK with automatic expression decomposition — no need to write a message, Catch2 shows you what both sides evaluated to
  • Sections for sharing setup across related tests without a separate fixture class
  • BDD-style SCENARIO / GIVEN / WHEN / THEN blocks as a first-class alternative
  • Parameterised and typed tests via TEST_CASE_METHOD, TEMPLATE_TEST_CASE, and generators
  • Built-in benchmarking with BENCHMARK

The key philosophical difference from GoogleTest is expression decomposition: when you write REQUIRE(a == b), the framework captures both a and b separately and prints both in the failure message. You never need to choose between EXPECT_EQ, EXPECT_LT, EXPECT_STREQ — one macro handles all of them.

**v2 vs v3:** Catch2 v3 (the current major version) is a proper multi-file library, not a single-header include. It requires CMake 3.16+ and a C++14 compiler at minimum, though C++17 unlocks generators and several quality-of-life features. All examples in this guide target **v3**.

2. Install & Build Setup (CMake)

No system install required. CMake downloads and builds Catch2 as part of your project.

cmake
cmake_minimum_required(VERSION 3.20)
project(MyProjectTests CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

include(FetchContent)

FetchContent_Declare(
  Catch2
  GIT_REPOSITORY https://github.com/catchorg/Catch2.git
  GIT_TAG        v3.5.3
)
FetchContent_MakeAvailable(Catch2)

enable_testing()

add_library(my_lib
  src/math.cpp
  include/math.hpp
)
target_include_directories(my_lib PUBLIC include)

add_executable(my_tests
  tests/test_math.cpp
)
target_link_libraries(my_tests PRIVATE
  my_lib
  Catch2::Catch2WithMain  # provides main() for you
)

include(CTest)
include(Catch)
catch_discover_tests(my_tests)  # registers each TEST_CASE with ctest

Build and run:

bash
cmake -S . -B build
cmake --build build -j
ctest --test-dir build --output-on-failure

**`Catch2::Catch2WithMain` vs `Catch2::Catch2`:** Link `Catch2WithMain` to get a pre-built `main()` that handles argument parsing, reporter selection, and test discovery. Use `Catch2::Catch2` only if you need a custom `main()` — for example, to initialise a global resource before any tests run.

Alternative: system package

bash
# Ubuntu / Debian
sudo apt install catch2

# macOS
brew install catch2

Then in CMakeLists.txt replace the FetchContent block with:

cmake
find_package(Catch2 3 REQUIRED)


3. Your First Test

Let's use the same math example as the GTest guide so you can compare the two side by side.

include/math.hpp

cpp
#pragma once

int add(int a, int b);
int divide(int a, int b); // throws on b == 0

src/math.cpp

cpp
#include "math.hpp"
#include <stdexcept>

int add(int a, int b) { return a + b; }

int divide(int a, int b) {
    if (b == 0) throw std::invalid_argument("division by zero");
    return a / b;
}

tests/test_math.cpp

cpp
#include <catch2/catch_all.hpp>
#include "math.hpp"

TEST_CASE("add works", "[math]") {
    REQUIRE(add(2, 3) == 5);
    REQUIRE(add(-2, 2) == 0);
}

TEST_CASE("divide works", "[math]") {
    REQUIRE(divide(10, 2) == 5);
}

TEST_CASE("divide by zero throws", "[math][exceptions]") {
    REQUIRE_THROWS_AS(divide(10, 0), std::invalid_argument);
}

Key idea: `REQUIRE` vs `CHECK`

These are Catch2's equivalents of ASSERT_ and EXPECT_ in GoogleTest:

MacroOn failureGTest equivalent
REQUIRE(expr)Stops the test immediatelyASSERT_*()
CHECK(expr)Records failure, test continuesEXPECT_*()
REQUIRE_FALSE(expr)Stops if expr is trueASSERT_FALSE()
CHECK_FALSE(expr)Records if expr is trueEXPECT_FALSE()

Use REQUIRE when continuing after a failure would cause a crash or produce misleading subsequent failures (dereferencing a pointer, accessing a nullopt, etc.). Use CHECK when you want to see all failures in one run.

Expression decomposition in action

When this fails:

cpp
REQUIRE(result.size() == 3);

Catch2 prints:

text
FAILED:
  REQUIRE( result.size() == 3 )
with expansion:
  5 == 3

No custom message needed. The framework decomposes both sides automatically.

Tags

The second argument to TEST_CASE is a space-separated list of tags in square brackets: "[math][fast]". Tags are used to filter which tests run.

TagMeaning
[math]Custom category tag
[!hide] or [.]Exclude from default run
[!mayfail]Mark an expected failure
[!throws]Expect test body to throw

4. Assertions Reference

Equality and comparisons

Catch2 uses a single REQUIRE / CHECK for all comparison operators:

cpp
REQUIRE(a == b);
REQUIRE(a != b);
REQUIRE(a < b);
REQUIRE(a <= b);
REQUIRE(a > b);
REQUIRE(a >= b);

Floating-point

Don't use REQUIRE(a == b) for floats:

cpp
// Classic: Approx with relative/absolute epsilon
REQUIRE(result == Catch::Approx(3.14).epsilon(1e-6));
REQUIRE(result == Catch::Approx(100.0).margin(0.5));

// Matcher style (v3 preferred):
REQUIRE_THAT(result, Catch::Matchers::WithinRel(3.14, 1e-6));
REQUIRE_THAT(result, Catch::Matchers::WithinAbs(100.0, 0.5));

String matchers

cpp
REQUIRE_THAT(s, Catch::Matchers::ContainsSubstring("hello"));
REQUIRE_THAT(s, Catch::Matchers::StartsWith("pre"));
REQUIRE_THAT(s, Catch::Matchers::EndsWith("fix"));
REQUIRE_THAT(s, Catch::Matchers::Matches("\\d{4}-\\d{2}-\\d{2}")); // regex

Range / collection matchers

cpp
#include <catch2/matchers/catch_matchers_range_equals.hpp>

std::vector<int> got = compute();
REQUIRE_THAT(got, Catch::Matchers::RangeEquals(std::vector<int>{ 1, 2, 3 }));
REQUIRE_THAT(got, Catch::Matchers::UnorderedRangeEquals(expected));
REQUIRE_THAT(got, Catch::Matchers::Contains(42));
REQUIRE_THAT(got, Catch::Matchers::IsEmpty());
REQUIRE_THAT(got, Catch::Matchers::SizeIs(3));

Composing matchers

cpp
// && and || work on matchers directly
REQUIRE_THAT(s,
    Catch::Matchers::StartsWith("Error") &&
    Catch::Matchers::ContainsSubstring("code 42"));

Adding context to failures

cpp
// INFO — only shown on failure
INFO("processing item index: " << i);
REQUIRE(items[i].valid());

// CAPTURE — shorthand for printing "variable = value"
CAPTURE(x, y, result);
REQUIRE(result == expected);
// prints: x = 7, y = 3, result = 22

**`CAPTURE` in loops** is Catch2's equivalent of GTest's `SCOPED_TRACE`. Drop it inside a loop and every failure message will include the loop variable automatically.

5. Sections & Fixtures

Sections — shared setup without a class

Catch2's biggest ergonomic win over GTest is SECTION. Every section within a TEST_CASE runs from the top — the code above the first SECTION acts as implicit per-section setup. No fixture class required.

cpp
#include <catch2/catch_all.hpp>
#include "bank.hpp"

TEST_CASE("BankAccount operations", "[bank]") {
    BankAccount acc(100);  // runs fresh before EACH section

    SECTION("deposit increases balance") {
        acc.deposit(50);
        REQUIRE(acc.balance() == 150);
    }

    SECTION("withdraw decreases balance") {
        acc.withdraw(40);
        REQUIRE(acc.balance() == 60);
    }

    SECTION("overdraft throws") {
        REQUIRE_THROWS_AS(acc.withdraw(200), std::runtime_error);
    }

    SECTION("nested: deposit then withdraw") {
        acc.deposit(100);

        SECTION("full withdrawal") {
            acc.withdraw(200);
            REQUIRE(acc.balance() == 0);
        }

        SECTION("partial withdrawal") {
            acc.withdraw(50);
            REQUIRE(acc.balance() == 150);
        }
    }
}

Catch2 runs the TEST_CASE body once per leaf section. The nesting creates a test tree — so acc is always constructed fresh at balance 100, even in nested cases.

TEST_CASE_METHOD — class-based fixtures

When you want a full OOP fixture (shared state that needs a constructor/destructor, or inheritance), use TEST_CASE_METHOD:

cpp
struct BankFixture {
    BankAccount acc{ 100 };

    void fund(int extra) { acc.deposit(extra); }
};

TEST_CASE_METHOD(BankFixture, "deposit via fixture", "[bank]") {
    fund(50);
    REQUIRE(acc.balance() == 150);
}

TEST_CASE_METHOD(BankFixture, "overdraft via fixture", "[bank]") {
    REQUIRE_THROWS(acc.withdraw(200));
}

**Sections vs TEST_CASE_METHOD:** Prefer `SECTION` for most cases — it keeps the fixture and tests in one place. Use `TEST_CASE_METHOD` when the fixture logic is complex enough to warrant its own class, or when you need to share it across multiple test files.

6. BDD Style

Catch2 ships BDD-style macros as first-class syntax. They are syntactic sugar over TEST_CASE and SECTION, so everything you know about sections applies.

cpp
#include <catch2/catch_all.hpp>
#include "bank.hpp"

SCENARIO("a customer makes deposits and withdrawals", "[bank][bdd]") {

    GIVEN("an account with 100 balance") {
        BankAccount acc(100);

        WHEN("the customer deposits 50") {
            acc.deposit(50);

            THEN("the balance is 150") {
                REQUIRE(acc.balance() == 150);
            }
        }

        WHEN("the customer withdraws more than the balance") {
            THEN("an exception is thrown") {
                REQUIRE_THROWS_AS(acc.withdraw(500), std::runtime_error);
            }
        }
    }
}

When this runs, Catch2 produces:

text
Scenario: a customer makes deposits and withdrawals
    Given: an account with 100 balance
     When: the customer deposits 50
     Then: the balance is 150

Scenario: a customer makes deposits and withdrawals
    Given: an account with 100 balance
     When: the customer withdraws more than the balance
     Then: an exception is thrown

BDD style is most useful when non-engineers read test output (QA, product managers) or when the test descriptions serve as living documentation for domain rules. For pure unit tests of internal functions, plain TEST_CASE / SECTION is usually cleaner.


7. Parameterised Tests

TEMPLATE_TEST_CASE — typed tests

Run the same test body for multiple types:

cpp
TEMPLATE_TEST_CASE("numeric add is commutative", "[math][template]",
                   int, long, float, double)
{
    TestType a = static_cast<TestType>(3);
    TestType b = static_cast<TestType>(7);
    REQUIRE(add(a, b) == add(b, a));
}

Generators — data-driven tests

Generators are Catch2's answer to INSTANTIATE_TEST_SUITE_P:

cpp
#include <catch2/generators/catch_generators.hpp>
#include <catch2/generators/catch_generators_adapters.hpp>

TEST_CASE("add with generated values", "[math][gen]") {
    auto [a, b, expected] = GENERATE(table<int, int, int>({
        {  1,  2,  3 },
        { -1,  1,  0 },
        { 10,  5, 15 },
    }));

    CAPTURE(a, b, expected);
    REQUIRE(add(a, b) == expected);
}

Built-in generator helpers

cpp
// Integer range
auto x = GENERATE(range(1, 10));        // 1..9 (exclusive end)

// Explicit values
auto v = GENERATE(1, 2, 4, 8, 16);

// Random integers (seeded by --rng-seed)
auto r = GENERATE(take(5, random(0, 100)));

// String values
auto path = GENERATE("/api/v1", "/api/v2", "/health");

// Cartesian product of two generators
auto [i, j] = GENERATE(cartesian_product(
    std::vector{ 1, 2, 3 },
    std::vector{ 10, 20 }
));

**Generators and sections interact:** When you use both `GENERATE` and `SECTION` in the same test case, the body runs for every combination of generator value × section. This is powerful but can create a large number of runs. Prefer one or the other when the test tree would be hard to read.

8. Exception Testing

Core exception macros

MacroPasses when
REQUIRE_THROWS(expr)Any exception is thrown
REQUIRE_THROWS_AS(expr, Type)An exception of exactly Type (or subclass) is thrown
REQUIRE_THROWS_WITH(expr, str)Exception thrown and what() matches the string/matcher
REQUIRE_THROWS_MATCHES(expr, Type, matcher)Type matches AND the exception object satisfies a matcher
REQUIRE_NOTHROW(expr)No exception is thrown
CHECK_THROWS_AS(...)Same as above but test continues on failure

Practical examples

cpp
TEST_CASE("exception testing", "[exceptions]") {

    SECTION("any exception") {
        REQUIRE_THROWS(divide(1, 0));
    }

    SECTION("specific type") {
        REQUIRE_THROWS_AS(divide(1, 0), std::invalid_argument);
    }

    SECTION("type + message substring") {
        REQUIRE_THROWS_WITH(
            divide(1, 0),
            Catch::Matchers::ContainsSubstring("zero")
        );
    }

    SECTION("type + custom matcher on the exception object") {
        REQUIRE_THROWS_MATCHES(
            divide(1, 0),
            std::invalid_argument,
            Catch::Matchers::Message("division by zero")
        );
    }

    SECTION("no exception on valid input") {
        REQUIRE_NOTHROW(divide(10, 2));
    }
}

Inspecting exception fields manually

When you need to inspect the exception object beyond its message:

cpp
TEST_CASE("inspect exception fields", "[exceptions]") {
    try {
        riskyOperation();
        FAIL("expected an exception but none was thrown");
    } catch (const MyDomainError& e) {
        REQUIRE(e.code() == 503);
        REQUIRE(e.retryable() == true);
    }
}


9. Mocking with trompeloeil

Catch2 does not ship a mocking framework, but it integrates cleanly with trompeloeil — a header-only C++ mock library designed to work alongside it.

CMake setup

cmake
FetchContent_Declare(
  trompeloeil
  GIT_REPOSITORY https://github.com/rollbear/trompeloeil.git
  GIT_TAG        v47
)
FetchContent_MakeAvailable(trompeloeil)

target_link_libraries(my_tests PRIVATE
  Catch2::Catch2WithMain
  trompeloeil::trompeloeil
)

Activate the Catch2 integration by including the bridge header once in your test target:

cpp
#include <catch2/catch_all.hpp>
#include <catch2/trompeloeil.hpp>

Production code

cpp
// include/worker.hpp
#pragma once
#include <string>

struct ILogger {
    virtual ~ILogger() = default;
    virtual void info(const std::string& msg) = 0;
    virtual void error(const std::string& msg) = 0;
};

class Worker {
public:
    explicit Worker(ILogger& log) : log_(log) {}

    bool run(int x) {
        if (x < 0) { log_.error("x is negative"); return false; }
        log_.info("running");
        return true;
    }

private:
    ILogger& log_;
};

Tests with trompeloeil

cpp
#include <catch2/catch_all.hpp>
#include <catch2/trompeloeil.hpp>
#include "worker.hpp"

class MockLogger : public ILogger {
public:
    MAKE_MOCK1(info,  void(const std::string&), override);
    MAKE_MOCK1(error, void(const std::string&), override);
};

TEST_CASE("Worker logs error on negative input", "[worker]") {
    MockLogger log;
    Worker w(log);

    REQUIRE_CALL(log, error(trompeloeil::re("negative")))
        .TIMES(1);
    FORBID_CALL(log, info(trompeloeil::_));

    REQUIRE(w.run(-1) == false);
}

TEST_CASE("Worker logs info on valid input", "[worker]") {
    MockLogger log;
    Worker w(log);

    REQUIRE_CALL(log, info("running"))
        .TIMES(1);
    FORBID_CALL(log, error(trompeloeil::_));

    REQUIRE(w.run(5) == true);
}

Key trompeloeil matchers

MatcherMeaning
trompeloeil::_Match any argument (wildcard)
trompeloeil::eq(v)Equals v
trompeloeil::ne(v)Not equal to v
trompeloeil::gt(v)Greater than v
trompeloeil::re("pattern")String matches regex
trompeloeil::any_of(a, b)Equals any of the listed values
**Prefer lightweight fakes over mocks.** For simple dependencies like loggers or counters, a hand-written fake struct (a plain class that records calls in a vector) is often more readable than a mock framework. Reach for trompeloeil when you need to verify call order, argument matchers, or return value sequences.

10. Running & Filtering Tests

Running the binary directly

bash
# Run all tests
./build/my_tests

# Verbose: show test names as they run
./build/my_tests -v

# List all tests without running
./build/my_tests --list-tests

# List all tags
./build/my_tests --list-tags

Filtering by tag

bash
# Only tests tagged [math]
./build/my_tests "[math]"

# Tests tagged [math] but NOT [slow]
./build/my_tests "[math][~slow]"

# Any test tagged [unit] OR [fast]
./build/my_tests "[unit],[fast]"

# All tests except those tagged [integration]
./build/my_tests "~[integration]"

Filtering by name

bash
# Wildcard match on test case name
./build/my_tests "*divide*"
./build/my_tests "add*"

Useful flags

FlagEffect
--abortStop after the first failure
-x NStop after N failures
--rng-seed 42Fix random seed for reproducible generator tests
--rng-seed timeUse current time as seed (default)
-r xmlOutput JUnit XML (for CI integration)
-r compactTerse output format
--durations yesPrint time taken per test
--benchmark-no-analysisSkip statistical analysis in benchmarks

Running via ctest

bash
# Run all (parallel)
ctest --test-dir build -j8 --output-on-failure

# Filter by label (ctest sees Catch2 tags as labels)
ctest --test-dir build -L math

# Rerun only failing tests
ctest --test-dir build --rerun-failed

Repeat until failure (flaky test hunting)

bash
for i in $(seq 100); do
    ./build/my_tests --abort || break
done


11. A Real Example: Testing a Parser

The same parse_kv function from the GTest guide, tested with Catch2 idioms.

include/config.hpp

cpp
#pragma once
#include <optional>
#include <string>
#include <string_view>

struct Kv { std::string key; std::string value; };

// Parses "key=value" (no spaces). Returns nullopt if invalid.
std::optional<Kv> parse_kv(std::string_view line);

tests/test_config.cpp

cpp
#include <catch2/catch_all.hpp>
#include "config.hpp"

TEST_CASE("parse_kv — valid input", "[config]") {
    auto kv = parse_kv("mode=fast");

    REQUIRE(kv.has_value());
    REQUIRE(kv->key   == "mode");
    REQUIRE(kv->value == "fast");
}

TEST_CASE("parse_kv — invalid inputs", "[config]") {

    SECTION("missing equals sign") {
        REQUIRE_FALSE(parse_kv("modefast").has_value());
    }

    SECTION("empty key") {
        REQUIRE_FALSE(parse_kv("=fast").has_value());
    }

    SECTION("empty value") {
        REQUIRE_FALSE(parse_kv("mode=").has_value());
    }

    SECTION("empty string") {
        REQUIRE_FALSE(parse_kv("").has_value());
    }
}

// Generator-based table test — equivalent to GTest's INSTANTIATE_TEST_SUITE_P
TEST_CASE("parse_kv — valid pairs round-trip", "[config][gen]") {
    auto [input, exp_key, exp_val] = GENERATE(table<
        std::string_view, std::string_view, std::string_view>({
        { "mode=fast",     "mode",    "fast"    },
        { "timeout=30",    "timeout", "30"      },
        { "host=0.0.0.0",  "host",    "0.0.0.0" },
    }));

    CAPTURE(input);
    auto kv = parse_kv(input);
    REQUIRE(kv.has_value());
    REQUIRE(kv->key   == exp_key);
    REQUIRE(kv->value == exp_val);
}

Designing for testability — inject dependencies

Never call global time, filesystem, or randomness functions directly inside your logic. Inject them.

cpp
using NowFn = std::function<std::chrono::system_clock::time_point()>;

class Token {
public:
    explicit Token(NowFn now) : now_(std::move(now)) {}

    bool expired(std::chrono::seconds ttl) const {
        return (now_() - created_) > ttl;
    }

private:
    NowFn now_;
    std::chrono::system_clock::time_point created_{ std::chrono::system_clock::now() };
};

// In the test:
TEST_CASE("Token expiry", "[token]") {
    using namespace std::chrono_literals;
    auto fixed = std::chrono::system_clock::now();

    SECTION("not expired when fresh") {
        Token t([&] { return fixed; });
        REQUIRE_FALSE(t.expired(60s));
    }

    SECTION("expired after ttl passes") {
        Token t([&] { return fixed + 120s; });
        REQUIRE(t.expired(60s));
    }
}


12. Best Practices

Naming conventions

Catch2 test names are free-form strings. Use a consistent pattern so names are scannable:

cpp
// subject — condition — expected outcome
TEST_CASE("parse_kv — empty key — returns nullopt",     "[config]");
TEST_CASE("BankAccount — overdraft — throws runtime_error", "[bank]");
TEST_CASE("divide — by zero — throws invalid_argument", "[math]");

Arrange / Act / Assert

cpp
TEST_CASE("Encoder round-trips valid data", "[encoder]") {
    // Arrange
    Encoder enc;
    std::string input = "hello world";

    // Act
    auto encoded = enc.encode(input);
    auto decoded = enc.decode(encoded);

    // Assert
    REQUIRE(decoded == input);
}

One behaviour per test

Keep each TEST_CASE or SECTION focused on a single observable behaviour. If a test fails, you want the name alone to tell you what broke.

Things to avoid

Anti-patternWhy it hurtsFix
Global / static mutable state in testsTests affect each other, order-dependent failuresUse SECTION with local variables, or fixture constructors
Calling system_clock::now() in production code directlyTests are timing-dependent and flakyInject a NowFn or clock abstraction
Asserting on what() strings with ==Exception messages change; brittle testsUse ContainsSubstring or REQUIRE_THROWS_AS for type only
One enormous TEST_CASE with no sectionsHard to pinpoint failuresSplit into sections or separate TEST_CASEs
Leaving [.]-hidden tests indefinitelyAccumulates dead code nobody runsFix the test or delete it; treat [.] as a TODO with a deadline
Using GENERATE(random(...)) without a fixed seed in CINon-reproducible failuresPass --rng-seed 12345 in the CI pipeline

Mini checklist: when your Catch2 suite is in good shape

You're in a great place when:

  • Running the full suite takes under a minute
  • A failing test name and Catch2's expansion output tell you exactly what went wrong without reading source
  • Tests pass in any order — no shared mutable state, no filesystem residue
  • Changing an implementation detail does not break tests unless observable behaviour changes
  • Every test passes deterministically — flaky tests are treated as broken tests and fixed immediately
  • Test names and section names read like a specification of the module's contract