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 useASSERTin a helper that must return void with a non-trivial destructor.- Prefer
TEST_Fwith 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| Tool | Finds | Cost |
|---|---|---|
| AddressSanitizer | Heap overflow, use after free, leaks | About 2x slower |
| UndefinedBehaviorSanitizer | Signed overflow, bad shifts, misaligned access | Small |
| ThreadSanitizer | Data races, lock order inversions | About 10x slower |
| Valgrind memcheck | Uninitialised reads, leaks in unmodified binaries | About 20x slower |
| clang-tidy | Suspicious patterns, missing override, expensive copies | Compile-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.Related
Concurrency: threads, mutexes and async Setting up C++: compilers, CMake and the build workflow
Last refreshed 2026-09-18.