Menu Share

How to write unit tests with CMake

by | Last Updated:
ANSI C, C++

Unit testing is an important part of every software project. Many people underestimate how powerful it is to spend some boring time to write unit tests but it will really save you some frustration time at a later point in time. It is easy to discard this for game development too – but don’t forget that all big engines support fully unit testing so it must be useful.

Table of Contents

What is unit testing?

You write code. While writing it you run the code and see it works… then you add more code on top… and more code on top… and you end up with a big system. Suddenly you change a function somewhere – test it with the current scenario and it works. You deploy your game and it crashes for everyone else. Suddenly you find out that your change affected another code and created a bug on another part of the game that you forgot to check.

Well this workflow could be avoided. When you first write a piece of code you can write another that would automatically check if it works. That other code that automatically checks if the first one works is called an unit test. Also just to be more specific if the code you write interacts with a third party API (database, library, network, filesystem, etc.) it is called an integration test because you are testing if your code works with the second API / system.

You would mostly get benefits from writing tests for your code. The only major problem is that it takes time and it requires discipline. But this time will always pay off in the end. My proposal is that you try implement this in a small section of your code and see the benefits it brings. Your mindset will change and you will start to write better designed functions and classes.

Before you start

Before you continue with unit testing you should know:

Project structure

So lets get to it – how do you achieve running your tests using CTest? First lets define our project structure. We are mosty going to be interested in a test target as well as the root CMakeLists.txt file.

root
├── MyLib/
│ ├── src/
│ │ └── ...files...
│ ├── inlude/
│ │ └── ...files...
│ └── CMakeLists.txt
├── MyLibTests/
│ ├── src/
│ │ └── MyLibTests.cpp
│ └── CMakeLists.txt
└── CMakeLists.txt

CTest using executable

The most simple example of unit testing is without any kind of testing framework. You just want to compile your code and see if a certain scenario(s) work. This can be achieved by creating a new target that is executable. The C/C++ code inside should test whatever functions you want to test and return a success code (0) or an error code (any other number) if the tests succeed or fail. This is also what the test frameworks do but they allow you to describe your scanarios using macros as you will see in the next section.

We would need a code to test so I define a MyLib target that would contain some imaginary functions and then our new test code will be contained within MyLibTests target. We will start with a simple MyLibTests.cpp file:

#include <iostream>

int main() {
    std::cout << "Tests started!" << std::endl;
    std::cout << "Tests (0) succeeded!" << std::endl;
    return 0; // You can put a 1 here to see later that it would generate an error
}

Next we would need to add some things to the root CMakeLists file:

# ...other code

include(CTest) # We include CTest which is part of CMake

add_subdirectory(MyLib)

# We check if this is the main file
# you don't usually want users of your library to
# execute tests as part of their build
if (${CMAKE_SOURCE_DIR} STREQUAL ${CMAKE_CURRENT_SOURCE_DIR})
    add_subdirectory(MyLibTests)
endif ()

enable_testing()

The CMakeLists.txt in the test folder would look something like this:

project(MyLibTests LANGUAGES CXX)

add_executable(MyLibTests src/MyLibTests.cpp)

target_link_libraries(MyLibTests PRIVATE MyLib)

add_test(NAME MyLibTests COMMAND MyLibTests)

You will notice this is very similar to how we would create a normal executable. And it is – after all tests are for taht to execute your code in a specific way that covers multiple scenarios and thus protects your final application from regressions (bugs that come out from modifying existing code). Running tests is simple – you open up a terminal in the build directory and write the following command:

ctest

CTest will then execute all tests added through the add_test command. It isn’t that hard but you test code can quickly get overwhelming without the propper structure and you also have to write all the output yourself. This is why there are frameworks that help you describe your tests in a more human-readable approach.

Add test – Other options

As you may have already noticed add_test actually accepts a COMMAND parameter. This means that you can easily add a bash/batch/powershell scripts and run other scripts or run executables written in multiple languages to validate your application run cycle.

Testing using Catch2

One such framework is Catch2. It will make it really easy to describe tests and it will autamatically print them out to the console when you run CTest. I will not get into the details into how to include Catch2 in your project – it is already covered in other articles and you can choose between the vcpkg approach or the vanilla approach. For this tutorial I used the vcpkg approach but you can see all available approaches also in Catch2 wiki. I made the following changes to my CMakeLists.txt file in the root folder:

find_package(Catch2 CONFIG REQUIRED)

include(CTest)
include(Catch) # contains some cmake macros that will help with discovery of tests

# ... remains the same

The target specific CMakeLists.txt would aslo change a bit:

project(MyLibTests LANGUAGES CXX)

add_executable(MyLibTests src/MyLibTests.cpp)

target_link_libraries(MyLibTests PRIVATE MyLib Catch2::Catch2)

# add_test(NAME MyLibTests COMMAND MyLibTests) -- this gets deleted
catch_discover_tests(MyLibTests)

The Catch2 syntax is pretty simple. This is how the main file will look:

// This line should be included only in one file 
// it instructs catch2 to generate the main() for you
// (important: the define should be before the include)
#define CATCH_CONFIG_MAIN
#include "catch.hpp"

#include "MyLib.h"

// Test case is a single test that you run
// You give it a name/description and also you give it some tags.
TEST_CASE("Testing framework is working fine", "[Catch2]")
{

    // Tests have to meet some requirements to be considered valid
    REQUIRE(true);
}


TEST_CASE("Testing MyLib", "[mylib]")
{


    int width = GetWidth(); // imaginary function in MyLib
    REQUIRE(width == 1920);
    

    // Sections would actually run the code from the beginning of the test case
    // but they you will run sections one by one
    SECTION("A Section")
    {
        SetWidth(640);
        width = GetWidth();
        REQUIRE(width == 640);
    }
}

Other frameworks

CTest is actually really powerfull – we’ve already examined running your custom targets, mentioned that you can run scripts and other executables and provided an example using a test framework like Catch2. But Catch2 is not the only test framework out there. You can also take a look and you will be just alright using one of the following frameworks instead:

Conclusion

This is just a scrape on the surface on how to start with unit testing. Its not really that complicated – it just runs an executable that then executes functions that you want to make sure will work. You would be wise to write tests for your critical functions and functions that change a lot. Contrary to many beliefs you don’t have to achive 100% tested code – start with covering the places that would be touched upon most and that you can easily verify.

Doing this will save you a lot of time in the long run. You can look into my advent of code repository where I have unit testing to validate that the functions I am writing meet the challenge.

Happy testing!

Leave a comment