Learning Resources

Unit Testing Part 1


Introduction

Today, C++ developers can choose from several great Open Source unit testing frameworks such as Catch2, doctest, and Boost.Test (just to name a few). All of them are good choices with different strengths and weaknesses.

When it comes to testing CAF applications, however, we recommend using the native CAF unit testing API. Not because of particular design choices but because of its tight integration that enables fully deterministic execution of actors.

In part 1 of this guide, we will introduce the unit testing API and show how to write tests, scenarios and outlines as well as custom fixtures. In part 2, we will show how to use the predefined fixture in CAF for writing deterministic unit tests for actors.

The source code for all of the examples in this guide as well as the full setup for compiling and running the tests can be found on GitHub. We strongly recommend cloning the repository and following along with the examples.

Basic Setup and Test Binaries

Before discussing the unit testing API in detail, we first look at a minimal test that we can compile and run.

#include <caf/test/test.hpp>
#include <caf/test/caf_test_main.hpp>

TEST("a simple test") {
  check_eq(1, 1);
}

CAF_TEST_MAIN()

To compile and run the test, we will use CMake. Boilerplate code aside, the relevant part of the CMake code looks like this:

add_executable(simple-test main.test.cpp)

target_compile_features(simple-test PRIVATE cxx_std_${CXX_VERSION})

target_link_libraries(simple-test PRIVATE CAF::test)

add_test(NAME simple-test COMMAND simple-test)

This first example is located in the directory simple-test of the repository.

By convention, we use the suffix .test.cpp for all source files that belong to unit tests. Hence, the file that contains the main function for the test binary is called main.test.cpp. In this case, since it is a trivial first test, we only have this one file. The main function itself is generated by the CAF_TEST_MAIN macro. This macro works similar to the CAF_MAIN macro and allows us to pass custom type ID blocks to the macro for initializing the global meta objects table. The main function generated from this macro also takes care of parsing command line arguments and running the tests.

To tell CMake what version of C++ we want to use, our setup uses the variable CXX_VERSION. This variable is set in the main CMakeLists.txt file and defaults to 17. Hence, we will pass cxx_std_17 to the target_compile_features function by default, which enables C++17 features.

The CAF::test target is a CMake target that links against the CAF unit testing library. This target is provided by CAF and includes all necessary dependencies to write and run unit tests with CAF. Lastly, we add a test to the CMake setup with the add_test function. This will allow us to run the test with ctest.

After building the project, this test will run when we execute ctest. However, we can also run the test binary directly. Unless configured otherwise, CMake will put the binary in the build directory, e.g., build/simple-example/simple-test.

When running the test binary manually, the output will look like this:

$ simple-test
Summary:
  Time:   0.000s
  Suites: 1 / 1
  Checks: 1 / 1
  Status: passed

As we can see, the unit test framework will omit most of the output if all tests pass and we will only see a summary. If a test fails, the framework will print more detailed information about the failure. This is because output is generated lazily and only printed if the severity of the log message is included in the verbosity level of the reporter.

The test binary also supports several command line options. We can see all available options by running the test binary with the --help flag:

$ simple-test --help
global options:
  (-a|--available-suites)              : print all available suites
  (-h|-?|--help)                       : print this help text
  (-n|--no-colors)                     : disable coloring (ignored on Windows)
  (-r|--max-runtime) <uint64_t>        : set a maximum runtime in seconds
  (-s|--suites) <std::string>          : regex for selecting suites
  (-t|--tests) <std::string>           : regex for selecting tests
  (-A|--available-tests) <std::string> : print tests for a suite
  (-v|--verbosity) <std::string>       : set verbosity level of the reporter
  (-l|--log-component-filter) <list>   : set log filter

Here, we can find an option to set the verbosity level of the reporter. By using the -v flag, we can control how much output we want to see. For example, we can set the verbosity level to debug to see more log messages (CAF recognizes the verbosity levels quiet, error, warning, info, debug, and trace):

$ simple-test -v debug
Test: a simple test
  pass /Users/neverlord/github/unit-test-example/simple-example/main.test.cpp:5

Suite Summary: (default suite)
  Time:   0.000s
  Checks: 1 / 1
  Status: passed

Summary:
  Time:   0.000s
  Suites: 1 / 1
  Checks: 1 / 1
  Status: passed

This time, we see the test name, followed by the file and line number where the successful check was made. There is also a new section called Suite Summary that summarizes the results for a single suite. This is useful when we have multiple suites in a single test binary, which we will see soon.

By default, CAF will put all tests into the default suite. This implicit default suite has the name $. With the -A flag, we can list all available tests in a suite:

$ simple-test -A $
available tests in suite $:
- a simple test

CAF also implements filter options with the -s and -t flags. With these flags, we can select specific suites or tests to run. We can also combine the two flags to run a single test:

$ simple-test -s '\$' -t 'simple'
Summary:
  Time:   0.000s
  Suites: 1 / 1
  Checks: 1 / 1
  Status: passed

When using the -s option, we have to escape the $ character because it is a special character in regular expressions. Filtering suites and tests is useful to having fine-grained control over which tests to run. Either on the command line directly, or when adding tests in CMake (on a suite or test level).

The remaining options should be self-explanatory, except for the -l option. As we have mentioned earlier, the test framework is tightly integrated with CAF. The output of the tests also includes log messages from the CAF runtime. This can be quite overwhelming when running tests, especially when running tests with a high verbosity level. Hence, most log components are disabled by default via this filter. To disable the filter and see all log messages, we can use the -l option:

$ simple-test -l '[]'
Summary:
  Time:   0.000s
  Suites: 1 / 1
  Checks: 1 / 1
  Status: passed

In this first example, no log messages are generated, so the output is the same as before. Usually, setting this filter manually is not necessary except when debugging CAF itself or when needing more low-level context information about a failing test.

Checks and Requirements

The DSL for writing tests in CAF distinguishes between checks and requirements. A check is a statement that is either true or false. If the statement is false, the test fails and an error is reported. However, the test execution continues after a check failure. A requirement is a statement that must be true for the test to continue. If a requirement is false, the test fails and the execution stops immediately.

The following checks are available in CAF:

  • check(arg): Checks whether the argument is true.
  • check_eq(lhs, rhs): Checks whether the left-hand side is equal to the right-hand side.
  • check_ne(lhs, rhs): Checks whether the left-hand side is not equal to the right-hand side.
  • check_lt(lhs, rhs): Checks whether the left-hand side is less than the right-hand side.
  • check_le(lhs, rhs): Checks whether the left-hand side is less than or equal to the right-hand side.
  • check_gt(lhs, rhs): Checks whether the left-hand side is greater than the right-hand side.
  • check_ge(lhs, rhs): Checks whether the left-hand side is greater than or equal to the right-hand side.
  • check_throws(fn): Checks whether fn() throws an exception.
  • check_throws<E>(fn): Checks whether fn() throws an exception of type E.

Note that all of these are proper C++ functions, not macros. All check functions also return a boolean value, which is true if the check succeeded and false if the check failed. This allows us to use the check functions in if statements to conditionally execute code. A common pattern is to use a check function in an if statement and then place dependent checks inside the if block. For example:

if (check_eq(values.size(), 3u)) {
  check_eq(values[0], 1);
  check_eq(values[1], 2);
  check_eq(values[2], 3);
}

Here, we first check the size of some container, e.g., a std::vector, and then check the values of the elements only if the size check succeeded. We could also use a requirement instead of the check:

require_eq(values.size(), 3u);
check_eq(values[0], 1);
check_eq(values[1], 2);
check_eq(values[2], 3);

In this case, the test would abort if the size check fails and the following checks would not run. Both versions have their use cases and it depends on the context (and personal preference) which one to use.

The function names for the requiements are the same as for the checks, simply replace check with require. The requirements are also proper C++ functions.

Test Suites

There are two ways to group tests into suites in CAF. The first way is to use the explicit SUITE macro. This macro takes a suite name and a block of tests. The suite name is a string literal that can be used to filter tests on the command line.

Here is an example of how to use the SUITE macro:

SUITE("algorithms") {

TEST("std::sort sorts a sequence of elements") {
  auto xs = std::vector{3, 1, 4, 1, 5, 9, 2, 6};
  std::sort(xs.begin(), xs.end());
  check_eq(xs, std::vector{1, 1, 2, 3, 4, 5, 6, 9});
}

TEST("std::find searches for an element in a sequence") {
  auto xs = std::vector{3, 1, 4, 1, 5, 9, 2, 6};
  auto i = std::find(xs.begin(), xs.end(), 5);
  if (check_ne(i, xs.end())) {
    check_eq(*i, 5);
  }
  check_eq(std::find(xs.begin(), xs.end(), 42), xs.end());
}

} // SUITE("algorithms")

SUITE("containers") {

TEST("a default-construted vector is empty") {
  auto xs = std::vector<int>{};
  check(xs.empty());
  check_eq(xs.size(), 0u);
}

TEST("push_back increases the size of a vector") {
  auto xs = std::vector<int>{};
  xs.push_back(42);
  check_eq(xs.size(), 1u);
}

TEST("push_back adds elements to the end of a vector") {
  auto xs = std::vector<int>{};
  xs.push_back(42);
  xs.push_back(43);
  if (check_eq(xs.size(), 2u)) {
    check_eq(xs[0], 42);
    check_eq(xs[1], 43);
  }
}

} // SUITE("containers")

The upside of using the SUITE macro is that we can define multiple suites in a single file. Having the suite name explicitly in the test code also makes it easier to understand the context of the tests. The downside is that we have to maintain the suite names by hand. If the suite name is derived from the file name, it is easy to forget to update the suite name when renaming the file.

For example, in CAF, files are named after the class they contain. If we want to rename a class, we also have to rename the files. When using this explicit method, we also have to update the suite name in the test code. Since the name of the test suite by convention is derived from the file name, we can automate the generation of the suite name with CMake by providing a definition for the macro CAF_TEST_SUITE_NAME when compiling a .test.cpp file.

In the repository, we have an example of this setup in the directory multi-suite-generated. The directory contains the two files algorithms.test.cpp and containers.test.cpp that contain the tests from the previous example. We also have a trivial main.test.cpp that only includes these three lines:

#include <caf/test/caf_test_main.hpp>

CAF_TEST_MAIN()

Instead of using the SUITE macro, we set the CAF_TEST_SUITE_NAME macro in CMake as follows:

set(test_suites algorithms containers)

add_executable(multi-suites-generated main.test.cpp)

foreach(test_suite IN LISTS test_suites)
    set_property(SOURCE ${test_suite}.test.cpp PROPERTY COMPILE_DEFINITIONS
                 CAF_TEST_SUITE_NAME=${test_suite})
    target_sources(multi-suites-generated PRIVATE ${test_suite}.test.cpp)
    add_test(NAME multi-suites-generated-${test_suite}
             COMMAND multi-suites-generated -s "^${test_suite}\$")
endforeach()

target_compile_features(multi-suites-generated PRIVATE cxx_std_${CXX_VERSION})

target_link_libraries(multi-suites-generated PRIVATE CAF::test)

First, we store the suite names in a variable test_suites. Then, we iterate over the suite names and set the CAF_TEST_SUITE_NAME macro for each source file while also adding the source file to the target. We also add a test for each suite with the -s flag to filter the tests. The suite name is a regular expression that matches the suite name exactly. The ^ and $ characters are used to match the beginning and end of the string, respectively.

Which method to prefer of course depends on the project and personal preference. Having the build system generate the suite names is more robust and less error prone. However, of course it also obfuscates the suite names by basically hiding them in a CMake script.

Log Messages and Custom Types

Even when running tests without constructing an actor system, the unit test framework installs a logger that will print log messages to the console. CAF also provides a set of logging functions that can be used in tests. These functions live in the namespace caf::log::test, are simply named after the log levels and take a format string as well as corresponding arguments.

The format string uses the same syntax as std::format from C++20. CAF can be configured to use std::format if the compiler supports it. If not, CAF will fall back to a custom implementation. In any case, CAF recognizes not only the types recognized by the formatters but any type with an inspect overload.

In the following example, we define a custom type point and provide an inspect overload for it. We also give the type a proper CAF type ID so that we could use it in CAF messages if we wanted to. Just like with the regular CAF_MAIN macro, we can pass a custom type ID block to the CAF_TEST_MAIN macro to initialize the global meta objects table. After this setup, we can pass an instance of point to the caf::log::test functions:

struct point {
  int x;
  int y;
};

CAF_BEGIN_TYPE_ID_BLOCK(log_test, caf::first_custom_type_id)

  CAF_ADD_TYPE_ID(log_test, (point))

CAF_END_TYPE_ID_BLOCK(log_test)

template <class Inspector>
bool inspect(Inspector& f, point& x) {
  return f.object(x).fields(f.field("x", x.x), f.field("y", x.y));
}

TEST("a simple test") {
  auto res = 0;
  caf::log::test::debug("run the for loop in line {}", __LINE__);
  for (int i = 0; i < 3; ++i) {
    caf::log::test::debug("i = {}", i);
    res += i;
  }
  check_eq(res, 3);
  auto p = point{1, 2};
  caf::log::test::info("{}", p);
  check_eq(p.x, 1);
  check_eq(p.y, 2);
}

CAF_TEST_MAIN(caf::id_block::log_test)

Running this example will produce the following output:

Test: a simple test
  info:
    loc: /Users/neverlord/github/unit-test-example/log/main.test.cpp:31
    msg: point(1, 2)

Summary:
  Time:   0.000s
  Suites: 1 / 1
  Checks: 3 / 3
  Status: passed

To display the debug log messages as well, we need to raise the verbosity level of the reporter to debug.

Sections and Control Flow

In CAF tests, we can use sections using the SECTION macro to structure the test code. Sections form a tree-like structure and can be nested arbitrarily deep. The SECTION macro takes a string literal as an argument that will be displayed in the test output. CAF will run only one "leaf" section at a time.

Structuring tests in this way makes it easy to first initialize some state in the test and then run independent checks on that state, whereas each section receives a fresh state.

In the following example, we use the path variable to highlight the path that each test run takes through the sections:

TEST("sections showscase") {
  auto path = "test"s;
  SECTION("section 1") {
    path += " « section 1";
    SECTION("section 1.1") {
      path += ".1";
      SECTION("section 1.1.1") {
        path += ".1";
        caf::log::test::info("{}", path);
      }
      SECTION("section 1.1.2") {
        path += ".2";
        caf::log::test::info("{}", path);
      }
    }
    SECTION("section 1.2") {
      path += ".1";
      caf::log::test::info("{}", path);
    }
  }
  SECTION("section 2") {
    path += " « section 2";
    SECTION("section 2.1") {
      path += ".1";
      caf::log::test::info("{}", path);
    }
    SECTION("section 2.2") {
      path += ".2";
      caf::log::test::info("{}", path);
    }
  }
}

When running the executable, this test runs six times in total, once for each leaf section. The output will look like this:

Test: sections showcase
  Section: section 1
    Section: section 1.1
      Section: section 1.1.1
        info:
          loc: /Users/neverlord/github/unit-test-example/sections/main.test.cpp:15
          msg: test « section 1.1.1

 Test: sections showcase
  Section: section 1
    Section: section 1.1
      Section: section 1.1.2
        info:
          loc: /Users/neverlord/github/unit-test-example/sections/main.test.cpp:19
          msg: test « section 1.1.2

 Test: sections showcase
  Section: section 1
    Section: section 1.2
      info:
        loc: /Users/neverlord/github/unit-test-example/sections/main.test.cpp:24
        msg: test « section 1.2

 Test: sections showcase
  Section: section 2
    Section: section 2.1
      info:
        loc: /Users/neverlord/github/unit-test-example/sections/main.test.cpp:31
        msg: test « section 2.1

 Test: sections showcase
  Section: section 2
    Section: section 2.2
      info:
        loc: /Users/neverlord/github/unit-test-example/sections/main.test.cpp:35
        msg: test « section 2.2

This also means that a failed requirement in one section does not affect the execution of other sections.

Scenarios: Given-When-Then

Writing tests using the given-when-then pattern is a common practice and CAF supports this pattern as an alternative to using regular tests and sections.

Scenarios use the SCENARIO macro instead of the TEST macro. Inside a scenario, we can use the GIVEN, WHEN, and THEN macros to structure the test code. The GIVEN macro is used to set up the initial state of the test. Inside the GIVEN block, we can define one or more WHEN blocks that describe the actions that we want to test. Finally, the WHEN block contains a THEN block that checks the outcome of the actions.

Just like with sections, each leaf node of the scenario tree is run as a separately. To following example revisits our previous test for sorting and searching in a vector:

SCENARIO("std::sort sorts a sequence of elements") {
  GIVEN("a vector of integers") {
    auto xs = std::vector{3, 1, 4, 1, 5, 9, 2, 6};
    WHEN("sorting the vector using std::sort") {
      std::sort(xs.begin(), xs.end());
      THEN("the vector is sorted in ascending order") {
        check_eq(xs, std::vector{1, 1, 2, 3, 4, 5, 6, 9});
      }
    }
  }
}

SCENARIO("std::find searches for an element in a sequence") {
  GIVEN("a vector of integers") {
    auto xs = std::vector{3, 1, 4, 1, 5, 9, 2, 6};
    WHEN("searching for an existing element") {
      THEN("std::find returns an iterator to the element") {
        auto i = std::find(xs.begin(), xs.end(), 5);
        if (check_ne(i, xs.end())) {
          check_eq(*i, 5);
        }
      }
    }
    WHEN("searching for a non-existing element") {
      THEN("std::find returns an iterator to the end of the sequence") {
        check_eq(std::find(xs.begin(), xs.end(), 42), xs.end());
      }
    }
  }
}

Note that a scenario cannot contain sections. However, a single scenario can contain multiple GIVEN blocks that will run independently.

Outlines

Outlines in CAF are parameterized scenarios that allow us to run the same scenario with different input values. We can think of them as scenario templates that are instantiated with different arguments. The sources for the examples in this section can be found in the directory outlines.

An outline is defined with the OUTLINE macro. Inside the outline, we can use the GIVEN, WHEN, and THEN macros just like in a regular scenario. In the description of the individual blocks, we can use placeholders that will be replaced with the actual values when the outline is instantiated. Placeholds use the syntax <name>, where name is an arbitrary identifier.

The key-value pairs for the instantiation of the outline are passed to an EXAMPLES block as markdown-formatted table. The first row of the table contains the names of the placeholders and the following rows contain the values for each example. Each row in the table corresponds to a single instantiation of the outline.

To retrieve the values for the placeholders, we can use the function block_parameters, which takes the types of the placeholders as template arguments. The function will scan the description of the current block and create a tuple with the values for the placeholders in the order they appear in the block. If the block only contains a single placeholder, the function will return the value directly (not as a tuple).

In the following example, we define an outline with three examples for adding two numbers:

OUTLINE("adding two numbers") {
  GIVEN("the numbers <a> and <b>") {
    auto [a, b] = block_parameters<int, int>();
    WHEN("adding the numbers") {
      auto result = a + b;
      THEN("the result is <sum>") {
        auto sum = block_parameters<int>();
        check_eq(result, sum);
      }
    }
  }
  EXAMPLES = R"_(
    | a  | b  | sum |
    | 1  | 7  | 8   |
    | 7  | 1  | 8   |
    | -7 | 11 | 4   |
  )_";
}

When running the test, CAF will instantiate the outline three times, once for each row in the table. As we can see, when calling block_parameters with only a single template argument, the function will return the value directly. When calling the function with multiple template arguments, the function will return a tuple with the values in the order they appear in the block description.

The type conversion for the placeholders is done automatically by CAF. In this example, we convert all values to int. However, we can also use other types. CAF will try to convert the values to the requested type by using the same parser that CAF would use for parsing command line arguments.

Lists generally use the syntax [...]. However, a list of comma-separated values is interpreted as a list when CAF tries to convert the value to a container type like std::vector:

OUTLINE("std::sort sorts a sequence of elements") {
  GIVEN("a vector containing the values <input>") {
    auto input = block_parameters<std::vector<int>>();
    WHEN("sorting the vector using std::sort") {
      std::sort(input.begin(), input.end());
      THEN("the vector should contain <output>") {
        auto output = block_parameters<std::vector<int>>();
        check_eq(input, output);
      }
    }
  }
  EXAMPLES = R"_(
    | input                    | output                   |
    | 1                        | [1]                      |
    | [1, 3, 2]                | [1, 2, 3]                |
    | 3, 1, 4, 1, 5, 9, 2, 6   | 1, 1, 2, 3, 4, 5, 6, 9   |
  )_";
}

In our EXAMPLES section above, we mix explicitly defined lists using the [...] syntax and "plain" comma-separated values. Since we define the target type as vector<int>, CAF will interpret the comma-separated values as a list of integers. However, we do recommend using the [...] syntax for lists to make the intent clear and also to be consistent.

User-defined types can also be used in outlines. We simply define the values by writing a dictionary into the table that contains the values for the fields of the type. CAF will then use the inspect overload to convert the dictionary to the user-defined type.

In our final example, we define an outline for scaling points by adding a scalar to both coordinates. The type point is a user-defined type with two integer fields x and y (please see the repository for the full definition of the type):

OUTLINE("points can be scaled by adding a scalar to both coordinates") {
  GIVEN("the point <input>") {
    auto value = block_parameters<point>();
    WHEN("adding the scalar <scalar> to it") {
      auto scalar = block_parameters<int>();
      value += scalar;
      THEN("the result point is <output>") {
        auto output = block_parameters<point>();
        check_eq(value, output);
      }
    }
  }
  EXAMPLES = R"_(
    | input            | scalar | output           |
    | {"x": 7, "y": 5} |  2     | {"x": 9, "y": 7} |
    | {"x": 0, "y": 0} |  0     | {"x": 0, "y": 0} |
    | {"x": 1, "y": 2} | -1     | {"x": 0, "y": 1} |
  )_";
}

Fixtures

Like many other unit testing frameworks, CAF also supports fixtures. Fixtures allow us to set up a common state or to provide common functionality for a set of tests. A fixture is simply a class that will be injected into the test code as a base class. The test code can then access the members and functions of the fixture class.

To add a fixture to a test, we use the WITH_FIXTURE macro. This macro takes the fixture class as a template argument and a block of tests. Inside the block, we can access the members and functions of the fixture class through the this pointer.

struct my_fixture {
  int x = 0;
  int y = 0;

  void setup() {
    x = 42;
    y = 23;
  }
};

WITH_FIXTURE(my_fixture) {

TEST("tests can access fixture members") {
  check_eq(x, 0);
  check_eq(y, 0);
  setup();
  check_eq(x, 42);
  check_eq(y, 23);
}

} // WITH_FIXTURE(my_fixture)

In this example, we define a fixture class my_fixture and then inject it into the test code using the WITH_FIXTURE macro. Inside the test block, we can access all members and functions of the fixture class. The fixture class is constructed before the test block is executed and destructed after the test block has run. Hence, each run of the test block will have a fresh instance of the fixture class.

Fixtures cannot be nested, i.e., a test block can only have a single fixture. However, this is rarely a limitation in practice since we can simply define a fixture that inherits from multiple other fixtures we want to use.

Of course, the example we have shown here is a toy example. In practice, we would not use a fixture to set up a simple state like this. If we need to have some state for multiple test blocks, we would usually simply use sections. However, fixtures provide a powerful customization point for setting up a domain-specific language (DSL) to write tests. CAF also provides a predefined fixture for deterministic testing of actors, as we will see in part 2 of this guide.

Conclusion

In this part of the guide, we have introduced the CAF unit testing API. We have shown how to write checks, requirements, tests, scenarios, and outlines as well as how to use custom fixtures.

The main benefit of using the CAF unit testing API is the tight integration with the framework. For example, the logging API is directly available in the test code and automatically picks up inspect overloads for user-defined types.

In part 2, we will delve into the deterministic testing of actors with CAF.