In the era of Modern C++ (C++17/20/23), the language has evolved into a powerful tool for safe and expressive high-performance computing. However, the ecosystem around itβbuild systems, testing, and continuous integrationβis just as critical as the code itself.
This guide walks you through setting up a production-ready C++ project using modern CMake, proper dependency injection, compiler hardening, and a robust CI/CD pipeline.
π Table of Contents
1. Project Structure and Modularity
The days of a single main.cpp or a flat folder structure are over. A modern project should be modular, separating public interfaces from private implementations.
The Recommended Layout
Adopt a standard layout that tools and other developers expect:
my-project/
βββ CMakeLists.txt # Root build definition
βββ CMakePresets.json # Build configurations
βββ vcpkg.json # Dependency manifest
βββ src/
β βββ app/ # Executable code
β βββ core/ # Business logic (library)
βββ include/
β βββ myproject/ # Public headers
βββ tests/
β βββ unit/
β βββ e2e/
βββ external/ # For vendored dependencies
Modern CMake: Targets over Variables
Forget include_directories and link_libraries. Modern CMake is all about Targets and Properties.
- Rule: Always use
target_include_directories,target_compile_features, andtarget_link_libraries. - Visibility: Use
PUBLIC,PRIVATE, andINTERFACEto control dependency propagation.
src/core/CMakeLists.txt:
add_library(core_lib STATIC implementation.cpp)
# Consumers automatically get the include path
target_include_directories(core_lib PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../../include>
$<INSTALL_INTERFACE:include>
)
# Enforce C++20 for this library and its consumers
target_compile_features(core_lib PUBLIC cxx_std_20)
CMake Presets for Reproducible Builds
CMakePresets.json (introduced in CMake 3.19) standardizes build configurations across your team and CI. Instead of remembering complex command-line flags, developers simply run cmake --preset=release.
CMakePresets.json:
{
"version": 6,
"cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 },
"configurePresets": [
{
"name": "base",
"hidden": true,
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"cacheVariables": {
"CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake"
}
},
{
"name": "debug",
"inherits": "base",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" }
},
{
"name": "release",
"inherits": "base",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release" }
}
],
"buildPresets": [
{ "name": "debug", "configurePreset": "debug" },
{ "name": "release", "configurePreset": "release" }
]
}
2. Testability and Dependency Injection
Writing testable C++ means writing code that is loosely coupled. The biggest enemies of unit testing are singletons and direct instantiation of heavy dependencies (databases, network sockets, file systems) inside your business logic.
The Approach: Constructor Injection
Instead of instantiating a dependency inside your class, accept an interface (abstract base class) in the constructor.
The Interface (The Contract):
// IDataStore.hpp
class IDataStore {
public:
virtual ~IDataStore() = default;
virtual std::string GetData(int id) = 0;
};
The Consumer (Your Business Logic):
// UserManager.hpp
class UserManager {
IDataStore& db_; // Reference to interface
public:
// Dependency Injection via Constructor
explicit UserManager(IDataStore& db) : db_(db) {}
std::string GetUserName(int id) {
return "User: " + db_.GetData(id);
}
};
Unit Testing with Google Mock
Now you can inject a mock object during testing without needing a real database:
// tests/unit/TestUserManager.cpp
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "UserManager.hpp"
class MockDB : public IDataStore {
public:
MOCK_METHOD(std::string, GetData, (int id), (override));
};
TEST(UserManagerTest, FormatsNameCorrectly) {
MockDB mock_db;
// Expectation: When GetData(42) is called, return "Alice"
EXPECT_CALL(mock_db, GetData(42)).WillOnce(testing::Return("Alice"));
UserManager mgr(mock_db);
EXPECT_EQ(mgr.GetUserName(42), "User: Alice");
}
3. End-to-End Testing with CTest
Unit tests verify logic in isolation, but E2E tests verify the application works as a whole. CMake's CTest tool is excellent for orchestrating both.
For a C++ CLI application, an E2E test typically looks like: run the binary with arguments X, expect output Y.
tests/e2e/CMakeLists.txt:
add_test(
NAME E2E_Login_Flow
COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/test_login.py
$<TARGET_FILE:my_app_executable>
)
tests/e2e/test_login.py:
import subprocess
import sys
executable_path = sys.argv[1]
result = subprocess.run(
[executable_path, "--login", "user"],
capture_output=True,
text=True
)
if "Welcome, user" in result.stdout:
sys.exit(0) # Success
else:
print(f"Failed! Output was: {result.stdout}")
sys.exit(1) # Failure
π‘ Tip: For more complex E2E scenarios, consider using pytest with fixtures for setup/teardown and better assertion messages.
Running ctest in your build folder will execute both your GTest unit tests and your Python E2E scripts.
4. Compiler Hardening and Sanitizers
A production-ready project should catch bugs at compile time whenever possible and use runtime sanitizers in Debug builds.
Warning Flags
Create a reusable interface target for compiler warnings:
cmake/CompilerWarnings.cmake:
add_library(project_warnings INTERFACE)
if(MSVC)
target_compile_options(project_warnings INTERFACE /W4 /WX /permissive-)
else()
target_compile_options(project_warnings INTERFACE
-Wall -Wextra -Wpedantic -Werror
-Wshadow -Wnon-virtual-dtor -Wold-style-cast
-Wcast-align -Wunused -Woverloaded-virtual
-Wconversion -Wsign-conversion -Wnull-dereference
)
endif()
# Usage: target_link_libraries(my_target PRIVATE project_warnings)
Address and Undefined Behavior Sanitizers
Enable sanitizers in Debug builds to catch memory errors and undefined behavior:
cmake/Sanitizers.cmake:
option(ENABLE_SANITIZERS "Enable ASan and UBSan" OFF)
if(ENABLE_SANITIZERS AND NOT MSVC)
add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer)
add_link_options(-fsanitize=address,undefined)
endif()
5. Reproducibility and Dependency Management
"It works on my machine" is not an acceptable excuse. To achieve reproducibility, you must manage third-party libraries strictly. Do not ask users to install dependencies manuallyβuse a package manager.
Using vcpkg
vcpkg integrates seamlessly with CMake via a toolchain file. Create a manifest file to declare your dependencies:
vcpkg.json:
{
"name": "my-project",
"version-string": "1.0.0",
"dependencies": [
"fmt",
"gtest",
"nlohmann-json",
"spdlog"
]
}
When you run CMake with the vcpkg toolchain, it automatically downloads and builds specific versions of these libraries, ensuring every developer and CI runner has the exact same binary dependencies.
Alternative: Conan
Conan is another popular option, especially for projects that need fine-grained control over package versions or binary compatibility. A conanfile.txt serves the same purpose:
[requires]
fmt/10.1.1
gtest/1.14.0
nlohmann_json/3.11.2
[generators]
CMakeDeps
CMakeToolchain
6. CI/CD Pipeline (GitHub Actions)
A modern C++ project must build and test on multiple platforms automatically. This proves your code compiles with GCC, Clang, and MSVC.
.github/workflows/ci.yml:
name: CI
on: [push, pull_request]
jobs:
build:
name: ${{ matrix.os }} - ${{ matrix.config }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
config: [Debug, Release]
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Setup vcpkg
shell: bash
run: |
if [[ "$RUNNER_OS" == "Windows" ]]; then
./vcpkg/bootstrap-vcpkg.bat
else
./vcpkg/bootstrap-vcpkg.sh
fi
- name: Configure CMake
run: |
cmake -B build -S . \
-DCMAKE_TOOLCHAIN_FILE=vcpkg/scripts/buildsystems/vcpkg.cmake \
-DCMAKE_BUILD_TYPE=${{ matrix.config }} \
-DENABLE_SANITIZERS=${{ matrix.config == 'Debug' }}
- name: Build
run: cmake --build build --config ${{ matrix.config }}
- name: Run Tests
working-directory: build
run: ctest -C ${{ matrix.config }} --output-on-failure
Key Features of This Pipeline
- Cross-Platform: Proves your code compiles with GCC (Linux), Clang (macOS), and MSVC (Windows).
- Configuration Matrix: Tests both Debug (catches assertions, enables sanitizers) and Release (catches optimization bugs).
- Platform-Aware Setup: Uses bash conditionals to run the correct bootstrap script per OS.
- Automated Testing: If ctest fails, the Pull Request cannot be merged.
Summary
By combining these modern practices, you elevate your C++ project from "just code" to a professional engineering product:
- Structure: Use
src/,include/, andtests/with clear separation of concerns. - Modularity: Use
add_subdirectoryandtarget_link_librarieswith proper visibility. - Testability: Inject dependencies via interfaces; mock them with Google Mock.
- Hardening: Enable strict warnings (
-Wall -Wextra -Werror) and sanitizers in Debug. - Reproducibility: Use vcpkg or Conan manifests to lock dependency versions.
- CI/CD: Automate cross-platform builds and tests on every push.
Modern C++ is not just about language features like concepts or coroutinesβit's about the discipline of building software that is portable, verifiable, and maintainable.