The purpose of this blog is to help web developers to jump into systems programming. So you can ask any questions; there are no dummy questions. I want this blog to be a discussion space for every programmer on this journey.


As a JavaScript developer, you’re likely familiar with callback-based APIs. But when you transition to C++, testing these APIs may seem daunting. In this post, I’ll introduce you to GoogleTest, a popular testing framework for C++, and demonstrate how to use std::promise to test callback-based APIs. We’ll also guide you through setting up a simple C++ project with CMake.

Prerequisites:

  • Basic understanding of C++
  • Familiarity with callback functions in JavaScript
  • CMake installed on your system

By the end of this article, you should be able to:

  1. Set up a C++ project with GoogleTest using CMake and CTest.
  2. Utilize std::promise to manage callback-based API in your C++ tests.
  3. Write test cases for callback-based APIs in C++ using GoogleTest.

Setup

To begin, create a new directory structure for your project:

|-- src/
    |-- api.cpp
    |-- api.h
|-- test/
    |-- test_main.cpp
|-- CMakeLists.txt
|-- .gitignore

CMake project’s structureNext, set up GoogleTest in your C++ project using CMake:

  • Add the following content to CMakeLists.txt:
# Basic CMake configuration
cmake_minimum_required(VERSION 3.10)
project(test_cpp_callbacks)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)

# Enable CTest
include(CTest)

# Download and build GoogleTest
include(FetchContent)
FetchContent_Declare(
  googletest
  URL https://github.com/google/googletest/archive/03597a01ee50ed33e9dfd640b249b4be3799d395.zip
)
FetchContent_MakeAvailable(googletest)
enable_testing()

# Executable for the tests
add_executable(test_main test/test_main.cc)
target_link_libraries(test_main gtest_main)
add_test(NAME test_main COMMAND test_main)

👉Before going further, please check this link to see if the FetchContent\_Declare URL has changed.What does this file mean? Basically, it just declares a few project variables, including CTest and GoogleTest, and creates an executable for running tests.

Add the following content to .gitignore to ignore build files:

build/

Now We’ll be ready for coding.

Write our first test case

First, create a test/test_main.cc file: we’ll include gtest header file and declare an empty test case:

#include <gtest/gtest.h>

TEST(dummy_api, return_error_on_empty_string) {}

We used the TEST() macro, which takes two arguments:

  1. The name of the test suite
  2. The name of the test case within the suite

Let’s say that we want to implement a function that takes two parameters, a string and a callback, and if the string is empty, we call the callback with an error (std::string), otherwise, with a std::nullopt.

Here we’ll test the error case first; let’s write a naive test approach:

#include <gtest/gtest.h>

#include <functional>
#include <optional>
#include <string>

void dummy_api(/* TODO */) {
    // TODO
}

TEST(dummy_api, return_error_on_empty_string) {
    const std::string empty_string = "";
    
    dummy_api(empty_string, [](MaybeError maybe_err) {
    	EXPECT_EQ(maybe_err.value(), "Input string is empty.");
    });
}

You’ll ask: “Tony, why the hell do we have the test and the implementation on the same file?”

It’s the way I like to start with a brand new API because it avoids the overhead of switching between two files. But I promise that I will move the implementation to a api.cc later.

The given C++ code snippet won’t work as expected because it doesn’t account for the asynchronous nature of this API. The test function will finish executing and report a result before the callback can run.

So we must ensure that the test case will finish execution once the callback has been called. Let’s try something with std::promise:

#include <gtest/gtest.h>

#include <functional>
#include <optional>
#include <string>

void dummy_api(/* TODO */) {
    // TODO
}

TEST(dummy_api, return_error_on_empty_string) {
  std::promise<bool> waitGuard;
  const std::string empty_string = "";

  dummy_api(empty_string, [&waitGuard](MaybeError maybe_err) {
    EXPECT_EQ(maybe_err.value(), "Input string is empty.");
    waitGuard.set_value(true);
  });

  EXPECT_TRUE(waitGuard.get_future().get());
}

Let’s break down this test case:

  • We create a std::promise object called waitGuard to manage the callback-based API.
  • Call the dummy_api function with an empty string and a lambda function as the callback.
  • Use GoogleTest’s EXPECT_EQ macro to check if the error message matches the expected value inside the lambda function. (the heart of our test)
  • Set the waitGuard’s value to true to indicate the callback has been executed.
  • At the end of the test case, I used waitGuard.get_future().get() to wait for the callback to complete before checking the result.

Representation of the execution flow.😄If something it’s not clear, please feel free to let a comment below.Now let’s implement the function.

Implement the callback-based API

Complete the function dummy_api in the test/test_main.cc file with the following snippet:

using MaybeError = std::optional<std::string>;
using Callback = std::function<void(MaybeError)>;

void dummy_api(const std::string &input, Callback callback) {
  if (input.empty()) {
    callback("Input string is empty.");
  }
}

The function takes a string as a parameter and a callback. If the string is empty, we call the callback function with a string as an error message.

I got used to adding semantics in my code, so I created a Callback type at the beginning of the file.

Run the test with CTest

To build and run your project, execute the following commands in your project’s root directory:

# Create a build directory and change directory
$ mkdir build && cd build

# Generate Make project 
$ cmake ..

# Build
$ make

# Run tests
$ ctest

At the end of the standard output, you should see the following:

Test project /Users/tonygorez/projects/blog-snippets/testing-callbacks/build
    Start 1: test_main
1/1 Test #1: test_main ........................   Passed    0.00 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) =   0.01 sec

🎉 Congratulations on having made it so far!

Refactoring

Let’s put the dummy_api into its own file, let’s add a src/api.h header file first:

#include <functional>
#include <optional>
#include <string>

using MaybeError = std::optional<std::string>;
using Callback = std::function<void(MaybeError)>;

void dummy_api(const std::string &input, Callback callback);

And the implementation file:

#include "api.h"

void dummy_api(const std::string &input, Callback callback) {
  if (input.empty()) {
    callback("Input string is empty.");
  }
}

Finally, let’s add our new files src/api.cc src/api.h into our CMakeLists.txt file:

# Executable for the tests
add_executable(test_main test/test_main.cc src/api.cc src/api.h) # here
target_link_libraries(test_main gtest_main)
add_test(NAME test_main COMMAND test_main)

Now you’re ready to rerun tests (from the /build directory):

make && ctest

Add a last case

We have to handle a last case, open the test/test_main.cc file and write the following case:

TEST(dummy_api, return_nullopt_on_non_empty_string) {
  std::promise<bool> waitGuard;
  const std::string non_empty_string = "non-empty";

  dummy_api(non_empty_string, [&waitGuard](MaybeError maybe_err) {
    EXPECT_EQ(maybe_err, std::nullopt);
    waitGuard.set_value(true);
  });

  EXPECT_TRUE(waitGuard.get_future().get());
}

If you run make && ctest it will run forever:

➜  build git:(main) ✗ make && ctest
[ 18%] Built target gtest
[ 36%] Built target gtest_main
Consolidate compiler generated dependencies of target test_main
[ 63%] Built target test_main
[ 81%] Built target gmock
[100%] Built target gmock_main
Test project /Users/tonygorez/projects/blog-snippets/testing-callbacks/build
    Start 1: test_main

...(never stop)

It’s normal, as we didn’t no modify the original function; let’s tweak the src/api.cc file:

#include "api.h"

void dummy_api(const std::string &input, Callback callback) {
  if (input.empty()) {
    callback("Input string is empty.");
  } else {                     # here
    callback(std::nullopt);    # here
  }                            # here
}

You can finally run make && ctest command:

Test project /Users/tonygorez/projects/blog-snippets/testing-callbacks/build
    Start 1: test_main
1/1 Test #1: test_main ........................   Passed    0.00 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) =   0.01 sec

Wrap it up

Congratulations if you read the full post and managed to make it work. If it’s not the case, feel free to let a comment below.

Moreover, you can check the source code.