CTest and CMake: Complete Test Integration Guide

CTest and CMake: Complete Test Integration Guide

CTest is CMake's built-in test runner. It discovers and executes tests defined in your CMakeLists.txt, reports results uniformly regardless of the underlying test framework (gtest, Catch2, Boost.Test, or a plain executable), and integrates with CI via standard exit codes and XML reports.


What CTest Is (and Isn't)

CTest doesn't write tests — it runs them. It's an orchestration layer that:

  • Discovers tests via add_test() or framework-specific discovery macros (gtest_discover_tests, catch_discover_tests)
  • Runs tests in parallel with -j
  • Filters by label, regex, or test number
  • Collects results into a uniform report
  • Integrates with CDash for result submission

Your tests can use any framework. CTest just calls your executables and checks exit codes.


Basic Setup

cmake_minimum_required(VERSION 3.20)
project(MyProject CXX)

# Required to enable ctest command
enable_testing()

add_subdirectory(src)
add_subdirectory(tests)
# tests/CMakeLists.txt

# Basic test: add_test(NAME <name> COMMAND <executable> [args])
add_test(NAME unit_tests COMMAND $<TARGET_FILE:unit_tests_exe>)
add_test(NAME integration_tests COMMAND $<TARGET_FILE:integration_tests_exe>)

Run:

cmake -B build && cmake --build build
cd build && ctest

Test Properties

Set properties to control timeout, environment, labels, and failure behavior:

# tests/CMakeLists.txt

add_test(NAME slow_integration_test COMMAND integration_tests_exe)

set_tests_properties(slow_integration_test PROPERTIES
    TIMEOUT 120                           # kill after 120 seconds
    ENVIRONMENT "DB_HOST=localhost"        # set env vars
    LABELS "integration;slow"             # for filtering
    PASS_REGULAR_EXPRESSION "PASSED"      # pass only if output matches
    FAIL_REGULAR_EXPRESSION "FAILED;ERROR" # fail if output matches
    WILL_FAIL TRUE                        # expect non-zero exit (negative test)
)

Common Properties Reference

Property Purpose
TIMEOUT Max seconds before killing test
LABELS Tags for filtering
ENVIRONMENT Env vars for this test
WORKING_DIRECTORY CWD for the test process
PASS_REGULAR_EXPRESSION Regex that must appear in output to pass
FAIL_REGULAR_EXPRESSION Regex in output = failure
WILL_FAIL Invert exit code check
FIXTURES_SETUP Run before other tests
FIXTURES_CLEANUP Run after other tests
FIXTURES_REQUIRED This test requires a fixture
RESOURCE_LOCK Prevent parallel execution conflicts

Google Test Integration

include(FetchContent)
FetchContent_Declare(
    googletest
    URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip
)
FetchContent_MakeAvailable(googletest)

add_executable(unit_tests
    tests/test_calculator.cpp
    tests/test_string_utils.cpp
)
target_link_libraries(unit_tests PRIVATE mylib GTest::gtest_main)

# Automatically discovers individual gtest cases as CTest tests
include(GoogleTest)
gtest_discover_tests(unit_tests
    PROPERTIES LABELS "unit"
    DISCOVERY_TIMEOUT 30
)

After gtest_discover_tests, each TEST(Suite, Case) becomes an individual CTest test:

CalculatorTest.AddsTwoIntegers
CalculatorTest.AddNegativeNumbers
CalculatorTest.DivisionByZeroThrows

You can run, filter, and report on each individually.


Catch2 Integration

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

add_executable(catch_tests tests/test_calculator.cpp)
target_link_libraries(catch_tests PRIVATE mylib Catch2::Catch2WithMain)

list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
include(Catch)
catch_discover_tests(catch_tests
    PROPERTIES LABELS "unit"
)

Test Fixtures for Setup/Teardown

CTest fixtures run setup and cleanup processes before/after test groups:

# Fixture: start test database before integration tests
add_test(NAME start_test_db
    COMMAND docker compose -f tests/docker-compose.test.yml up -d
)

add_test(NAME stop_test_db
    COMMAND docker compose -f tests/docker-compose.test.yml down
)

# Mark as fixture
set_tests_properties(start_test_db PROPERTIES
    FIXTURES_SETUP db_fixture
)
set_tests_properties(stop_test_db PROPERTIES
    FIXTURES_CLEANUP db_fixture
)

# Integration tests require the fixture
add_test(NAME integration_tests COMMAND integration_tests_exe)
set_tests_properties(integration_tests PROPERTIES
    FIXTURES_REQUIRED db_fixture
    LABELS "integration"
)

CTest ensures start_test_db runs before integration_tests and stop_test_db runs after, even if tests fail.


Resource Locks

Prevent test conflicts when tests share a resource:

# Both tests write to the same database — prevent simultaneous execution
set_tests_properties(test_user_create test_user_delete PROPERTIES
    RESOURCE_LOCK database
)

Tests with the same RESOURCE_LOCK value never run simultaneously, even with -j.


Running CTest

# Run all tests
ctest

<span class="hljs-comment"># Run in parallel (4 jobs)
ctest -j4

<span class="hljs-comment"># Verbose output
ctest -V

<span class="hljs-comment"># Show only failures
ctest --output-on-failure

<span class="hljs-comment"># Filter by label
ctest -L unit          <span class="hljs-comment"># run tests labeled "unit"
ctest -LE integration  <span class="hljs-comment"># exclude tests labeled "integration"

<span class="hljs-comment"># Filter by name regex
ctest -R Calculator    <span class="hljs-comment"># run tests matching "Calculator"
ctest -E slow          <span class="hljs-comment"># exclude tests matching "slow"

<span class="hljs-comment"># Run specific test numbers
ctest -I 1,5           <span class="hljs-comment"># run tests 1 through 5
ctest -I 3,,2          <span class="hljs-comment"># run every 2nd test starting from 3

<span class="hljs-comment"># List tests without running
ctest -N

<span class="hljs-comment"># Retry failed tests
ctest --rerun-failed --output-on-failure

Organizing a Multi-Target Project

MyProject/
├── CMakeLists.txt
├── src/
│   ├── CMakeLists.txt
│   ├── calculator.cpp
│   └── string_utils.cpp
└── tests/
    ├── CMakeLists.txt
    ├── unit/
    │   ├── CMakeLists.txt
    │   ├── test_calculator.cpp
    │   └── test_string_utils.cpp
    └── integration/
        ├── CMakeLists.txt
        └── test_api.cpp
# tests/CMakeLists.txt
add_subdirectory(unit)
add_subdirectory(integration)

# tests/unit/CMakeLists.txt
add_executable(unit_tests test_calculator.cpp test_string_utils.cpp)
target_link_libraries(unit_tests PRIVATE mylib GTest::gtest_main)

include(GoogleTest)
gtest_discover_tests(unit_tests PROPERTIES LABELS "unit;fast")

# tests/integration/CMakeLists.txt
add_executable(integration_tests test_api.cpp)
target_link_libraries(integration_tests PRIVATE mylib GTest::gtest_main)

gtest_discover_tests(integration_tests PROPERTIES
    LABELS "integration;slow"
    TIMEOUT 60
    ENVIRONMENT "API_URL=http://localhost:8080"
)

Run fast tests only in development:

ctest -L fast --output-on-failure

Run everything in CI:

ctest -j4 --output-on-failure

Code Coverage with gcov/lcov

# Add coverage flags in Debug builds
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
    target_compile_options(mylib PRIVATE --coverage)
    target_link_options(mylib PRIVATE --coverage)
    
    target_compile_options(unit_tests PRIVATE --coverage)
    target_link_options(unit_tests PRIVATE --coverage)
endif()
# Build with coverage
cmake -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build

<span class="hljs-comment"># Run tests
<span class="hljs-built_in">cd build && ctest

<span class="hljs-comment"># Generate coverage report
lcov --capture --directory . --output-file coverage.info
lcov --remove coverage.info <span class="hljs-string">'/usr/*' <span class="hljs-string">'*/gtest/*' --output-file coverage.info
genhtml coverage.info --output-directory coverage_html

<span class="hljs-comment"># Open report
open coverage_html/index.html

Or use CMake's built-in CDash coverage:

ctest -T Coverage

XML Reports for CI

# JUnit-compatible XML (requires CTest 3.16+)
ctest --output-junit results.xml

<span class="hljs-comment"># CTest native format
ctest -T Test  <span class="hljs-comment"># generates Testing/*/Test.xml

GitHub Actions with test results:

- name: Run tests
  run: cd build && ctest --output-on-failure -j4 --output-junit results.xml

- name: Publish test results
  uses: EnricoMi/publish-unit-test-result-action@v2
  if: always()
  with:
    files: build/results.xml

Complete GitHub Actions Workflow

name: CMake Tests

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        build_type: [Debug, Release]
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Configure CMake
        run: cmake -B build -DCMAKE_BUILD_TYPE=${{ matrix.build_type }}
      
      - name: Build
        run: cmake --build build --config ${{ matrix.build_type }} --parallel
      
      - name: Test (unit only on PR)
        if: github.event_name == 'pull_request'
        run: cd build && ctest -C ${{ matrix.build_type }} -L unit --output-on-failure -j4
      
      - name: Test (all on push)
        if: github.event_name == 'push'
        run: cd build && ctest -C ${{ matrix.build_type }} --output-on-failure -j4 --output-junit results.xml
      
      - name: Publish results
        uses: EnricoMi/publish-unit-test-result-action@v2
        if: github.event_name == 'push' && always()
        with:
          files: build/results.xml

CTest Best Practices

Label everything

set_tests_properties(my_test PROPERTIES LABELS "unit;calculator")

Set realistic timeouts

set_tests_properties(fast_test PROPERTIES TIMEOUT 5)
set_tests_properties(integration_test PROPERTIES TIMEOUT 120)

Use --output-on-failure in CI — you only want noise from failing tests, not all 300 passing ones.

Separate fast and slow tests — fast tests run on every commit, slow tests run nightly or on main.

Use fixtures for external dependencies — database, HTTP server, or filesystem setup should be in CTest fixtures, not test SetUp() methods.


Next Steps

Read more