Table of Contents

  1. Google Test Integration
  2. gtest_discover_tests
  3. Catch2 Integration
  4. Boost.Test Integration
  5. doctest Integration
  6. Code Coverage
  7. Memory Analysis
  8. AddressSanitizer with Tests
  9. Test Organization Patterns
  10. Continuous Integration
Back to CMake Mastery Series

Part 13: Testing Frameworks Integration

June 4, 2026 Wasil Zafar 40 min read

Integrate popular testing frameworks with CMake, add code coverage and memory analysis, configure sanitizers, organize test suites at scale, and run everything in CI pipelines.

Google Test Integration

Google Test (GTest) is the most widely-used C++ testing framework. CMake provides first-class support through both FetchContent and find_package, plus the powerful gtest_discover_tests() for automatic test registration.

FetchContent with GTest

cmake_minimum_required(VERSION 3.24)
project(MyProject LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

include(CTest)
include(FetchContent)

# Fetch Google Test
FetchContent_Declare(
    googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG        v1.14.0
)
# Prevent GTest from overriding compiler/linker options on Windows
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)

# Build the library
add_library(calculator src/calculator.cpp)
target_include_directories(calculator PUBLIC include)

# Build tests
add_executable(test_calculator tests/test_calculator.cpp)
target_link_libraries(test_calculator PRIVATE
    calculator
    GTest::gtest_main   # Provides main() automatically
    GTest::gmock        # Google Mock for mocking
)

# Register tests with CTest
include(GoogleTest)
gtest_discover_tests(test_calculator)

If Google Test is already installed on the system (e.g., via a package manager), use find_package:

cmake_minimum_required(VERSION 3.24)
project(MyProject LANGUAGES CXX)

include(CTest)

# Find system-installed GTest
find_package(GTest REQUIRED)

add_executable(test_math tests/test_math.cpp)
target_link_libraries(test_math PRIVATE
    GTest::gtest_main
    GTest::gmock
)

include(GoogleTest)
gtest_discover_tests(test_math)

gtest_discover_tests

The gtest_discover_tests() function (from the GoogleTest module) queries your test executable at build time to discover all TEST() and TEST_F() macros, registering each as a separate CTest test:

cmake_minimum_required(VERSION 3.24)
project(DiscoverDemo LANGUAGES CXX)

include(CTest)
include(FetchContent)

FetchContent_Declare(googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG        v1.14.0
)
FetchContent_MakeAvailable(googletest)

add_executable(test_all
    tests/test_math.cpp
    tests/test_string.cpp
    tests/test_container.cpp
)
target_link_libraries(test_all PRIVATE GTest::gtest_main)

include(GoogleTest)

# Discover all tests with custom options
gtest_discover_tests(test_all
    # Prefix test names for clarity in ctest output
    TEST_PREFIX "unit."
    # Pass extra properties to all discovered tests
    PROPERTIES
        LABELS "unit"
        TIMEOUT 30
    # Set working directory for tests
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/tests
    # Discovery timeout (increase for slow systems)
    DISCOVERY_TIMEOUT 60
)
Key Insight: gtest_discover_tests runs at build time (not configure time), so it automatically picks up new TEST() macros without re-running CMake. This makes it far superior to the older gtest_add_tests() which required re-configuration.

Properties Propagation

// tests/test_math.cpp — Google Test example
#include <gtest/gtest.h>
#include "calculator.h"

TEST(Calculator, Addition) {
    Calculator calc;
    EXPECT_EQ(calc.add(2, 3), 5);
    EXPECT_EQ(calc.add(-1, 1), 0);
    EXPECT_EQ(calc.add(0, 0), 0);
}

TEST(Calculator, Division) {
    Calculator calc;
    EXPECT_DOUBLE_EQ(calc.divide(10, 2), 5.0);
    EXPECT_THROW(calc.divide(1, 0), std::invalid_argument);
}

TEST(Calculator, DISABLED_SlowTest) {
    // This test is disabled — won't run but will show as "Not Run"
    Calculator calc;
    // ... expensive computation
}
# After building, ctest sees individual tests:
$ ctest -N
Test #1: unit.Calculator.Addition
Test #2: unit.Calculator.Division
Test #3: unit.Calculator.DISABLED_SlowTest

# Run just the Calculator suite:
$ ctest -R "Calculator"

Catch2 Integration

Catch2 v3 is a modern header-light testing framework with BDD support and excellent integration with CMake:

cmake_minimum_required(VERSION 3.24)
project(Catch2Demo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
include(CTest)
include(FetchContent)

# Fetch Catch2 v3
FetchContent_Declare(
    Catch2
    GIT_REPOSITORY https://github.com/catchorg/Catch2.git
    GIT_TAG        v3.5.4
)
FetchContent_MakeAvailable(Catch2)

# Build test executable
add_executable(test_app
    tests/test_math.cpp
    tests/test_strings.cpp
)
target_link_libraries(test_app PRIVATE Catch2::Catch2WithMain)

# Automatic test discovery (similar to gtest_discover_tests)
list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
include(Catch)
catch_discover_tests(test_app
    TEST_PREFIX "catch."
    REPORTER junit    # Output JUnit XML for CI
)

Catch2 BDD Style Tests

// tests/test_strings.cpp — Catch2 BDD example
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "string_utils.h"

using Catch::Matchers::StartsWith;
using Catch::Matchers::EndsWith;

SCENARIO("String trimming removes whitespace", "[strings][trim]") {
    GIVEN("A string with leading and trailing spaces") {
        std::string input = "  hello world  ";

        WHEN("trim is applied") {
            auto result = trim(input);

            THEN("leading and trailing spaces are removed") {
                REQUIRE(result == "hello world");
                REQUIRE_THAT(result, !StartsWith(" "));
                REQUIRE_THAT(result, !EndsWith(" "));
            }
        }
    }

    GIVEN("A string with no whitespace") {
        std::string input = "hello";

        WHEN("trim is applied") {
            auto result = trim(input);

            THEN("the string is unchanged") {
                REQUIRE(result == input);
            }
        }
    }
}

Boost.Test Integration

If your project already uses Boost, Boost.Test integrates naturally:

cmake_minimum_required(VERSION 3.24)
project(BoostTestDemo LANGUAGES CXX)

include(CTest)

# Find Boost with the unit_test_framework component
find_package(Boost 1.80 REQUIRED COMPONENTS unit_test_framework)

add_executable(test_boost tests/test_boost.cpp)
target_link_libraries(test_boost PRIVATE Boost::unit_test_framework)

# Boost.Test doesn't have automatic discovery like GTest
# Register tests manually or use a wrapper
add_test(NAME boost_math    COMMAND test_boost --run_test=MathSuite)
add_test(NAME boost_strings COMMAND test_boost --run_test=StringSuite)

set_tests_properties(boost_math boost_strings PROPERTIES
    LABELS "boost"
    TIMEOUT 30
)
// tests/test_boost.cpp
#define BOOST_TEST_MODULE MyProjectTests
#include <boost/test/unit_test.hpp>
#include "math_utils.h"

BOOST_AUTO_TEST_SUITE(MathSuite)

BOOST_AUTO_TEST_CASE(test_factorial) {
    BOOST_CHECK_EQUAL(factorial(0), 1);
    BOOST_CHECK_EQUAL(factorial(5), 120);
    BOOST_CHECK_THROW(factorial(-1), std::invalid_argument);
}

BOOST_AUTO_TEST_SUITE_END()

doctest Integration

doctest is a single-header testing framework known for extremely fast compilation. It's ideal for projects where build time matters:

cmake_minimum_required(VERSION 3.24)
project(DoctestDemo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
include(CTest)
include(FetchContent)

# Fetch doctest (header-only — very fast to fetch)
FetchContent_Declare(
    doctest
    GIT_REPOSITORY https://github.com/doctest/doctest.git
    GIT_TAG        v2.4.11
)
FetchContent_MakeAvailable(doctest)

add_executable(test_doctest tests/test_main.cpp tests/test_math.cpp)
target_link_libraries(test_doctest PRIVATE doctest::doctest)

# doctest provides its own discovery module
list(APPEND CMAKE_MODULE_PATH ${doctest_SOURCE_DIR}/scripts/cmake)
include(doctest)
doctest_discover_tests(test_doctest
    TEST_PREFIX "dt."
)
// tests/test_math.cpp — doctest example
#include <doctest/doctest.h>
#include "math_utils.h"

TEST_CASE("Factorial computation") {
    CHECK(factorial(0) == 1);
    CHECK(factorial(1) == 1);
    CHECK(factorial(5) == 120);

    SUBCASE("Negative input throws") {
        CHECK_THROWS_AS(factorial(-1), std::invalid_argument);
    }
}
Testing Framework Selection Decision Tree
        flowchart TD
            A{Need mocking?} -->|Yes| B[Google Test + GMock]
            A -->|No| C{Build time critical?}
            C -->|Yes| D[doctest]
            C -->|No| E{BDD style preferred?}
            E -->|Yes| F[Catch2]
            E -->|No| G{Already using Boost?}
            G -->|Yes| H[Boost.Test]
            G -->|No| I[Google Test]
            style B fill:#d4edda,stroke:#28a745
            style D fill:#d4edda,stroke:#28a745
            style F fill:#d4edda,stroke:#28a745
            style H fill:#d4edda,stroke:#28a745
            style I fill:#d4edda,stroke:#28a745
    

Code Coverage

gcov, lcov, and gcovr

Code coverage measures which lines of your source code are exercised by tests. With GCC/Clang, use --coverage flags:

cmake_minimum_required(VERSION 3.24)
project(CoverageDemo LANGUAGES CXX)

include(CTest)

# Define a coverage build type
option(ENABLE_COVERAGE "Enable code coverage" OFF)

add_library(mylib src/mylib.cpp)
target_include_directories(mylib PUBLIC include)

if(ENABLE_COVERAGE)
    # Add coverage flags to library and tests
    target_compile_options(mylib PRIVATE --coverage -O0 -g)
    target_link_options(mylib PRIVATE --coverage)
endif()

# Tests
add_executable(test_mylib tests/test_mylib.cpp)
target_link_libraries(test_mylib PRIVATE mylib GTest::gtest_main)

if(ENABLE_COVERAGE)
    target_compile_options(test_mylib PRIVATE --coverage -O0 -g)
    target_link_options(test_mylib PRIVATE --coverage)
endif()

include(GoogleTest)
gtest_discover_tests(test_mylib)

Custom Coverage Target

# Add a custom target to generate coverage reports
if(ENABLE_COVERAGE)
    find_program(LCOV lcov REQUIRED)
    find_program(GENHTML genhtml REQUIRED)

    add_custom_target(coverage
        # Clean previous data
        COMMAND ${LCOV} --directory . --zerocounters
        # Run tests
        COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure
        # Capture coverage data
        COMMAND ${LCOV} --directory . --capture --output-file coverage.info
        # Remove system headers and test files from report
        COMMAND ${LCOV} --remove coverage.info
            '/usr/*' '*/tests/*' '*/googletest/*'
            --output-file coverage_filtered.info
        # Generate HTML report
        COMMAND ${GENHTML} coverage_filtered.info
            --output-directory coverage_report
            --title "MyLib Coverage"
            --legend --show-details
        WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
        COMMENT "Generating code coverage report..."
    )
endif()
# Build with coverage enabled
cmake -B build -DENABLE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build

# Generate the coverage report
cmake --build build --target coverage

# Open the report
open build/coverage_report/index.html
Alternative gcovr for Coverage (Simpler, Python-based)

If you prefer a Python-based tool that generates Cobertura XML (for CI) and HTML in one step:

if(ENABLE_COVERAGE)
    find_program(GCOVR gcovr REQUIRED)

    add_custom_target(coverage
        COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure
        COMMAND ${GCOVR}
            --root ${CMAKE_SOURCE_DIR}
            --exclude "tests/"
            --html-details coverage_report/index.html
            --xml coverage.xml
            --print-summary
        WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
        COMMENT "Generating coverage with gcovr..."
    )
endif()
gcovr python-based cobertura-xml

Memory Analysis

CTest has built-in support for memory checking tools like Valgrind and DrMemory:

cmake_minimum_required(VERSION 3.24)
project(MemcheckDemo LANGUAGES CXX)

include(CTest)

# Set memory check command (usually auto-detected if Valgrind is installed)
set(CTEST_MEMORYCHECK_COMMAND "/usr/bin/valgrind")
set(CTEST_MEMORYCHECK_COMMAND_OPTIONS
    "--tool=memcheck --leak-check=full --show-leak-kinds=all --track-origins=yes"
)
# Suppression file for known acceptable leaks
set(CTEST_MEMORYCHECK_SUPPRESSIONS_FILE
    "${CMAKE_SOURCE_DIR}/valgrind.supp"
)

add_executable(test_memory tests/test_memory.cpp)
target_link_libraries(test_memory PRIVATE mylib GTest::gtest_main)

include(GoogleTest)
gtest_discover_tests(test_memory)
# Run tests under Valgrind memcheck
ctest -T memcheck

# Or run Valgrind manually for more control
valgrind --tool=memcheck --leak-check=full \
    --show-leak-kinds=all --track-origins=yes \
    ./build/test_memory

# On Windows, use DrMemory instead
# Set in CTestCustom.cmake or via:
# ctest -D CTEST_MEMORYCHECK_COMMAND="C:/DrMemory/bin/drmemory.exe" -T memcheck

AddressSanitizer with Tests

Sanitizers (ASan, UBSan, TSan) catch bugs at runtime with minimal overhead compared to Valgrind:

cmake_minimum_required(VERSION 3.24)
project(SanitizerDemo LANGUAGES CXX)

include(CTest)

option(ENABLE_ASAN "Enable AddressSanitizer" OFF)
option(ENABLE_UBSAN "Enable UndefinedBehaviorSanitizer" OFF)
option(ENABLE_TSAN "Enable ThreadSanitizer" OFF)

add_library(mylib src/mylib.cpp)

# Apply sanitizer flags globally
if(ENABLE_ASAN)
    add_compile_options(-fsanitize=address -fno-omit-frame-pointer -O1 -g)
    add_link_options(-fsanitize=address)
endif()

if(ENABLE_UBSAN)
    add_compile_options(-fsanitize=undefined -fno-omit-frame-pointer -O1 -g)
    add_link_options(-fsanitize=undefined)
endif()

if(ENABLE_TSAN)
    add_compile_options(-fsanitize=thread -O1 -g)
    add_link_options(-fsanitize=thread)
endif()

# Tests
add_executable(test_mylib tests/test_mylib.cpp)
target_link_libraries(test_mylib PRIVATE mylib GTest::gtest_main)

include(GoogleTest)
gtest_discover_tests(test_mylib)
# Build with AddressSanitizer
cmake -B build-asan -DENABLE_ASAN=ON
cmake --build build-asan
cd build-asan && ctest --output-on-failure

# Build with UndefinedBehaviorSanitizer
cmake -B build-ubsan -DENABLE_UBSAN=ON
cmake --build build-ubsan
cd build-ubsan && ctest --output-on-failure
Important: Never combine ASan with TSan in the same build — they're incompatible. Create separate build directories for each sanitizer. Also note that sanitizers add ~2× runtime overhead, so don't enable them in production builds.

Test Organization Patterns

For large projects, organize tests into separate targets by scope:

cmake_minimum_required(VERSION 3.24)
project(OrganizedTests LANGUAGES CXX)

include(CTest)
include(FetchContent)

FetchContent_Declare(googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG        v1.14.0
)
FetchContent_MakeAvailable(googletest)
include(GoogleTest)

# ─── Unit Tests (fast, isolated) ─────────────────────────────
add_executable(test_unit
    tests/unit/test_math.cpp
    tests/unit/test_string.cpp
    tests/unit/test_container.cpp
)
target_link_libraries(test_unit PRIVATE mylib GTest::gtest_main)
gtest_discover_tests(test_unit
    TEST_PREFIX "unit."
    PROPERTIES LABELS "unit" TIMEOUT 10
)

# ─── Integration Tests (slower, may need resources) ──────────
add_executable(test_integration
    tests/integration/test_database.cpp
    tests/integration/test_api.cpp
)
target_link_libraries(test_integration PRIVATE mylib GTest::gtest_main)
gtest_discover_tests(test_integration
    TEST_PREFIX "integ."
    PROPERTIES LABELS "integration" TIMEOUT 60
)

# ─── Benchmark Tests (separate, not in default CTest run) ────
add_executable(bench_mylib tests/bench/bench_main.cpp)
target_link_libraries(bench_mylib PRIVATE mylib)
# Don't register with CTest — run manually
Pattern Per-Library Test Targets

In a multi-library project, each library gets its own test executable:

project/
├── libs/
│   ├── core/
│   │   ├── CMakeLists.txt      # add_library(core ...)
│   │   ├── src/
│   │   └── tests/
│   │       ├── CMakeLists.txt  # add_executable(test_core ...)
│   │       └── test_core.cpp
│   └── network/
│       ├── CMakeLists.txt      # add_library(network ...)
│       ├── src/
│       └── tests/
│           ├── CMakeLists.txt  # add_executable(test_network ...)
│           └── test_network.cpp
└── CMakeLists.txt              # Top-level
# libs/core/tests/CMakeLists.txt
add_executable(test_core test_core.cpp)
target_link_libraries(test_core PRIVATE core GTest::gtest_main)
gtest_discover_tests(test_core TEST_PREFIX "core.")

# Run only core tests:
# ctest -R "^core\."
multi-library per-target-tests scalable

Continuous Integration

Running CMake tests in CI pipelines with proper reporting:

# .github/workflows/test.yml — GitHub Actions example
name: CMake Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure
        run: cmake -B build -DCMAKE_BUILD_TYPE=Release

      - name: Build
        run: cmake --build build -j$(nproc)

      - name: Test
        run: |
          cd build
          ctest --output-on-failure --output-junit test-results.xml

      - name: Upload Test Results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: build/test-results.xml
# GitLab CI example (.gitlab-ci.yml)
test:
  stage: test
  image: gcc:13
  script:
    - cmake -B build -DCMAKE_BUILD_TYPE=Debug -DENABLE_COVERAGE=ON
    - cmake --build build -j$(nproc)
    - cd build && ctest --output-on-failure --output-junit report.xml
    - gcovr --root .. --exclude "tests/" --xml coverage.xml
  artifacts:
    reports:
      junit: build/report.xml
      coverage_report:
        coverage_format: cobertura
        path: build/coverage.xml
CI Pipeline with CMake Testing
        flowchart LR
            A[Push / PR] --> B[Configure]
            B --> C[Build]
            C --> D[Unit Tests]
            C --> E[Integration Tests]
            D --> F[Coverage Report]
            E --> F
            F --> G{All Pass?}
            G -->|Yes| H[Deploy / Merge]
            G -->|No| I[Notify / Block]
            style A fill:#f8f9fa,stroke:#3B9797
            style H fill:#d4edda,stroke:#28a745
            style I fill:#f8d7da,stroke:#dc3545
    
# CTest output formats for CI:
ctest --output-junit results.xml        # JUnit XML (GitHub Actions, GitLab)
ctest --output-on-failure               # Print stdout/stderr only for failures
ctest -T Test                           # CDash submission format
ctest --test-output-size-passed 65536   # Limit output size per test
Official Reference: See GoogleTest module, FindBoost, and ctest(1) for complete API references.