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 && ctestTest 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.DivisionByZeroThrowsYou 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-failureOrganizing 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-failureRun everything in CI:
ctest -j4 --output-on-failureCode 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.htmlOr use CMake's built-in CDash coverage:
ctest -T CoverageXML 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.xmlGitHub 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.xmlComplete 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.xmlCTest 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
- Integrate gtest — see the Google Test tutorial for writing the tests CTest runs
- Try Catch2 — see the Catch2 guide for a modern alternative
- Add AddressSanitizer — run CTest with
-DCMAKE_CXX_FLAGS="-fsanitize=address"and check the memory testing guide