Testing, sanitizers and profiling

Structure tests with GoogleTest or Catch2, run sanitizers and coverage in CI, benchmark with Google Benchmark, and profile with perf.

Test structure with GoogleTest

#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "core/stack.h"

class StackTest : public ::testing::Test {
protected:
    void SetUp() override { s_.push(1); }
    void TearDown() override { /* release anything not RAII-managed */ }
    Stack s_;
};

TEST_F(StackTest, PushesAndPops) {
    s_.push(2);
    EXPECT_EQ(s_.pop(), 2);
    ASSERT_EQ(s_.size(), 1u);            // ASSERT stops the test; EXPECT continues
}

TEST_F(StackTest, ThrowsWhenEmpty) {
    Stack empty;
    EXPECT_THROW(empty.pop(), std::out_of_range);
    EXPECT_NO_THROW(empty.size());
}

TEST(ParseTest, HandlesEdgeCases) {
    EXPECT_EQ(parse("  42 "), 42);
    EXPECT_TRUE(parse("").has_value() == false);
    EXPECT_THAT(std::vector<int>{1, 2, 3}, ::testing::ElementsAre(1, 2, 3));
}

// parameterised test: one body, many inputs
class ParseCase : public ::testing::TestWithParam<std::pair<std::string, int>> {};
TEST_P(ParseCase, Accepts) { EXPECT_EQ(parse(GetParam().first), GetParam().second); }
INSTANTIATE_TEST_SUITE_P(Valid, ParseCase, ::testing::Values(
    std::make_pair("0", 0), std::make_pair("7", 7), std::make_pair("-3", -3)));

// death test for a documented precondition
TEST(PreconditionTest, AssertsOnBadIndex) {
    ASSERT_DEATH(access(-1), "index out of range|Assertion");
}
  • EXPECT_* records a failure and continues; ASSERT_* aborts the current test function. Never use ASSERT in a helper that must return void with a non-trivial destructor.
  • Prefer TEST_F with a fixture over repeating setup in every test, and keep fixtures small so failures stay local.
  • Death tests fork the process; they need a predicate or a message pattern and are slow, so use them sparingly.
  • Assert on observable behaviour, not on internal state. A test that reaches into private members breaks on every refactor.

Sanitizers, coverage and CI

# a matrix of builds is the practical answer
cmake -S . -B b-asan -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -g -fno-omit-frame-pointer"
cmake -S . -B b-tsan -DCMAKE_CXX_FLAGS="-fsanitize=thread -g"
cmake -S . -B b-cov  -DCMAKE_CXX_FLAGS="--coverage -g"

cmake --build b-asan -j && ctest --test-dir b-asan --output-on-failure
ctest --test-dir b-tsan --output-on-failure

# coverage report
gcovr -r . --html-details coverage.html

# lint and static analysis catch what tests do not
clang-tidy -p b-asan src/*.cpp --checks='bugprone-*,performance-*,modernize-*'
clang++ --analyze -std=c++20 src/*.cpp
ToolFindsCost
AddressSanitizerHeap overflow, use after free, leaksAbout 2x slower
UndefinedBehaviorSanitizerSigned overflow, bad shifts, misaligned accessSmall
ThreadSanitizerData races, lock order inversionsAbout 10x slower
Valgrind memcheckUninitialised reads, leaks in unmodified binariesAbout 20x slower
clang-tidySuspicious patterns, missing override, expensive copiesCompile-time only

Benchmarking and profiling

#include <benchmark/benchmark.h>
#include <vector>

static void BM_PushBack(benchmark::State& state) {
    for (auto _ : state) {
        std::vector<int> v;
        v.reserve(static_cast<std::size_t>(state.range(0)));
        for (int i = 0; i < state.range(0); ++i) v.push_back(i);
        benchmark::DoNotOptimize(v.data());     // stop the optimiser removing the work
    }
}
BENCHMARK(BM_PushBack)->Range(8, 8 << 12)->Unit(benchmark::kNanosecond);
BENCHMARK_MAIN();

/* run
   g++ -O2 -std=c++20 bench.cpp -lbenchmark -lpthread -o bench && ./bench
   perf stat ./bench            # counters: cycles, cache misses, branches
   perf record -g ./bench && perf report
   valgrind --tool=callgrind ./bench && callgrind_annotate callgrind.out.*   */
💡
Benchmark the release build with the same flags production uses, run each case several times, and look at the variance before the mean. A single run of a micro-benchmark is noise, and a benchmark that the optimiser deleted measures nothing at all.

FAQ

Why did coverage drop when I added tests?
Coverage usually falls when headers are included in the report or when a new template instantiates untested branches. Filter the report to your own translation units and exclude /usr/include before drawing conclusions.
How do I find a race that ThreadSanitizer misses?
Add stress: run the test in a loop, vary thread counts, and force context switches with a small sleep or sched_yield. Races that need a specific interleaving will not appear in a single clean run.

Concurrency: threads, mutexes and async Setting up C++: compilers, CMake and the build workflow

Last refreshed 2026-09-18.