Skip to content
Home
Modern CMake: Targets, Presets, FetchContent & CTest for C++ Builds

Modern CMake: Targets, Presets, FetchContent & CTest for C++ Builds

C/C++ C/C++ 7 min read 1471 words Beginner ExcellentWiki Editorial Team

CMake is the de facto standard build system generator for C++ projects. Modern CMake — version 3.15 and later — moves away from global variables and directory-level commands toward a target-based, declarative approach. This shift makes builds more predictable, reusable, and maintainable. This guide covers the modern CMake practices that every C++ developer should know.

Targets: The Core of Modern CMake

Modern CMake builds around targets. A target is an executable, library, or custom utility defined with add_executable or add_library. Properties are attached to targets, not set globally. This encapsulation is the key difference from old-style CMake.

add_library(my_lib STATIC
    src/core.cpp
    src/utils.cpp
)

target_include_directories(my_lib PUBLIC include)
target_compile_features(my_lib PUBLIC cxx_std_20)
target_link_libraries(my_lib PUBLIC fmt::fmt)

PUBLIC, PRIVATE, and INTERFACE Visibility

These keywords control how properties propagate to consumers of the target:

  • PUBLIC: The property applies to the target and propagates to all targets that link against it. Include directories and compile features that are part of the public API use PUBLIC.
  • PRIVATE: The property applies only to the target. Internal implementation details use PRIVATE.
  • INTERFACE: The property propagates to consumers but does not apply to the target itself. This is used for header-only libraries that have no compiled source files.
target_include_directories(my_lib
    PUBLIC  include   # Consumers of my_lib get this include path
    PRIVATE src       # Only my_lib's sources see this path
)

add_library(my_header_only INTERFACE)
target_include_directories(my_header_only INTERFACE include)
target_link_libraries(my_header_only INTERFACE fmt::fmt)

Correct use of visibility keywords is essential for proper encapsulation. A common mistake is using PRIVATE for library headers that consumers need, which causes include-path errors in downstream targets.

Linking and Transitive Dependencies

When target A links target B (PUBLIC), all targets that link A also get B’s PUBLIC properties. This transitive propagation eliminates manual tracking of dependency chains:

add_library(core core.cpp)
target_link_libraries(core PUBLIC fmt::fmt)

add_executable(app main.cpp)
target_link_libraries(app PRIVATE core)
# app automatically gets fmt::fmt include directories and link flags

CMake Presets: Standardized Configuration

CMake presets standardize configuration and build commands across developers and CI. A CMakePresets.json file in the project root defines presets:

{
    "version": 3,
    "configurePresets": [
        {
            "name": "default",
            "generator": "Ninja",
            "binaryDir": "${sourceDir}/build/${presetName}",
            "cacheVariables": {
                "CMAKE_BUILD_TYPE": "Debug",
                "CMAKE_CXX_COMPILER": "clang++",
                "CMAKE_CXX_FLAGS": "-Wall -Wextra -Wpedantic"
            }
        },
        {
            "name": "release",
            "inherits": "default",
            "cacheVariables": {
                "CMAKE_BUILD_TYPE": "Release",
                "CMAKE_CXX_FLAGS": "-O3 -DNDEBUG"
            }
        },
        {
            "name": "ci",
            "inherits": "release",
            "cacheVariables": {
                "BUILD_TESTING": "ON",
                "ENABLE_COVERAGE": "ON"
            }
        }
    ],
    "buildPresets": [
        {
            "name": "default",
            "configurePreset": "default"
        }
    ],
    "testPresets": [
        {
            "name": "default",
            "configurePreset": "default",
            "output": {"outputOnFailure": true}
        }
    ]
---
cmake --preset release
cmake --build --preset default
ctest --preset default

Presets document all intended build configurations and eliminate command-line flag mismatches between developers. They support inheritance, making it easy to maintain debug and release variants.

FetchContent: Modern Dependency Management

FetchContent downloads and integrates dependencies at configure time, replacing submodules and manual dependency management:

include(FetchContent)

FetchContent_Declare(
    nlohmann_json
    GIT_REPOSITORY https://github.com/nlohmann/json.git
    GIT_TAG v3.11.2
)

FetchContent_Declare(
    catch2
    GIT_REPOSITORY https://github.com/catchorg/Catch2.git
    GIT_TAG v3.4.0
)

FetchContent_MakeAvailable(nlohmann_json catch2)

target_link_libraries(my_app PRIVATE nlohmann_json::nlohmann_json)

if(BUILD_TESTING)
    add_executable(tests test_main.cpp)
    target_link_libraries(tests PRIVATE Catch2::Catch2WithMain)
endif()

FetchContent integrates seamlessly with the target model. Dependencies become targets with full transitive property propagation. It caches downloads in the build directory, so rebuilds do not redownload. For production, consider pinning dependencies to specific commits rather than release tags for reproducible builds.

Testing with CTest

CMake integrates testing through CTest and the enable_testing and add_test commands:

enable_testing()

add_executable(test_core test/core_test.cpp)
target_link_libraries(test_core PRIVATE my_lib Catch2::Catch2WithMain)

add_executable(test_utils test/utils_test.cpp)
target_link_libraries(test_utils PRIVATE my_lib Catch2::Catch2WithMain)

add_test(NAME CoreTests COMMAND test_core)
add_test(NAME UtilsTests COMMAND test_utils)

# Test with custom configuration
add_test(NAME IntegrationTest
    COMMAND integration_test --config ${CMAKE_BUILD_TYPE}
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/tests)
# Run all tests
ctest

# Run with verbose output
ctest -V

# Run specific test
ctest -R CoreTests

# Run tests in parallel
ctest -j $(nproc)

# Run with output on failure
ctest --output-on-failure

CTest supports test fixtures (setup/teardown), resource allocation, label-based filtering, and parallel execution. For large test suites, use LABELS to organize tests:

add_test(NAME UnitTest COMMAND unit_test)
set_tests_properties(UnitTest PROPERTIES LABELS "unit")

add_test(NAME SlowTest COMMAND slow_test)
set_tests_properties(SlowTest PROPERTIES LABELS "integration" TIMEOUT 300)
ctest -L unit        # Run only unit tests
ctest -LE slow       # Exclude slow tests

Toolchain Files for Cross-Compilation

Toolchain files configure cross-compilation and custom compiler setups:

# arm-toolchain.cmake
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_CXX_COMPILER arm-linux-gnueabihf-g++)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
cmake -DCMAKE_TOOLCHAIN_FILE=arm-toolchain.cmake -S . -B build-arm

Toolchain files separate target platform configuration from project configuration, making it straightforward to support multiple architectures.

Best Practices Summary

Set C++ standard on targets, not globally: target_compile_features(target PUBLIC cxx_std_20). Use target_sources to list sources rather than file(GLOB ...) — glob does not detect new files automatically. Avoid add_definitions and include_directories — use target-specific commands instead. Keep minimum CMake version low enough for downstream users but high enough for modern features — 3.16 is a good minimum for public libraries. Use --Werror=dev in CI to catch CMake warning.

Managing Large Projects with Subdirectories

Modern CMake handles multi-directory projects through add_subdirectory. Each subdirectory contains its own CMakeLists.txt that builds components of the larger project:

# Top-level CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)

add_subdirectory(lib)
add_subdirectory(src)
add_subdirectory(tests)

if(BUILD_EXAMPLES)
    add_subdirectory(examples)
endif()

Each subdirectory creates its own targets with proper visibility. The lib directory might build a static library, src builds the executable linking against it, and tests builds test executables. This structure scales to hundreds of subdirectories without global variable pollution.

Object Libraries for Performance

Object libraries collect compiled object files without creating an archive or shared library. They avoid the overhead of archiving and extracting during linking:

add_library(my_objs OBJECT
    src/core.cpp
    src/utils.cpp
    src/parser.cpp
)

target_include_directories(my_objs PUBLIC include)
target_compile_features(my_objs PUBLIC cxx_std_20)

add_executable(app main.cpp $<TARGET_OBJECTS:my_objs>)
target_link_libraries(app PRIVATE my_objs)

add_executable(tests test_main.cpp $<TARGET_OBJECTS:my_objs>)
target_link_libraries(tests PRIVATE my_objs)

Object libraries share compiled code across multiple targets without duplicating compilation or creating archive files. They are particularly useful for test suites that link against the same sources as the main executable.

Custom Commands and Targets

CMake supports custom build steps through add_custom_command and add_custom_target:

# Generate a file at build time
add_custom_command(
    OUTPUT ${CMAKE_BINARY_DIR}/generated/version.h
    COMMAND ${CMAKE_SOURCE_DIR}/scripts/generate_version.sh
            ${PROJECT_VERSION} > ${CMAKE_BINARY_DIR}/generated/version.h
    DEPENDS ${CMAKE_SOURCE_DIR}/scripts/generate_version.sh
    COMMENT "Generating version header"
)

# Custom target for code formatting
add_custom_target(
    format
    COMMAND clang-format -i ${CMAKE_SOURCE_DIR}/src/*.cpp
    COMMAND clang-format -i ${CMAKE_SOURCE_DIR}/include/*.h
    COMMENT "Formatting source files"
)

Custom commands integrate code generation, linting, and documentation building into the build system. Dependencies ensure generators run before dependent targets build.

Frequently Asked Questions

What is the difference between old-style and modern CMake?

Old-style CMake uses global commands like include_directories(), add_definitions(), and link_directories() that affect every target in the directory. Modern CMake attaches properties to individual targets using target_include_directories(), target_compile_definitions(), and target_link_libraries(). Modern CMake is more predictable, supports transitive dependency propagation, and enables proper encapsulation.

How do I choose between FetchContent and submodules?

FetchContent provides version control and automatic downloading without requiring developers to manage submodules. Submodules give you more control over local modifications and are better for actively developed in-house dependencies. For external open-source libraries, FetchContent is generally preferred.

Can I use modern CMake with an existing project?

Yes. Modern CMake features can be adopted incrementally. Start by converting include_directories() calls to target_include_directories(). Replace add_definitions() with target_compile_definitions(). Add target_compile_features() for C++ standard specification. Add CMakePresets.json for build configuration. The migration is straightforward and pays dividends in build maintainability.

What is the difference between STATIC, SHARED, and INTERFACE libraries?

STATIC libraries are archived object files linked into the executable at build time. SHARED libraries are dynamically loaded at runtime. INTERFACE libraries are header-only — they have no compiled source files and exist solely to propagate properties to consumers. For most internal libraries, start with STATIC. Use SHARED for plugins or separate distribution.

How do I debug CMake configuration issues?

Run cmake --trace to see every CMake command executed. Use cmake --trace-source=CMakeLists.txt for focused tracing. Check the generated CMakeCache.txt for variable values. Use message(STATUS "var=${var}") for debugging prints. Verify target properties with get_target_property.

Build Types and Configuration Presets

CMake supports multiple build types through CMAKE_BUILD_TYPE. Debug builds include symbols and no optimization. Release builds use -O3 and strip symbols. RelWithDebInfo balances optimization with debugging. MinSizeRel optimizes for binary size. Each build type can define custom compile flags and link options through per-configuration variables like CMAKE_CXX_FLAGS_RELEASE:

set(CMAKE_CXX_FLAGS_RELEASE "-O3 -DNDEBUG -march=native")
set(CMAKE_CXX_FLAGS_DEBUG "-O0 -g -fsanitize=address")

Configuration presets in CMakePresets.json document the intended build configurations, ensuring all developers and CI systems use identical flags.

CMake with Package Managers

Modern C++ development often combines CMake with package managers for dependency management. Conan, vcpkg, and Spack integrate with CMake through toolchain files or config-mode package discovery:

# Conan integration
# After running: conan install . --output-folder=build
list(APPEND CMAKE_PREFIX_PATH ${CMAKE_BINARY_DIR})

# vcpkg integration
# After setting CMAKE_TOOLCHAIN_FILE
# cmake -DCMAKE_TOOLCHAIN_FILE=/path/to/vcpkg/scripts/buildsystems/vcpkg.cmake
find_package(fmt CONFIG REQUIRED)
find_package(Boost COMPONENTS filesystem REQUIRED)

target_link_libraries(my_app PRIVATE fmt::fmt Boost::filesystem)

FetchContent works for simple dependencies, but package managers excel at managing complex dependency graphs with version constraints. Conan supports multiple build configurations (debug/release, static/shared) and is the most popular package manager for C++ in enterprise environments.

Related Articles

Section: C/C++ 1471 words 7 min read Beginner 756 articles in section Report inaccuracy Back to top