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 FetchContent (Recommended)
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 catch2Basic 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 == 5You 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 100Output:
benchmark name samples mean std dev
std::sort 100 12.3 µs 0.4 µs
custom quicksort 100 18.7 µs 0.9 µsTest 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
./testsLogging 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
- Add GMock compatibility — Catch2 works with GMock matchers for mocking
- Try CTest integration — see the CTest and CMake guide for multi-target test orchestration
- Add memory checks — run Catch2 tests under AddressSanitizer (see memory testing guide)
- Compare with gtest — see the Google Test tutorial for the alternative approach