Testing in C++

Motivation

Testing in software development is the process of evaluating and validating a software system to ensure its correctness, functionality, and performance. Automated testing is especially important for large projects.

The modular development of large projects breaks codes into smaller modules and units such as functions, and classes. Within in this context, Unit Testing and Integration Testing are extremely effective. Both of them are designed to be automated with the help of testing frameworks.

  • Large projects are hard to test manually.

  • Automated testing is more efficient and reliable.

  • Automated testing is more scalable.

  • Tests can describe the functionality of the system (requirements).

  • Tests can serve as documentation.

  • Testable code is usually modular and well-organized.

Unit test

It is a type of testing strategy to test software units individually in isolation. To ensure isolation, techniques like mocking, stubbing, and faking are introduced.

Integration test

It is a type of testing strategy to test how a group of units works together. Isolation can also be reinforced, similar to unit testing.

System test

It is a type of testing strategy to test the whole software system as a whole.

Acceptance test

It is a final system test that runs the whole software system all together in order to decide if the project can be accepted as a finished work.

Testing framework

A library/software to facilitate the testing process.

Simple Test Run

As a beginner taking entry-level courses, you should learn testing as soon as possible to build a correct workflow on project. You can start writing tests by simply add cout and if statements to check whether your function or class is doing its job as expected. These codes can be kept in separate cpp files with its own main function or as functions in your driver file, such as main.cpp.

For example, assume that we have two classes StoreItem and Store to test. A test driver may look like this:

test-driver.cpp
 1#include <iostream>
 2
 3#include "store.hpp"
 4#include "store-item.hpp"
 5
 6using namespace std;
 7
 8void testStoreItem() {
 9  StoreItem item1("Apple");
10  item1.print();
11  StoreItem item2("apple");
12  item2.print();
13  if (item1.equals(item2))
14    cout << "They are equal as expect!\n";
15  else
16    cout << "They are not equal, wrong!\n";
17}
18
19void testStore() {
20  Store myStore("Test Store");
21  myStore.addItem(StoreItem("Apple"));
22  myStore.addItem(StoreItem("Banana"));
23
24  myStore.print();
25  if (myStore.size() == 2)
26    cout << "The size is correct";
27  else
28    cout << "The size is wrong";
29}
30
31int main() {
32  testStoreItem();
33  // testStore();
34  return 0;
35}

Assertions

The assert macro function (similar to a function when used) allows a more efficient way to run tests. This macro takes a bool parameter and abort your program when the bool expression evaluates to false. You can add as many as you like in your program during debugging and disable all of them by adding a line of #define NDEBUG before the #include <cassert> directive.

test.cpp
 1#include <iostream>
 2#include <cassert>
 3
 4#include "store.hpp"
 5#include "store-item.hpp"
 6
 7using namespace std;
 8
 9void testStoreItem() {
10  StoreItem item1("Apple");
11  StoreItem item2("apple");
12  assert(item1.equals(item2));
13  item1.print();
14  item2.print();
15}
16
17void testStore() {
18  Store myStore("Test Store");
19  myStore.addItem(StoreItem("Apple"));
20  myStore.addItem(StoreItem("Banana"));
21  assert(myStore.size() == 2);
22  myStore.print();
23}
24
25int main() {
26  testStoreItem();
27  // testStore();
28  return 0;
29}

Testing Frameworks

Choosing a good testing framework is more effective in terms of easier test writing and creation, better test organization, and better test result presentations. We choose the Catch2 framework to write tests in some projects. It is simple but feature-rich. The syntax to write tests are simple:

test-catch.cpp
 1#include <iostream>
 2#include <cassert>
 3
 4#include "catch.hpp"
 5#include "store.hpp"
 6#include "store-item.hpp"
 7
 8using namespace std;
 9
10TEST_CASE("Test Store class") {
11  Store myStore("Test Store");
12  myStore.addItem(StoreItem("Apple"));
13  myStore.addItem(StoreItem("Banana"));
14  REQUIRE(myStore.size() == 2);
15  // CHECK(myStore.size() == 2);
16}
17
18TEST_CASE("Test StoreItem class") {
19  StoreItem item1("Apple");
20  StoreItem item2("apple");
21  REQUIRE(item1.equals(item2));
22  // CHECK(item1.equals(item2));
23}

The TEST_CASE macro defines a test case with a description and a tag string. The REQUIRE macro will check the condition in the parenthesis. It will pass if the condition is true. The whole testing process will be aborted on false. Replace REQUIRE with CHECK if you want the testing process to continue on false. Thus, REQUIRE behaves like assert and is good for debugging so we can focus on the first error. On the contrast, CHECK is good for summarizing or grading when the overall passing rate is important. You will see CHECK in the testing cases in our projects rather than REQUIRE.

A comprehensive official tutorial is available online.

Testability

Some codes are easier to test than others. In most of the cases, a more testable code base has better quality than something not very testable. Testable codes are usually well-designed.

There are many principles in how to write testable codes. Here are some simple rules we can follow:

  1. keep the size of each unit small

  2. keep each unit coherent

  3. keep each unit focusing on one responsibility

  4. keep the units decoupled (each unit knows minimal numbers of other units)

  5. keep units deterministic (same inputs always give the same results)

  6. keep most units side effect free if possible