Catch2: Modern C++ Testing Framework Guide

Catch2: Modern C++ Testing Framework Guide

Catch2 is the modern C++ testing framework that prioritizes natural expression over boilerplate. Its SECTION model eliminates test fixture overhead, expression decomposition shows you actual values on failure without custom assertion messages, and BDD-style macros make test intent self-documenting.


Catch2 vs Google Test: When to Choose Which

Both are excellent. The choice comes down to style:

Catch2 Google Test
Setup Single header or CMake CMake FetchContent
Assertion style Expression-based (REQUIRE(a == b)) Macro-based (EXPECT_EQ(a, b))
Test organization SECTION nesting Fixtures
BDD support Native (GIVEN/WHEN/THEN) No
Benchmarking Built-in External (benchmark library)
Matchers Rich built-in GMock matchers

Choose Catch2 when you want less boilerplate and BDD-style documentation. Choose gtest when you need the GMock mocking library or are already standardized on it.


Installation

cmake_minimum_required(VERSION 3.14)
project(MyProject CXX)

set(CMAKE_CXX_STANDARD 17)

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

add_library(mylib src/calculator.cpp)
target_include_directories(mylib PUBLIC include/)

add_executable(tests tests/test_calculator.cpp)
target_link_libraries(tests PRIVATE mylib Catch2::Catch2WithMain)

list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
include(Catch)
catch_discover_tests(tests)

vcpkg

vcpkg install catch2

Basic Tests

#include <catch2/catch_test_macros.hpp>
#include "calculator.h"

TEST_CASE("Calculator addition") {
    Calculator calc;
    
    REQUIRE(calc.add(2, 3) == 5);
    REQUIRE(calc.add(-1, -2) == -3);
    REQUIRE(calc.add(0, 0) == 0);
}

TEST_CASE("Calculator handles division") {
    Calculator calc;
    
    REQUIRE(calc.divide(10, 2) == 5);
    REQUIRE(calc.divide(9, 3) == 3);
}

Key difference from gtest: REQUIRE decomposes the expression and shows you actual vs. expected on failure without needing a custom message:

tests/test_calculator.cpp:8:
FAILED:
  REQUIRE( calc.add(2, 3) == 5 )
with expansion:
  4 == 5

You see the actual value (4) automatically.


SECTION: Nested Test Organization

SECTION is Catch2's alternative to fixtures. Each SECTION re-runs the entire test case from the top, giving each section a fresh starting state:

TEST_CASE("Stack operations") {
    Stack<int> stack;    // Created fresh for EVERY section
    
    SECTION("starts empty") {
        REQUIRE(stack.empty());
        REQUIRE(stack.size() == 0);
    }
    
    SECTION("can push elements") {
        stack.push(42);
        REQUIRE(!stack.empty());
        REQUIRE(stack.size() == 1);
        REQUIRE(stack.top() == 42);
    }
    
    SECTION("can pop elements") {
        stack.push(1);
        stack.push(2);
        
        stack.pop();
        REQUIRE(stack.top() == 1);
        REQUIRE(stack.size() == 1);
    }
    
    SECTION("throws on pop from empty") {
        REQUIRE_THROWS_AS(stack.pop(), std::underflow_error);
    }
}

Each SECTION gets a fresh Stack — no SetUp()/TearDown() methods needed.

Nested Sections

Sections can nest. The test case runs once per leaf section:

TEST_CASE("User authentication") {
    UserStore store;
    store.add_user("alice", "secret123");
    
    SECTION("with valid credentials") {
        auto result = store.authenticate("alice", "secret123");
        
        REQUIRE(result.success);
        
        SECTION("grants access to profile") {
            REQUIRE(result.user->can_access("profile"));
        }
        
        SECTION("grants access to dashboard") {
            REQUIRE(result.user->can_access("dashboard"));
        }
    }
    
    SECTION("with invalid password") {
        auto result = store.authenticate("alice", "wrong");
        REQUIRE(!result.success);
        REQUIRE(result.user == nullptr);
    }
    
    SECTION("with unknown user") {
        auto result = store.authenticate("unknown", "secret");
        REQUIRE(!result.success);
    }
}

BDD Style: SCENARIO / GIVEN / WHEN / THEN

#include <catch2/catch_test_macros.hpp>

SCENARIO("User can add items to cart") {
    GIVEN("an empty shopping cart") {
        Cart cart;
        
        WHEN("an item is added") {
            cart.add(Item{"widget", 9.99});
            
            THEN("the cart has one item") {
                REQUIRE(cart.size() == 1);
            }
            
            THEN("the total reflects the item price") {
                REQUIRE_THAT(cart.total(), Catch::Matchers::WithinAbs(9.99, 0.01));
            }
        }
        
        WHEN("the same item is added twice") {
            cart.add(Item{"widget", 9.99});
            cart.add(Item{"widget", 9.99});
            
            THEN("the cart has two items") {
                REQUIRE(cart.size() == 2);
            }
            
            THEN("the total is doubled") {
                REQUIRE_THAT(cart.total(), Catch::Matchers::WithinAbs(19.98, 0.01));
            }
        }
    }
}

SCENARIO, GIVEN, WHEN, THEN are aliases for TEST_CASE, SECTION, nested SECTION, nested SECTION — same semantics, better readability.


Assertions

Core Macros

REQUIRE(expr);            // fails immediately if false
CHECK(expr);              // records failure, continues
REQUIRE_FALSE(expr);      // fails if true
CHECK_FALSE(expr);

REQUIRE_THROWS(expr);                    // any exception
REQUIRE_THROWS_AS(expr, ExceptionType); // specific type
REQUIRE_THROWS_WITH(expr, "message");   // specific message
REQUIRE_NOTHROW(expr);

Matchers

Catch2 matchers compose for readable assertions:

#include <catch2/matchers/catch_matchers_all.hpp>

using namespace Catch::Matchers;

// String matchers
REQUIRE_THAT(str, StartsWith("Hello"));
REQUIRE_THAT(str, EndsWith("world"));
REQUIRE_THAT(str, ContainsSubstring("foo"));
REQUIRE_THAT(str, Matches("^[A-Z].*"));  // regex

// Floating point
REQUIRE_THAT(result, WithinAbs(3.14, 0.001));
REQUIRE_THAT(result, WithinRel(expected, 0.01));  // within 1%

// Range/vector
std::vector<int> v = {1, 2, 3, 4};
REQUIRE_THAT(v, Contains(3));
REQUIRE_THAT(v, SizeIs(4));
REQUIRE_THAT(v, UnorderedEquals(std::vector<int>{4, 3, 2, 1}));

// Composing
REQUIRE_THAT(str, StartsWith("Error") && ContainsSubstring("404"));
REQUIRE_THAT(value, (WithinAbs(5.0, 0.1) || WithinAbs(10.0, 0.1)));

Custom Matchers

class HasValidChecksum : public Catch::Matchers::MatcherBase<Packet> {
public:
    bool match(const Packet& pkt) const override {
        return pkt.checksum() == compute_expected_checksum(pkt);
    }
    
    std::string describe() const override {
        return "has valid checksum";
    }
};

// Usage
REQUIRE_THAT(received_packet, HasValidChecksum());

Generators: Data-Driven Tests

Catch2's generators replace parameterized test suites:

#include <catch2/generators/catch_generators.hpp>
#include <catch2/generators/catch_generators_range.hpp>

TEST_CASE("is_prime returns true for prime numbers") {
    auto n = GENERATE(2, 3, 5, 7, 11, 13, 17, 19, 23, 29);
    
    Calculator calc;
    REQUIRE(calc.is_prime(n));
}

TEST_CASE("is_prime returns false for composites") {
    auto n = GENERATE(4, 6, 8, 9, 10, 12, 14, 15, 16);
    
    Calculator calc;
    REQUIRE(!calc.is_prime(n));
}

// Range generator
TEST_CASE("divide works for range of denominators") {
    auto denom = GENERATE(range(1, 10));  // 1 through 9
    Calculator calc;
    REQUIRE(calc.divide(100, denom) == 100 / denom);
}

// Table generator for structured data
TEST_CASE("divide computes correctly") {
    auto [a, b, expected] = GENERATE(table<int, int, int>({
        {10, 2, 5},
        {9, 3, 3},
        {100, 4, 25},
        {7, 2, 3}
    }));
    
    Calculator calc;
    REQUIRE(calc.divide(a, b) == expected);
}

Benchmarking

Catch2 includes a micro-benchmarking harness:

#include <catch2/benchmark/catch_benchmark.hpp>

TEST_CASE("Sorting performance") {
    std::vector<int> data(1000);
    std::iota(data.begin(), data.end(), 0);
    std::shuffle(data.begin(), data.end(), std::mt19937{42});
    
    BENCHMARK("std::sort") {
        auto copy = data;
        std::sort(copy.begin(), copy.end());
        return copy;  // prevent optimization
    };
    
    BENCHMARK("custom quicksort") {
        auto copy = data;
        quicksort(copy.begin(), copy.end());
        return copy;
    };
}

Run benchmarks:

./tests "[benchmark]" --benchmark-samples 100

Output:

benchmark name            samples   mean       std dev
std::sort                 100       12.3 µs    0.4 µs
custom quicksort          100       18.7 µs    0.9 µs

Test Tagging and Filtering

TEST_CASE("fast unit test", "[unit][fast]") { ... }
TEST_CASE("slow integration test", "[integration][slow]") { ... }
TEST_CASE("database test", "[integration][db]") { ... }
# Run only unit tests
./tests <span class="hljs-string">"[unit]"

<span class="hljs-comment"># Run all except slow tests
./tests <span class="hljs-string">"~[slow]"

<span class="hljs-comment"># Run fast integration tests
./tests <span class="hljs-string">"[fast][integration]"

<span class="hljs-comment"># Run everything
./tests

Logging in Tests

TEST_CASE("Complex computation") {
    auto input = generate_complex_input();
    
    // Captured only on failure
    CAPTURE(input.size());
    CAPTURE(input[0]);
    
    INFO("Processing input with " << input.size() << " elements");
    
    auto result = process(input);
    REQUIRE(result.valid());
}

On failure, CAPTURE and INFO content appears in the output — no clutter on success.


CI Integration

name: C++ Tests (Catch2)

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Configure CMake
        run: cmake -B build -DCMAKE_BUILD_TYPE=Debug
      
      - name: Build
        run: cmake --build build --parallel
      
      - name: Test
        run: cd build && ctest --output-on-failure -j4
      
      - name: Run fast tests only on PR
        if: github.event_name == 'pull_request'
        run: ./build/tests "~[slow]"

Next Steps

Read more