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.

Greg Filak
Published

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:

  1. Add add_subdirectory(tests) to tell CMake to process the new CMakeLists.txt file inside the tests/ directory.
  2. Add the enable_testing() command. This is the master switch that activates all of CMake's testing features, including the add_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 our GreeterTests 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 with add_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 in CMakePresets.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.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate