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)
#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)
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
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
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)
#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
#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)
#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());
}
#endifProperty-Based Testing (RapidCheck)
#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
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
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# 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.
| Double | Definition | When to use |
|---|---|---|
| Stub | Returns hardcoded values; records nothing | Isolate SUT from a dependency whose output you control |
| Mock | Pre-programmed with expectations; verified after test | Assert that a specific interaction occurred (calls, order, arguments) |
| Fake | Lightweight working implementation (e.g. in-memory DB) | Integration tests that need realistic behavior without infra cost |
| Spy | Real object that also records calls for later assertions | Wrap a production dependency and observe side effects |
// 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.
#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]);
}# Control number of generated cases via environment
RC_PARAMS="max_success=1000" ./tests
# Re-run a specific failing seed
RC_PARAMS="reproduce=<seed>" ./testsRapidCheck 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.
# 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%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 codeA 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.
#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";
}# 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.
# .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 }}# 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.
// 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"));
}# 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-failureCoverage-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.
# 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/"# 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 metric | What it measures | Typical threshold |
|---|---|---|
| Line coverage | Lines executed at least once | 80–90% |
| Branch coverage | Both sides of every if/switch | 70–80% |
| Function coverage | Functions called at least once | 90–95% |
| Region coverage | LLVM-level expression regions | 60–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.
// 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();# 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)# 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
fiSet 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.