Automated Testing and CTest
Learn the fundamentals of automated testing in C++ with CTest. Create your first test suite, register tests with add_test()
, and run them with the ctest
command.
We can now format our code and generate documentation with single commands, but an important question remains: how do we know our code actually works? And how do we ensure it keeps working as we refactor, optimize, and add new features?
The answer is automated testing. In this chapter, we'll integrate a testing workflow into our project. We'll start with the fundamentals, using CMake's built-in testing framework, CTest, to create and run our first simple tests.
The Benefits of Automated Testing
Before we write any test code, it's important to understand the value it provides. Automated tests have many benefits:
- A Safety Net Against Regressions: A regression is when a change to the code unintentionally breaks existing functionality. A test suite acts as a safety net. After every change, you can run your tests to get immediate feedback. If the tests are comprehensive and they all pass, you can be more confident you haven't broken anything.
- Confidence to Refactor: Code often needs to be refactored - restructured and improved without changing its external behavior. Without tests, refactoring is a high-risk activity. With a good test suite, you can make bold changes, knowing that you can instantly verify that the new implementation behaves identically to the old one.
- Executable Documentation: Tests are a form of documentation that cannot go out of date. A well-written test clearly demonstrates how a piece of code is intended to be used and what its expected behavior is, including for edge cases that might be missed in traditional documentation.
Throughout this chapter, we'll use our Greeter
project as a practical testbed to learn how to implement these ideas with CMake and CTest.
Creating a Test Sub-Project
Just as we separate our library (greeter/
) from our application (app/
), it's a standard practice to keep test code separate from production code. We'll create a new tests/
directory for this purpose.
Greeter/
├─ app/
├─ greeter/
├─ tests/
│ ├─ CMakeLists.txt
│ └─ main.cpp
└─ CMakeLists.txt
We'll cover what goes in these new files soon. To integrate this new component, we need to make two changes to our existing root CMakeLists.txt
:
- Add
add_subdirectory(tests)
to tell CMake to process the newCMakeLists.txt
file inside thetests/
directory. - Add the
enable_testing()
command. This is the master switch that activates all of CMake's testing features, including theadd_test()
command we'll use shortly and the CTest framework itself.
CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
project(Greeter)
add_subdirectory(greeter)
add_subdirectory(app)
enable_testing()
add_subdirectory(tests)
Writing a Test Driver
Our first test will verify that the get_greeting()
function in our GreeterLib
returns the correct string. To do this, we need to create a simple C++ program whose only job is to run this test. This is often called a test driver or a test runner.
First, let's define the build target for this executable in tests/CMakeLists.txt
. We'll call the target GreeterTests
and link it against our GreeterLib
so it can access the get_greeting()
function.
tests/CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
add_executable(GreeterTests main.cpp)
# Our test needs access to the library it's testing
target_link_libraries(GreeterTests PRIVATE GreeterLib)
Next, let's write the C++ code for our test driver in tests/main.cpp
. This is quite a lot of code to create a simple test but, in the next lesson, we'll introduce an external testing library that makes this easier.
For now, our test file might be verbose, but the logic is simple:
tests/main.cpp
#include <greeter/Greeter.h>
#include <string>
#include <iostream>
int main() {
const std::string expected{
"Hello from the modular greeter library"
};
const std::string actual{get_greeting()};
if (actual != expected) {
std::cerr << "Test Failed:"
<< "\n Expected: " << expected
<< "\n Actual: " << actual;
return EXIT_FAILURE;
}
std::cout << "Test Passed!";
return EXIT_SUCCESS;
}
We call the function we want to test, and compare the actual result to the expected result.
If they don't match, print an error and return a non-zero exit code from our main
function. Something like return 1
would work, but return EXIT_FAILURE
gives us similar behavior with more descriptive syntax.
If they do match, print a success message and return 0
(or EXIT_SUCCESS
).
This convention of using the program's exit code to signal success or failure is fundamental to how CTest and most command-line testing tools work.
For reference, here is the function we're testing. It's important that the string we're returning here exactly matches the expected
value in the test:
greeter/src/greeter.cpp
#include <greeter/Greeter.h>
#include <string>
std::string get_greeting() {
return "Hello from the modular greeter library"
}
Registering Tests with add_test()
We've created a test executable, but CMake's testing framework doesn't know about it yet. We need to formally register it as a test case. We do this with the add_test()
command.
tests/CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
add_executable(GreeterTests main.cpp)
target_link_libraries(GreeterTests PRIVATE GreeterLib)
add_test(
NAME Greeter.API.get_greeting
COMMAND GreeterTests
)
Let's break down this command:
NAME Greeter.API.get_greeting
: This gives our test a unique, human-readable name for reporting purposes. This is the name you'll see in the CTest output. It's a good practice to use a hierarchical naming scheme (e.g.,ComponentName.Subsystem.TestName
) to keep your tests organized.COMMAND GreeterTests
: This specifies the command to run for this test. In this case, it's just ourGreeterTests
executable target.
Running Tests with ctest
With our test registered, we can now run it. From our build directory, we should first configure and build the project in the usual way:
cmake ..
cmake --build .
We can then run the ctest
command, also from our build directory:
ctest
CTest will find all the tests registered with add_test()
and run them, providing a summary of the results:
Test project D:/Projects/cmake/build
Start 1: Greeter.API.get_greeting
1/1 Test #1: Greeter.API.get_greeting ... Passed 0.01 sec
100% tests passed, 0 tests failed out of 1
Total Test time (real) = 0.01 sec
Creating Test Presets
As our testing requirements get more complex, our ctest
commands will also get longer, and inputting them will become a chore. Just as we did for configuring and building, we can use presets to create simple, named workflows for testing.
We do this by adding a testPresets
array to our CMakePresets.json
file.
CMakePresets.json
{
"version": 3,
"configurePresets": [{
"name": "default",
"binaryDir": "${sourceDir}/build"
}],
"buildPresets": [{
"name": "default",
"configurePreset": "default"
}],
"testPresets": [{
"name": "default",
"displayName": "Run All Tests",
"configurePreset": "default"
}]
}
"name"
: The unique name for the test preset."displayName"
: A friendly name for our preset that can be used by IDEs and other tools."configurePreset"
: Similar to our build presets, this links our test preset to the configure preset it requires. It tells CTest which build directory to run the tests from by pointing to the corresponding configure preset.
With this preset defined, we can now configure, build, and test our project from the root directory with a consistent set of commands:
cmake --preset=default
cmake --build --preset=default
ctest --preset=default
Test project D:/Projects/cmake/build
Start 1: Greeter.API.get_greeting
1/1 Test #1: Greeter.API.get_greeting ... Passed 0.01 sec
100% tests passed, 0 tests failed out of 1
Total Test time (real) = 0.01 sec
Analyzing Failures
Let's see what happens when a test fails. We'll intentionally introduce a bug into our GreeterLib
by changing the greeting string.
greeter/src/Greeter.cpp
#include <greeter/Greeter.h>
#include <string>
std::string get_greeting() {
return "Hello from the buggy greeter library";
}
Now, let's rebuild and run our test preset:
cmake --build --preset=default
ctest --preset=default
The summary now correctly reports the failure:
Test project D:/Projects/cmake/build
Start 1: Greeter.API.get_greeting
1/1 Test #1: Greeter.API.get_greeting ... Failed 0.04 sec
0% tests passed, 1 tests failed out of 1
Total Test time (real) = 0.04 sec
The following tests FAILED:
1 - Greeter.API.get_greeting (Failed)
Errors while running CTest
Output from these tests are in: D:/Projects/cmake/build/Testing/Temporary/LastTest.log
Use "--rerun-failed --output-on-failure" to re-run the failed cases verbosely.
Verbose Output
This is good, but it doesn't tell us why the test failed. It does generate a log file which explains the failure, but most people prefer to see the reason directly in the terminal rather than opening a log file.
We can set this up with command-line flag --output-on-failure
like our error report suggested, but a more common way is to add this to our test preset, or to create a new test preset for debugging tests.
With either approach, we'd add an output
key, whose value is an object with outputOnFailure
set to true
:
CMakePresets.json
{
"version": 3,
"configurePresets": [ ... ],
"buildPresets": [ ... ],
"testPresets": [
{
"name": "default",
"displayName": "Run All Tests",
"configurePreset": "default"
},
{
"name": "debug-tests",
"displayName": "Run Tests (Output on Failure)",
"configurePreset": "default",
"output": { "outputOnFailure": true }
}
]
}
Now, when we run our new preset, CTest will print the stdout
and stderr
from any test that fails.
ctest --preset=debug-tests
This time, we get the detailed error message we wrote in our test driver in /tests/main.cpp
, making it much easier to see the problem:
Test project D:/Projects/cmake/build
Start 1: Greeter.API.get_greeting
1/1 Test #1: Greeter.API.get_greeting ... Failed 0.01 sec
Test Failed:
Expected: Hello from the modular greeter library
Actual: Hello from the buggy greeter library
0% tests passed, 1 tests failed out of 1
Total Test time (real) = 0.01 sec
The following tests FAILED:
1 - Greeter.API.get_greeting (Failed)
Errors while running CTest
Let's fix our bug in Greeter.cpp
and ensure our tests pass once more before moving on:
greeter/src/Greeter.cpp
#include <greeter/Greeter.h>
std::string get_greeting() {
return "Hello from the modular greeter library";
}
Summary
In this lesson, we took our first steps into automated testing with CTest, CMake's testing framework.
- Why Test?: We learned that automated tests are an important tool for ensuring code correctness, preventing regressions, and enabling confident refactoring.
- The CTest Workflow: The process involves enabling testing with
enable_testing()
, creating a test driver executable, and registering it as a test case withadd_test()
. - Test Drivers: A test driver is a simple program that returns
0
for success and a non-zero value for failure. This exit code is how CTest determines the pass/fail status. - Presets for Testing: We saw how
testPresets
inCMakePresets.json
can simplify the testing workflow, providing named shortcuts for running tests with specific configurations.
This was a quick crash-tour of CTest and how we can use it. It's a powerful tool with many more options, which are listed on the official docs.
For now, the most pressing concern is that our test driver makes writing tests extremely tedious. For every check, we have to write a verbose if/else
blocks, manually create strings to report successes and explain failures, and then write std::cerr
statements to let people see those reports.