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:
By the end of this article, you should be able to:
std::promise
to manage callback-based API in your C++ tests.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:
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.
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:
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:
std::promise
object called waitGuard
to manage the callback-based API.dummy_api
function with an empty string and a lambda function as the callback.EXPECT_EQ
macro to check if the error message matches the expected value inside the lambda function. (the heart of our test)waitGuard
’s value to true to indicate the callback has been executed.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.
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.
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!
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
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
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.