โ† Back to blog

Testing C++ callback based APIs

7 min read

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

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.