Building and testing C++ code with CMake

Writing code is only part of the job. In C/C++ projects setting up the build system can sometimes take longer than the coding itself.

So in a very unlike me spree, I’ll keep it very short and explain how to set up a C/C++ project with CMake for the build and Catch2 for testing.

We are aiming to build project mylib with the following structure:

mylib
├── CMakeLists.txt
├── src
│  ├── CMakeLists.txt
│  ├── mylib.cpp
│  └── mylib.hpp
└── tests
   ├── CMakeLists.txt
   └── test.cpp

The content of the library is not important for what we want to demonstrate, but for the sake of concreteness, here it is:

// File: src/mylib.hpp
int foo();
// File: src/mylib.cpp
#include "mylib.hpp"
int foo() { return 42; }

As for the test:

// File: tests/test.cpp
#define CATCH_CONFIG_MAIN
#include <catch2/catch.hpp>
#include <mylib.hpp>

TEST_CASE("Testing foo", "[foo]") { REQUIRE( foo() == 42 ); }

This is all the code we need to have a minimal non-trivial working project structure (MNTWPS, since I heard that software people like acronyms).

Setting up the CMake project

In its easiest working, CMake works by executing scripts contained in CMakeLists.txt files. Subfolders can have their own CMakeLists.txt, which can be linked on the root one.

In the following root CMakeLists.txt file we are not doing much else than informing that the two subdirectories src/ and tests/ are part of the project.

# File: CMakeLists.txt
cmake_minimum_required(VERSION 3.27)
project(mylib CXX)

set(CMAKE_CXX_STANDARD 20)

add_subdirectory(src)

include(CTest)
add_subdirectory(tests)

The only noteworthy detail is that we are including CTest before we add the tests/ subfolder. This will automatically call enable_testing(), which is responsible for registering the tests and creating a make test target in the final makefile.

Building the library

Let’s take on building the actual library. This is done with

# File: src/CMakeLists.txt
add_library(mylib SHARED mylib.cpp mylib.hpp)

target_include_directories(mylib PUBLIC
    ${CMAKE_CURRENT_BINARY_DIR}
    ${CMAKE_CURRENT_SOURCE_DIR}
    )

install(TARGETS mylib)

The call to target_include_directories() makes the header visible to other consumers of the library, including the tests.

Building the tests

As for the tests, we use

# File: tests/CMakeLists.txt
find_package(Catch2 REQUIRED)

add_executable(mytest test.cpp)
target_link_libraries(mytest PRIVATE mylib)
target_link_libraries(mytest PRIVATE Catch2::Catch2)

include(Catch)
catch_discover_tests(mytest)

Here we finally make that all this prolixity pays off. We load Catch2 by calling find_package() and then link finds the system installation of mylib. We tag test.cpp as executable of target mytest. The subsequent calls to target_link_libraries() take care of the -l and -I for the actual build.

Finally we import the Catch2 Cmake scripts and among those we call catch_discover_tests(), which parses the source looking for TEST_CASEs and implicitly calls CMake’s add_test() to register them in Ctest and make test.

Run it

So far we wrote a library consisting of two files and a whopping 3 lines of code. We are testing it with further 4 lines of code. In order to build the library and test it we had to write 18 lines of CMake.

Let’s check that at least it works. From the root folder:

mkdir build && cd build
cmake ..
make
make test
ctest

We notice that make takes an awful lot of time to run because of all the stuff that Ctest creates (9.6M to compile 7 lines of C++!).

Conclusion

This achieves the MNTWPS we were after, and to my knowledge it is probably the simplest example out there (otherwise there would be no point in writing this).

This is the end of this post but it can be the beginning of properly packaging our new library, for instance using Conan, which is decently well documented.

All the code is available as a git repository on Codeberg.