Skip to content
C++
Domain Track
Difficulty 2/5

Testing C++ Code

Testing strategies for C++ — Google Test, Catch2, doctest, property-based testing with RapidCheck, mocking with Google Mock, and CI integration.

TL;DR

Google Test for large projects (mature, feature-rich). Catch2/doctest for header-only simplicity. Google Mock for mocking collaborators. Always test at the unit, integration, and end-to-end levels.


Google Test (GTest)

cpp
#include <gtest/gtest.h>

// Basic test
TEST(MathTest, Addition) {
    EXPECT_EQ(1 + 1, 2);
    EXPECT_NE(1 + 1, 3);
    EXPECT_LT(1, 2);
    EXPECT_GT(2, 1);
    EXPECT_NEAR(3.14, std::numbers::pi, 0.01);
}

// ASSERT vs EXPECT
TEST(ArrayTest, Access) {
    std::vector<int> v{1, 2, 3};
    ASSERT_EQ(v.size(), 3u);   // stops test if fails
    EXPECT_EQ(v[0], 1);        // continues even if fails
    EXPECT_EQ(v[1], 2);
    EXPECT_EQ(v[2], 3);
}

// String assertions
TEST(StringTest, Content) {
    std::string s = "hello world";
    EXPECT_EQ(s.substr(0, 5), "hello");
    EXPECT_TRUE(s.contains("world"));  // C++23
}

Test Fixtures (Shared Setup)

cpp
class DatabaseTest : public ::testing::Test {
protected:
    void SetUp() override {
        db_.connect("sqlite::memory:");
        db_.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");
    }

    void TearDown() override {
        db_.disconnect();
    }

    Database db_;
};

TEST_F(DatabaseTest, InsertAndRetrieve) {
    db_.exec("INSERT INTO users VALUES (1, 'Alice')");
    auto user = db_.query_one("SELECT name FROM users WHERE id = 1");
    EXPECT_EQ(user["name"], "Alice");
}

TEST_F(DatabaseTest, NotFoundReturnsNullopt) {
    auto user = db_.find_user(999);
    EXPECT_FALSE(user.has_value());
}

Parameterized Tests

cpp
class PrimeTest : public ::testing::TestWithParam<int> {};

TEST_P(PrimeTest, IsPrime) {
    EXPECT_TRUE(is_prime(GetParam()));
}

INSTANTIATE_TEST_SUITE_P(PrimeValues, PrimeTest,
    ::testing::Values(2, 3, 5, 7, 11, 13, 17, 19, 23));

// Typed tests (same test for multiple types)
template<typename T>
class NumericTest : public ::testing::Test {};

using NumericTypes = ::testing::Types<int, long, float, double>;
TYPED_TEST_SUITE(NumericTest, NumericTypes);

TYPED_TEST(NumericTest, DefaultIsZero) {
    TypeParam val{};
    EXPECT_EQ(val, TypeParam{0});
}

Exception Testing

cpp
TEST(ErrorTest, ThrowsOnBadInput) {
    EXPECT_THROW(parse_int("abc"), std::invalid_argument);
    EXPECT_NO_THROW(parse_int("42"));

    // Check exception message
    try {
        parse_int("abc");
        FAIL() << "Expected exception";
    } catch (const std::invalid_argument& e) {
        EXPECT_THAT(std::string{e.what()}, ::testing::HasSubstr("invalid"));
    }
}

Google Mock (GMock)

cpp
#include <gmock/gmock.h>

// Interface to mock
struct IDatabase {
    virtual ~IDatabase() = default;
    virtual std::optional<User> find_user(int id) = 0;
    virtual bool save_user(const User& u) = 0;
};

// Mock class — generated macros
class MockDatabase : public IDatabase {
public:
    MOCK_METHOD(std::optional<User>, find_user, (int id), (override));
    MOCK_METHOD(bool, save_user, (const User& u), (override));
};

// Test using mock
TEST(UserServiceTest, ReturnsUserWhenFound) {
    MockDatabase db;
    User alice{1, "Alice"};

    // Set expectations
    EXPECT_CALL(db, find_user(1))
        .Times(1)
        .WillOnce(::testing::Return(std::optional{alice}));

    UserService svc{db};
    auto result = svc.get_user(1);
    ASSERT_TRUE(result.has_value());
    EXPECT_EQ(result->name, "Alice");
}

// Matchers
EXPECT_CALL(db, save_user(::testing::Field(&User::name, ::testing::Eq("Bob"))))
    .WillOnce(::testing::Return(true));

Catch2

cpp
#define CATCH_CONFIG_MAIN
#include <catch2/catch_all.hpp>

TEST_CASE("Vector operations", "[vector]") {
    std::vector<int> v;

    SECTION("empty initially") {
        REQUIRE(v.empty());
        REQUIRE(v.size() == 0);
    }

    SECTION("push_back adds element") {
        v.push_back(42);
        REQUIRE(v.size() == 1);
        REQUIRE(v[0] == 42);
    }
}

// BDD style
SCENARIO("Stack operations", "[stack]") {
    GIVEN("an empty stack") {
        std::stack<int> s;
        REQUIRE(s.empty());

        WHEN("we push a value") {
            s.push(5);
            THEN("stack is non-empty") {
                REQUIRE_FALSE(s.empty());
                REQUIRE(s.top() == 5);
            }
        }
    }
}

// Parameterized
TEST_CASE("Parse integers", "[parsing]") {
    auto [input, expected] = GENERATE(table<std::string, int>({
        {"42", 42}, {"0", 0}, {"-1", -1}, {"100", 100}
    }));
    REQUIRE(parse_int(input) == expected);
}

doctest (Lightweight)

cpp
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>

TEST_CASE("basic math") {
    CHECK(1 + 1 == 2);
    REQUIRE(2 * 2 == 4);  // stops on failure

    SUBCASE("negative numbers") {
        CHECK(-1 + 1 == 0);
    }
}

// Can live alongside production code (in #ifdef blocks)
#ifdef DOCTEST_CONFIG_IMPLEMENT
TEST_CASE("MyClass invariants") {
    MyClass obj;
    CHECK(obj.is_valid());
}
#endif

Property-Based Testing (RapidCheck)

cpp
#include <rapidcheck.h>

// Generate random inputs and test invariants
TEST(PropertyTest, SortedVectorIsNonDecreasing) {
    rc::check("sorted vector has no inversions",
        [](std::vector<int> v) {
            std::sort(v.begin(), v.end());
            for (size_t i = 1; i < v.size(); ++i)
                RC_ASSERT(v[i] >= v[i - 1]);
        });
}

TEST(PropertyTest, RoundTrip) {
    rc::check("serialize then deserialize is identity",
        [](const Config& cfg) {
            auto json = cfg.to_json();
            auto restored = Config::from_json(json);
            RC_ASSERT(cfg == restored);
        });
}

CMake Integration

cmake
include(FetchContent)

# Google Test
FetchContent_Declare(googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG v1.14.0 GIT_SHALLOW TRUE)
FetchContent_MakeAvailable(googletest)

enable_testing()
add_executable(tests test/unit_tests.cpp)
target_link_libraries(tests PRIVATE GTest::gtest_main GTest::gmock mylib)

include(GoogleTest)
gtest_discover_tests(tests)

# Catch2
FetchContent_Declare(Catch2
    GIT_REPOSITORY https://github.com/catchorg/Catch2.git
    GIT_TAG v3.5.3 GIT_SHALLOW TRUE)
FetchContent_MakeAvailable(Catch2)
target_link_libraries(tests PRIVATE Catch2::Catch2WithMain)
include(Catch)
catch_discover_tests(tests)

Test Organization

cpp
project/
├── src/         # production code
├── include/     # headers
└── tests/
    ├── unit/    # isolated unit tests (mock dependencies)
    ├── integration/  # real dependencies, in-memory DB
    └── e2e/     # end-to-end, full system
bash
# Run tests
ctest --test-dir build -j8 --output-on-failure

# Run specific test
./build/tests --gtest_filter="DatabaseTest.*"
./build/tests "[vector]"   # Catch2

# Run with sanitizers
cmake -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined" ...

Test Doubles Taxonomy

Mock, stub, fake, and spy are not synonyms — conflating them causes over-specified tests that break on irrelevant refactors. Understanding what each double does helps you choose the right tool for each test.

DoubleDefinitionWhen to use
StubReturns hardcoded values; records nothingIsolate SUT from a dependency whose output you control
MockPre-programmed with expectations; verified after testAssert that a specific interaction occurred (calls, order, arguments)
FakeLightweight working implementation (e.g. in-memory DB)Integration tests that need realistic behavior without infra cost
SpyReal object that also records calls for later assertionsWrap a production dependency and observe side effects
cpp
// STUB — returns fixed data, no expectations
struct StubUserRepo : IUserRepo {
    std::optional<User> find(int) override {
        return User{1, "Alice"};   // always returns Alice
    }
    void save(const User&) override {}
};

// FAKE — real logic, no persistence
class InMemoryUserRepo : public IUserRepo {
    std::unordered_map<int, User> store_;
public:
    std::optional<User> find(int id) override {
        auto it = store_.find(id);
        return it != store_.end() ? std::optional{it->second} : std::nullopt;
    }
    void save(const User& u) override { store_[u.id] = u; }
};

// SPY — records calls for assertion
class SpyMailer : public IMailer {
public:
    std::vector<std::string> sent_to;
    void send(const std::string& addr, const std::string&) override {
        sent_to.push_back(addr);  // record, then optionally forward
    }
};

// MOCK (GMock) — verifies interactions
MockDatabase mock_db;
EXPECT_CALL(mock_db, find_user(42)).Times(1).WillOnce(Return(std::nullopt));

Prefer fakes and stubs for state-based tests. Reach for mocks only when the interaction itself is the observable outcome (e.g., verifying an email was sent exactly once).


Property-Based Testing Deep Dive

Property-based testing (PBT) finds edge cases that example-based tests miss by generating hundreds of random inputs automatically. RapidCheck shrinks failing inputs to the minimal reproducer, making failure diagnosis tractable.

cpp
#include <rapidcheck.h>
#include <rapidcheck/gtest.h>

// Generator customization for domain types
namespace rc {
template <>
struct Arbitrary<Config> {
    static Gen<Config> arbitrary() {
        return gen::build<Config>(
            gen::set(&Config::max_connections, gen::inRange(1, 10000)),
            gen::set(&Config::timeout_ms,      gen::inRange(1, 60000)),
            gen::set(&Config::name,            gen::string<std::string>())
        );
    }
};
}

// Properties — invariants that must hold for ALL inputs
RC_GTEST_PROP(SerializationProps, RoundTrip, (Config cfg)) {
    auto json     = cfg.to_json();
    auto restored = Config::from_json(json);
    RC_ASSERT(cfg == restored);
}

RC_GTEST_PROP(SortProps, StableSort, (std::vector<int> v)) {
    auto copy = v;
    std::stable_sort(v.begin(), v.end());
    // Invariant 1: same elements
    RC_ASSERT(std::is_permutation(v.begin(), v.end(), copy.begin()));
    // Invariant 2: non-decreasing
    for (size_t i = 1; i < v.size(); ++i)
        RC_ASSERT(v[i] >= v[i-1]);
}
bash
# Control number of generated cases via environment
RC_PARAMS="max_success=1000" ./tests

# Re-run a specific failing seed
RC_PARAMS="reproduce=<seed>" ./tests

RapidCheck integrates with both GTest and Catch2. For stateful systems, use rc::state::check to generate sequences of commands and verify that the system and a reference model stay in sync.


Mutation Testing

Mutation testing measures how well your tests actually detect bugs, not just how much code they execute. A mutant is a small syntactic change to production code (e.g., > → >=); if your tests still pass after the mutation, the tests are not catching that class of bug.

bash
# mutate-cpp: source-level C++ mutation tool
pip install mutate-cpp   # or build from source

# 1. Record a test baseline
mutate-cpp record -- cmake --build build && ctest --test-dir build

# 2. Run mutations and collect surviving/killed counts
mutate-cpp run --timeout 30s

# Report: mutation score = killed / total_mutants
# Example output:
# Mutants: 312 total, 287 killed, 25 survived
# Score: 92.0%
cpp
Common mutation operators:
  Relational: >  →  >=  |  ==  →  !=  |  <  →  <=
  Arithmetic: +  →  -   |  *   →  /
  Logical:    && →  ||  |  !x  →  x
  Constant:   0  →  1   |  true → false
  Statement:  delete return statement (void functions)

Pitfalls:
- Equivalent mutants: syntactically different but semantically identical → inflate "survived" count
- Test suite speed matters: each mutant requires a full test run
- Start with high-value modules (parsers, business logic) not utility code

A mutation score above 80% is a healthy target for critical modules. Do not chase 100% — equivalent mutants make it unachievable, and the effort is better spent on new test cases for uncovered behaviours.


Golden File (Snapshot) Testing

Golden file testing compares program output against a stored reference file, catching accidental regressions in formatted output, generated code, or complex data structures. It is especially effective for serializers, pretty-printers, and report generators.

cpp
#include <gtest/gtest.h>
#include <fstream>
#include <filesystem>

namespace fs = std::filesystem;

// Helper: read a file or return empty string
std::string read_file(const fs::path& p) {
    if (!fs::exists(p)) return "";
    std::ifstream f(p);
    return {std::istreambuf_iterator<char>(f), {}};
}

// Helper: write a file
void write_file(const fs::path& p, const std::string& content) {
    fs::create_directories(p.parent_path());
    std::ofstream f(p);
    f << content;
}

TEST(ReportTest, MonthlyReportMatchesGolden) {
    const fs::path golden = "tests/golden/monthly_report.txt";
    std::string actual = generate_monthly_report(test_data());

    if (std::getenv("UPDATE_GOLDEN")) {
        write_file(golden, actual);   // update golden file
        SUCCEED();
        return;
    }

    std::string expected = read_file(golden);
    EXPECT_EQ(actual, expected)
        << "Re-run with UPDATE_GOLDEN=1 to accept new output";
}
bash
# Normal CI run — compare against committed golden files
ctest --test-dir build

# Accept new output (after intentional change)
UPDATE_GOLDEN=1 ./build/tests --gtest_filter="ReportTest.*"
git diff tests/golden/   # review what changed
git add tests/golden/ && git commit -m "update golden files"

Commit golden files into the repository alongside source code. In CI, fail the build if any golden file is dirty after running tests — this prevents silent regressions from being merged.


Fuzz Testing in CI

Integrating fuzz targets into CI ensures the fuzzer runs continuously and corpus improvements are preserved across commits. OSS-Fuzz provides free fuzzing infrastructure for open-source projects; for private codebases, ClusterFuzz or a simple cron job suffices.

yaml
# .github/workflows/fuzzing.yml — run fuzzer for a time-boxed window on each PR
name: Fuzz
on: [push, pull_request]
jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Clang
        run: sudo apt-get install -y clang

      - name: Build fuzz targets
        run: |
          cmake -B build-fuzz \
            -DCMAKE_CXX_COMPILER=clang++ \
            -DCMAKE_CXX_FLAGS="-fsanitize=fuzzer,address,undefined" \
            -DBUILD_FUZZ_TARGETS=ON
          cmake --build build-fuzz -j$(nproc)

      - name: Restore corpus cache
        uses: actions/cache@v4
        with:
          path: corpus/
          key: fuzzcorpus-${{ github.ref }}
          restore-keys: fuzzcorpus-

      - name: Run fuzzer (60 s budget per target)
        run: |
          for target in build-fuzz/fuzz_*; do
            $target corpus/ seed_corpus/ -max_total_time=60 -jobs=2
          done

      - name: Save corpus
        uses: actions/cache/save@v4
        with:
          path: corpus/
          key: fuzzcorpus-${{ github.ref }}-${{ github.run_id }}
bash
# Reproduce a crash found in CI
./fuzz_parser crash-abc1234def

# Minimize crashing input
./fuzz_parser -minimize_crash=1 crash-abc1234def \
              -exact_artifact_path=minimized_crash

# Merge corpus (deduplicates and minimizes)
./fuzz_parser -merge=1 merged_corpus/ corpus/ seed_corpus/

Integration Testing Patterns

Integration tests verify that components work together correctly across real boundaries — file systems, network sockets, databases. The key challenge is port selection and service lifecycle management without flakiness.

cpp
// In-process HTTP server for integration tests (using httplib or similar)
#include <httplib.h>
#include <gtest/gtest.h>

class ApiIntegrationTest : public ::testing::Test {
protected:
    void SetUp() override {
        // Bind to port 0 — OS assigns a free ephemeral port
        server_ = std::make_unique<httplib::Server>();
        register_routes(*server_);
        port_ = server_->bind_to_any_port("127.0.0.1");
        server_thread_ = std::thread([this] { server_->listen_after_bind(); });
    }

    void TearDown() override {
        server_->stop();
        if (server_thread_.joinable()) server_thread_.join();
    }

    int port() const { return port_; }

private:
    std::unique_ptr<httplib::Server> server_;
    std::thread server_thread_;
    int port_{};
};

TEST_F(ApiIntegrationTest, GetUser) {
    httplib::Client cli("127.0.0.1", port());
    auto res = cli.Get("/users/1");
    ASSERT_TRUE(res);
    EXPECT_EQ(res->status, 200);
    EXPECT_THAT(res->body, ::testing::HasSubstr("\"id\":1"));
}
cmake
# Use CTest labels to separate fast unit tests from slower integration tests
add_test(NAME unit_math COMMAND tests_unit "[math]")
set_tests_properties(unit_math PROPERTIES LABELS "unit")

add_test(NAME integration_api COMMAND tests_integration)
set_tests_properties(integration_api PROPERTIES LABELS "integration" TIMEOUT 60)

# Run only unit tests in fast feedback loop
ctest --test-dir build -L unit -j8

# Run all tests in CI
ctest --test-dir build -j4 --output-on-failure

Coverage-Driven Development

Branch coverage tells you which logical paths have never been executed — uncovered branches are blind spots where bugs hide. Enforcing a minimum threshold in CI prevents gradual coverage erosion.

bash
# Build with LLVM source-based coverage (preferred over gcov)
cmake -B build-cov \
      -DCMAKE_CXX_FLAGS="-fprofile-instr-generate -fcoverage-mapping" \
      -DCMAKE_EXE_LINKER_FLAGS="-fprofile-instr-generate"
cmake --build build-cov

# Run tests and collect profile data
LLVM_PROFILE_FILE="coverage-%p.profraw" ./build-cov/tests
llvm-profdata merge -sparse coverage-*.profraw -o coverage.profdata

# Generate HTML report
llvm-cov show ./build-cov/tests \
    -instr-profile=coverage.profdata \
    -format=html -output-dir=coverage_html \
    -show-branches=count -show-line-counts

# Print summary to stdout (for CI threshold check)
llvm-cov report ./build-cov/tests \
    -instr-profile=coverage.profdata \
    --ignore-filename-regex="tests/|third_party/"
bash
# Enforce minimum branch coverage in CI (bash script)
BRANCH_COV=$(llvm-cov report ./build-cov/tests \
    -instr-profile=coverage.profdata \
    --ignore-filename-regex="tests/" 2>/dev/null \
    | awk '/TOTAL/ {gsub(/%/,""); print $8}')

THRESHOLD=75
if (( $(echo "$BRANCH_COV < $THRESHOLD" | bc -l) )); then
    echo "Branch coverage ${BRANCH_COV}% is below threshold ${THRESHOLD}%"
    exit 1
fi
echo "Branch coverage: ${BRANCH_COV}% (>= ${THRESHOLD}%)"
Coverage metricWhat it measuresTypical threshold
Line coverageLines executed at least once80–90%
Branch coverageBoth sides of every if/switch70–80%
Function coverageFunctions called at least once90–95%
Region coverageLLVM-level expression regions60–75%

Performance Regression Testing

Tracking benchmark results across commits catches performance regressions before they reach production. The key is storing results in a durable format and comparing against a baseline, not just running benchmarks ad-hoc.

cpp
// Google Benchmark — write benchmarks alongside unit tests
#include <benchmark/benchmark.h>

static void BM_JsonParse(benchmark::State& state) {
    const std::string json = load_fixture("large_payload.json");
    for (auto _ : state) {
        auto doc = parse_json(json);
        benchmark::DoNotOptimize(doc);
    }
    state.SetBytesProcessed(
        static_cast<int64_t>(state.iterations()) * json.size());
}
BENCHMARK(BM_JsonParse)->Range(1 << 10, 1 << 20)->Unit(benchmark::kMicrosecond);

BENCHMARK_MAIN();
bash
# Build benchmarks in Release mode (sanitizers off, optimizations on)
cmake -B build-bench -DCMAKE_BUILD_TYPE=Release -DBUILD_BENCHMARKS=ON
cmake --build build-bench -j$(nproc)

# Run and save baseline (on the main branch)
./build-bench/benchmarks --benchmark_out=baseline.json \
                          --benchmark_out_format=json

# Run on feature branch and compare
./build-bench/benchmarks --benchmark_out=pr.json \
                          --benchmark_out_format=json

# compare.py from google/benchmark repo
python tools/compare.py benchmarks baseline.json pr.json
# Output example:
# BM_JsonParse/1024   +0.12   +0.11  (12% slower — investigate)
yaml
# GitHub Actions: store benchmark results as artifacts and comment on PRs
- name: Run benchmarks
  run: ./build-bench/benchmarks --benchmark_out=results.json --benchmark_out_format=json

- name: Upload benchmark results
  uses: actions/upload-artifact@v4
  with:
    name: benchmark-results-${{ github.sha }}
    path: results.json

- name: Compare against main baseline
  run: |
    gh run download --name "benchmark-results-${{ github.base_ref }}" \
       --dir baseline/ || echo "No baseline found, skipping comparison"
    if [ -f baseline/results.json ]; then
      python tools/compare.py benchmarks baseline/results.json results.json
    fi

Set a regression threshold (e.g., reject PRs with >10% slowdown on critical benchmarks) and automate the comparison in CI rather than relying on manual inspection.