Skip to content

Unit Testing

In a standard workflow, unit tests are compiled and executed as part of the build process. The build System must be capable of resolving testing dependencies, compiling test suites, and executing Verification passes as part of the standard workflow.

This module introduces the integration of CTest (the standard CMake test runner) with the two Dominant C++ testing frameworks: GoogleTest (GTest) and Catch2.

CTest is the test orchestration tool distributed with CMake. It does not define how to write Tests (syntax); rather, it defines how to execute them.

CTest operates on a simple protocol:

  1. It invokes an executable.
  2. If the executable returns Exit Code 0The test PASSED.
  3. If the executable returns non-zero, segfaults, or times out, the test FAILED.

While one could manually write a C++ program with assert()Professional development requires Frameworks that provide structured assertions, test fixtures, and machine-readable output (XML/JSON) For CI/CD pipelines.

To ensure build reproducibility, testing frameworks should not be installed globally on the System (e.g., via apt or brew). Version mismatches between the CI server and local machines Cause divergent behaviors.

The architectural best practice is to compile the test framework from source as part of the project Build using CMake’s FetchContent module. This locks the framework version to the project commit.

GoogleTest is the industry standard for C++ unit testing. It follows a strict xUnit architecture (Test Cases, Fixtures, Assertions).

The following configuration demonstrates how to fetch GoogleTest 1.14.0 and register a test suite.

File: tests/CMakeLists.txt

include(FetchContent)
# 1. Declare Dependency
FetchContent_Declare(
googletest
URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip
)
# 2. Make available (downloads and adds targets)
# setting INSTALL_GTEST=OFF prevents installing GTest when running 'make install' on the project
set(INSTALL_GTEST OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
# 3. Create Test Executable
add_executable(UnitTests
test_main.cpp
test_math.cpp
)
# 4. Link GTest
# GTest::gtest_main includes a standard main() entry point.
target_link_libraries(UnitTests PRIVATE GTest::gtest_main)
# 5. Register with CTest (Modern Discovery)
include(GoogleTest)
gtest_discover_tests(UnitTests)

Discovery Mechanics (gtest_discover_tests)

Section titled “Discovery Mechanics (gtest_discover_tests)”

Legacy CMake used add_test()Which treated the entire UnitTests executable as a single test. This is undesirable because a crash in test #1 prevents running test #100.

gtest_discover_tests:

  1. Runs the executable with --gtest_list_tests post-build.
  2. Parses the output.
  3. Registers every individual test case (e.g., MathTest.Addition) as a distinct CTest entry.

Catch2 is favored for its modern syntax, requiring no compilation of test fixtures for simple cases. Version 3 (v3) is a significant architectural shift from v2; it is no longer header-only and Requires compilation to improve build times.

File: tests/CMakeLists.txt

include(FetchContent)
# 1. Declare Dependency
FetchContent_Declare(
Catch2
URL https://github.com/catchorg/Catch2/archive/refs/tags/v3.4.0.zip
)
# 2. Make available
FetchContent_MakeAvailable(Catch2)
# 3. Create Test Executable
add_executable(CatchTests
test_main.cpp
test_vectors.cpp
)
# 4. Link Catch2
# Catch2::Catch2WithMain provides the entry point.
target_link_libraries(CatchTests PRIVATE Catch2::Catch2WithMain)
# 5. Register with CTest
include(Catch)
catch_discover_tests(CatchTests)

A separation of concerns is required between application source code and test code.

ProjectRoot/
├── CMakeLists.txt # Root definition
├── CMakePresets.json # Build configuration
├── src/ # Application Source
│ ├── engine.cpp
│ └── CMakeLists.txt # Defines library target 'Engine'
├── tests/ # Test Source
│ ├── test_engine.cpp
│ └── CMakeLists.txt # Defines executable 'UnitTests'

Root CMakeLists.txt Configuration:

cmake_minimum_required(VERSION 3.25)
project(HighPerfSystem LANGUAGES CXX)
# Standard testing hook
enable_testing()
add_subdirectory(src)
add_subdirectory(tests)

Linking Strategy: The UnitTests target inside tests/CMakeLists.txt must link against the Library defined in src/.

target_link_libraries(UnitTests
PRIVATE
GTest::gtest_main
Engine # The library under test
)

CTest aggregates results from all registered test suites.

Terminal window
# 1. Build the project (including tests)
cmake --build build
# 2. Run tests
cd build
ctest --output-on-failure

Key Flags:

  • --output-on-failure: Prints stdout/stderr only for failed tests.
  • -j <N>: Run tests in parallel (e.g., ctest -j 8).
  • -R <Regex>: Run specific tests (e.g., ctest -R Math runs only math tests).

Define a Test Preset in CMakePresets.json to standardize execution arguments.

{
"testPresets": [
{
"name": "default",
"configurePreset": "linux-clang-debug",
"output": {
"outputOnFailure": true
},
"execution": {
"jobs": 8,
"noTestsAction": "error"
}
}
]
}

Execution:

Terminal window
ctest --preset default

Test fixtures provide a mechanism for shared setup and teardown logic across multiple test cases. This eliminates code duplication when tests require identical preconditions.

#include <gtest/gtest.h>
#include <vector>
class VectorTest : public ::testing::Test {
protected:
void SetUp() override {
data = {1, 2, 3, 4, 5};
data.reserve(100);
}
void TearDown() override {
// Called after each test. Useful for cleanup.
data.clear();
}
std::vector<int> data;
};
TEST_F(VectorTest, SizeIsCorrect) {
EXPECT_EQ(data.size(), 5u);
}
TEST_F(VectorTest, FrontIsOne) {
EXPECT_EQ(data.front(), 1);
}
TEST_F(VectorTest, CapacityPreserved) {
EXPECT_GE(data.capacity(), 100u);
}

The TEST_F macro registers each test as a method of the VectorTest class. SetUp() runs before Each test, and TearDown() runs after each test. Each test gets a fresh instance of the fixture.

#include <catch2/catch_test_macros.hpp>
#include <vector>
struct VectorFixture {
std::vector<int> data;
VectorFixture() {
data = {1, 2, 3, 4, 5};
data.reserve(100);
}
~VectorFixture() {
data.clear();
}
};
TEST_CASE_METHOD(VectorFixture, "Vector size is correct") {
REQUIRE(data.size() == 5);
}
TEST_CASE_METHOD(VectorFixture, "Vector front is one") {
REQUIRE(data.front() == 1);
}

Parameterized tests allow running the same test logic with different input data, avoiding copy-paste Of test cases that differ only in their input values.

#include <gtest/gtest.h>
#include <cstdint>
struct PrimeTestParam {
uint32_t input;
bool expected;
};
class PrimeTest : public ::testing::TestWithParam<PrimeTestParam> {};
TEST_P(PrimeTest, ReturnsCorrectResult) {
auto [input, expected] = GetParam();
// Implementation of is_prime omitted for brevity
bool result = is_prime(input);
EXPECT_EQ(result, expected);
}
INSTANTIATE_TEST_SUITE_P(
PrimeNumbers,
PrimeTest,
::testing::Values(
PrimeTestParam{2, true},
PrimeTestParam{3, true},
PrimeTestParam{4, false},
PrimeTestParam{5, true},
PrimeTestParam{9, false},
PrimeTestParam{17, true},
PrimeTestParam{25, false}
)
);

With gtest_discover_testsEach instantiation appears as a separate test in CTest: PrimeNumbers/PrimeTest.ReturnsCorrectResult/0``PrimeNumbers/PrimeTest.ReturnsCorrectResult/1 Etc.

CTest allows setting properties on individual tests via set_tests_properties or through the Discovery commands:

# Set timeout for long-running tests
set_tests_properties(LongRunningTest PROPERTIES
TIMEOUT 60 # seconds
)
# Set environment variables for a test
set_tests_properties(DatabaseTest PROPERTIES
ENVIRONMENT "DB_HOST=localhost;DB_PORT=5432"
)
# Assign labels for filtering
set_tests_properties(
NetworkTest
DatabaseTest
PROPERTIES
LABELS "integration;slow"
)
# Skip a test conditionally
set_tests_properties(FlakyTest PROPERTIES
SKIP_RETURN_CODE 77
)
Terminal window
# Run only integration tests
ctest -L integration
# Run all tests except slow ones
ctest -LE slow
# Run with a timeout override
ctest --timeout 120

Coverage analysis measures which lines, branches, and functions of your source code are exercised by The test suite. This is critical for identifying untested code paths.

Terminal window
# 1. Compile with coverage instrumentation
clang++ -fprofile-instr-generate -fcoverage-mapping -g -O0 \
src/engine.cpp tests/test_engine.cpp \
-lgtest -lgtest_main -pthread -o test_runner
# 2. Run the tests (generates .profraw files)
LLVM_PROFILE_FILE="coverage_%p.profraw" ./test_runner
# 3. Merge profile data
llvm-profdata merge -sparse coverage_*.profraw -o coverage.profdata
# 4. Generate coverage report
llvm-cov show ./test_runner -instr-profile=coverage.profdata \
-format=html -output-dir=coverage_report
# 5. Summary
llvm-cov report ./test_runner -instr-profile=coverage.profdata
Terminal window
# 1. Compile with coverage
g++ --coverage -g -O0 src/engine.cpp tests/test_engine.cpp \
-lgtest -lgtest_main -pthread -o test_runner
# 2. Run tests (generates .gcda files)
./test_runner
# 3. Generate HTML report with lcov
lcov --capture --directory . --output-file coverage.info
lcov --remove coverage.info '/usr/*' '*/tests/*' '*/third_party/*' \
--output-file coverage_filtered.info
genhtml coverage_filtered.info --output-directory coverage_report
# Add coverage flags as a separate build type
set(CMAKE_CXX_FLAGS_COVERAGE "-g -O0 --coverage" CACHE STRING "")
set(CMAKE_EXE_LINKER_FLAGS_COVERAGE "--coverage" CACHE STRING "")
# Use via preset or build type:
# cmake --preset coverage
# cmake --build build --config Coverage

When testing a component that depends on an external system (database, network, filesystem), you Substitute the real dependency with a mock that records calls and returns predefined values.

#include <gmock/gmock.h>
#include <gtest/gtest.h>
class Database {
public:
virtual ~Database() = default;
virtual bool insert(const std::string& key, const std::string& value) = 0;
virtual std::optional<std::string> get(const std::string& key) = 0;
};
class MockDatabase : public Database {
public:
MOCK_METHOD(bool, insert, (const std::string& key, const std::string& value), (override));
MOCK_METHOD(std::optional<std::string>, get, (const std::string& key), (override));
};
class Cache {
public:
Cache(Database& db) : db_(db) {}
bool put(const std::string& key, const std::string& value) {
return db_.insert(key, value);
}
std::optional<std::string> find(const std::string& key) {
return db_.get(key);
}
private:
Database& db_;
};
TEST(CacheTest, PutDelegatesToDatabase) {
MockDatabase mock_db;
Cache cache(mock_db);
EXPECT_CALL(mock_db, insert("user:1", "Alice"))
.WillOnce(::testing::Return(true));
EXPECT_TRUE(cache.put("user:1", "Alice"));
}
TEST(CacheTest, FindReturnsValue) {
MockDatabase mock_db;
Cache cache(mock_db);
EXPECT_CALL(mock_db, get("user:1"))
.WillOnce(::testing::Return(std::make_optional<std::string>("Alice")));
auto result = cache.find("user:1");
ASSERT_TRUE(result.has_value());
EXPECT_EQ(*result, "Alice");
}
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5 # for FetchContent downloads
- name: Configure
run: cmake --preset linux-clang-debug
- name: Build
run: cmake --build --preset debug
- name: Test
run: ctest --preset default
- name: Coverage
run: |
cmake --build build --target coverage
bash <(curl -s https://codecov.io/bash)
# In CMakePresets.json: sanitizer preset
{
"configurePresets": [
{
"name": "asan",
"inherits": "base",
"cacheVariables": {
"CMAKE_CXX_FLAGS": "-fsanitize=address -fno-omit-frame-pointer",
"CMAKE_EXE_LINKER_FLAGS": "-fsanitize=address"
}
}
]
}
  1. Macro Isolation: Do not expose GTest/Catch2 macros in your public headers. Tests are implementation details.
  2. Private Visibility: If you need to test private class members, prefer the Friend Fixture pattern or compile private implementations as a separate OBJECT library rather than making members public.
  3. Sanitizers: Running tests with Address Sanitizer (ASan) enabled is the primary method for detecting memory leaks. Ensure your testPresets inherit from sanitizer-enabled build configurations.

As the test suite grows, the test executable becomes large and slow to compile. Split tests into Multiple executables grouped by module:

add_executable(EngineTests test_engine.cpp)
target_link_libraries(EngineTests PRIVATE GTest::gtest_main Engine)
add_executable(MathTests test_math.cpp)
target_link_libraries(MathTests PRIVATE GTest::gtest_main MathLib)
gtest_discover_tests(EngineTests)
gtest_discover_tests(MathTests)

Tests that modify global state (singletons, environment variables, filesystem) can cause ordering Dependencies: test B passes when run alone but fails when run after test A. Use fixtures with proper Setup/teardown to isolate each test.

Tests that depend on timing (sleep, timeout) are inherently non-deterministic. Avoid them. If timing Is unavoidable, use generous timeouts and mark the test as flaky in CI.

4. gtest_discover_tests Requires the Binary to Run Post-Build

Section titled “4. gtest_discover_tests Requires the Binary to Run Post-Build”

If the test binary fails to execute during the discovery phase (e.g., missing shared library at Runtime), CMake configuration fails. Ensure all runtime dependencies (.so/.dll files) are Findable via LD_LIBRARY_PATH``PATHOr CMAKE_INSTALL_RPATH.

Catch2 v3 is a compiled library, not header-only. If your project still uses v2 patterns (e.g., CATCH_CONFIG_MAIN without linking Catch2::Catch2Main), the build will fail. Update all test Targets to link against Catch2::Catch2WithMain and remove the CATCH_CONFIG_MAIN define.

This topic covers the essential concepts and techniques related to unit testing, including key principles and practical applications.

Key concepts include:

  • core concepts and definitions
  • key principles and frameworks
  • practical applications
  • common techniques and methods
  • evaluation and critical analysis

A thorough understanding of these concepts, combined with regular practice and review, is essential for mastery of this topic.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.