Basic testing with Catch2

Introduction

The easiest way to test software is to write a bunch of assertions. These are simple statements which assess that given certain predetermined inputs, your code should behave in a certain way (produce a certain result, throw an exception…).

It is certainly possible to write basic assertions by hand…

// Assume this is the code we want to test
int triple(int x) {
    return 3 * x;
}

// Somewhere else in the source tree, we might write this test program:

#include <exception>
#include <iostream>
#include <sstream>

int main() {
    const int result1 = triple(0);
    if (result1 != 0) {
        std::ostringstream s;
        s << "expected triple(0) to be 0, but got " << result1;
        throw std::runtime_error(s.str());
    }

    const int result2 = triple(1);
    if (result2 != 3) {
        std::ostringstream s;
        s << "expected triple(1) to be 3, but got " << result2;
        throw std::runtime_error(s.str());
    }

    const int result3 = triple(2);
    if (result3 != 6) {
        std::ostringstream s;
        s << "expected triple(2) to be 6, but got " << result3;
        throw std::runtime_error(s.str());
    }

    std::cout << "All tests completed successfully" << std::endl;
}

…but as you can see, this gets verbose quickly, and we have not even started introducing other commonly desired test harness features like…

  • Being able to run all tests even if some of them fails and report on all failing tests at the end.
  • Being able to selectively run some tests, without running the other ones.

Therefore, you will soon want to introduce some abstractions that reduce the amount of code you need to write for each test. Thankfully, you do not need to write this code yourself, as there are many high-quality test frameworks available for C++. In this practical, we will use Catch2.

Using it, the above test program becomes…

#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_session.hpp>

TEST_CASE("triple() multiplies a number by 3") {
  REQUIRE(triple(0) == 0);
  REQUIRE(triple(1) == 3);
  REQUIRE(triple(2) == 6);
}

int main(int argc, char* argv[]) {
  return Catch::Session().run(argc, argv);
}

…which as you can see is a lot more readable, focused on the task at hand, and yet more featureful (you get the aforementioned test harness features for free, among other).

Basic Catch2 usage

In Catch2, as in many other test frameworks, testing is done using test cases, which are code blocks described using a string and containing more assertions. If one of the assertion fails, you will automatically get an error message telling which test case(s) failed and why:

Randomness seeded to: 3094418497

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
test_triple_catch.bin is a Catch2 v3.5.3 host application.
Run with -? for options

-------------------------------------------------------------------------------
triple() multiplies a number by 3
-------------------------------------------------------------------------------
test_triple_catch.cpp:7
...............................................................................

test_triple_catch.cpp:9: FAILED:
  REQUIRE( triple(1) == 3 )
with expansion:
  2 == 3

===============================================================================
test cases: 1 | 1 failed
assertions: 2 | 1 passed | 1 failed

Test cases terminate on the first failed assertion, so if you want to test multiple mathematically unrelated properties, it is a good practice to test them using separate test cases. But for any nontrivial test, doing this naively means duplicating common setup code. The standard way to avoid this in Catch2 is to use sections:

#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_session.hpp>

TEST_CASE("std::vector size/capacity matches expectations") {
  // Common setup
  std::vector<int> v(42);
  const size_t initial_capacity = v.capacity();

  // Common expectations
  REQUIRE(v.size() == 42);
  REQUIRE(initial_capacity <= 42);

  // Section that tests push_back
  SECTION("adding an element increases the size, may affect capacity") {
    v.push_back(123);
    REQUIRE(v.size() == 43);
    if (initial_capacity >= 43) {
      REQUIRE(v.capacity() == initial_capacity);
    } else {
      REQUIRE(v.capacity() >= 43);
    }
  }

  // Section that tests pop_back
  SECTION("removing an element decreases the size, capacity is unaffected") {
    v.pop_back();
    REQUIRE(v.size() == 41);
    REQUIRE(v.capacity() == initial_capacity);
  }

  // Section that tests clear
  SECTION("clearing the vector zeroes the size, capacity is unaffected") {
    v.clear();
    REQUIRE(v.size() == 0);
    REQUIRE(v.capacity() == initial_capacity);
  }
}

int main(int argc, char* argv[]) {
  return Catch::Session().run(argc, argv);
}

From this code, Catch2 will generate three test cases that begin by performing the same initial setup (and initial assertion checking), and then go on to each test how a different method of std::vector affects the vector’s size and capacity.

For more information on the functionality provided by Catch2 and the way it is used, please refer to the library’s reference documentation.

Exercise

Set up Catch2 on one of the example programs provided as part of this course, or a program of your own, and write some test cases with assertions. If you are not using the provided Makefiles, bear in mind that you will need to add -lCatch2 to your compiler flags in order to link to the Catch2 library.