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:
- Set up a C++ project with GoogleTest using CMake and CTest.
- Utilize
std::promise
to manage callback-based API in your C++ tests. - 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:
- The name of the test suite
- 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 calledwaitGuard
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.
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.