Unit testing C++ programs with CTest
Separate logic from input/output, write repeatable tests, and register tests with CMake and CTest.
Learning objectives
- Separate program logic from terminal input and output so it can be tested directly.
- Write checks that return a failing process status when a test fails.
- Register a test executable with CTest and run it from a build directory.
- Distinguish CTest's test-running role from the assertions and fixtures supplied by a test framework.
Prerequisites: functions, separate compilation, and basic CMake usage. Review Functions, Header files, and CMake project builds first.
Why write tests?
A test checks a small behavior against an expected result. Tests make regressions visible: when code changes, the same checks can be run again. A useful unit test is focused, repeatable, and independent of manual keyboard input.
Separate logic from input/output
int add(int left, int right) { return left + right; }
// The calculation is easy to test without reading input or printing output.A complete small test project
Keep the function declaration in a header, its definition in a source file, and the test executable in a separate source file. This makes the example match the multi-file CMake setup below.
Project layout
addition/ CMakeLists.txt math_ops.hpp math_ops.cpp math_tests.cpp
#pragma once
int add(int left, int right);#include "math_ops.hpp"
int add(int left, int right) {
return left + right;
}#include "math_ops.hpp"
#include <iostream>
int main() {
bool passed = true;
auto check = [&passed](bool condition, const char* name) {
if (!condition) {
std::cerr << "FAILED: " << name << '\n';
passed = false;
}
};
check(add(2, 3) == 5, "positive values");
check(add(-2, 2) == 0, "opposite values");
check(add(0, 0) == 0, "zero values");
return passed ? 0 : 1;
}This test helper remains active in Debug and Release builds. The standard assert macro is useful for internal program checks, but defining NDEBUG disables it; a test executable must still report failures in the build configuration used by CI.
Register tests with CTest
cmake_minimum_required(VERSION 3.20)
project(Addition LANGUAGES CXX)
include(CTest)
add_library(math_ops math_ops.cpp)
target_compile_features(math_ops PUBLIC cxx_std_17)
add_executable(math_tests math_tests.cpp)
target_link_libraries(math_tests PRIVATE math_ops)
if(BUILD_TESTING)
add_test(NAME math_tests COMMAND math_tests)
endif()From the addition directory, configure and build with cmake -S . -B build and cmake --build build, then run ctest --test-dir build --output-on-failure. A successful run reports the registered test as passed. CTest runs registered test commands; it does not provide a full assertion framework by itself. Larger projects often use a dedicated framework such as GoogleTest or Catch2.
Design better test cases
- Normal case: typical valid input.
- Boundary case: minimum, maximum, empty, or one-element input.
- Invalid case: malformed or out-of-range input.
- Regression case: a test for a bug that was fixed.
Common mistakes
- Testing only one happy-path input.
- Making tests depend on execution order or external files without controlling them.
- Using assert for checks that must run in production builds.
- Testing implementation details rather than externally visible behavior.
- Forgetting to register the executable with add_test.
Practice
- Write three tests for a function that returns the larger of two integers.
- Why should a unit test not depend on a user typing input?
- What is the difference between CTest and a test framework?
Show answers
- Try (2, 5), (-4, -1), and (7, 7).
- Automation must be repeatable and should not block waiting for a person.
- CTest discovers and runs registered test commands; a framework supplies assertions, fixtures, reporting, and other test-writing tools.
Summary
Keep core logic testable, cover normal and edge cases, and automate execution with CTest. Use a dedicated framework as test suites grow.