Testing Private Logic in C++ Libraries Without Breaking Encapsulation
When developing dynamic or static libraries, a common challenge arises: how to achieve high test coverage for internal logic without exposing private members to the end-user.
Typically, without a specialized testing framework, testing private methods often leads to "dirty" code—either by making members public or by using intrusive workarounds that violate the principles of Object-Oriented Programming.
My name is Vladimir Markin, and I am the author of fcfTest—a lightweight C++ unit testing library designed to solve exactly this problem. In this article, I will show you two "lifehacks" for testing private methods within a shared library using fcfTest.
Documentation: https://fcf-framework.is-a-fullstack.dev/cpp-libraries/fcfTest
GitHub: https://github.com/fcf-framework/fcfTest
Hack 1: Testing Private Class Methods via friend
To make a test a "friend" of a class, you usually need to know the exact name of the class generated by the testing framework. In fcfTest, the FCF_TEST_DEFINE macro accepts an optional fourth parameter: an explicit name for the generated test class. This allows you to elegantly grant your test access to the private sections of your class.
Implementation Example (shared_engine.cpp)
#include <fcfTest/test.hpp>
class MySecretEngine {
private:
int _state = 42;
int computeInternalData(int multiplier) {
return _state * multiplier;
}
// Declare the future test class as a friend.
// You decide the name!
friend class MyEnginePrivateTester;
};
// Define the test.
// We pass our chosen name as the fourth argument.
// The first three arguments are the Part, Group, and Test name strings.
FCF_TEST_DEFINE("Engine", "Internal", "PrivateMethodTest", MyEnginePrivateTester) {
MySecretEngine engine;
// The test has full access to private methods and fields!
int result = engine.computeInternalData(2);
// Perform the assertion.
FCF_TEST(result == 84, result);
}
Why is this better?
You aren't opening your class internals to the entire world. Instead, you are providing surgical, targeted access to one specific test class.
Hack 2: Separating the Shared Library from the Test Runner
The example above should reside within your dynamic library (.dll or .so), but you still need a way to execute those internal tests.
The strategy is simple: your dynamic library will contain all the necessary tests and export the fcfTest symbols. An external "Runner" application will then link against your library and trigger the execution.
Step 1. Embedding Tests in the Library
To achieve this, you must define two macros before including the fcfTest/test.hpp header: FCF_TEST_IMPLEMENTATION and FCF_TEST_EXPORT.
-
FCF_TEST_IMPLEMENTATION: Enables the implementation section within the header. This should be defined in only one C++ file in your library. -
FCF_TEST_EXPORT: Tells the compiler to export the fcfTest symbols (essential for DLL/SO).
Let's update shared_engine.cpp:
// Defining FCF_TEST_IMPLEMENTATION tells the compiler
// to include the actual logic of the library in this file.
#define FCF_TEST_IMPLEMENTATION
// Export fcfTest symbols so they are available to the runner.
#define FCF_TEST_EXPORT
#include <fcfTest/test.hpp>
class MySecretEngine {
private:
int _state = 42;
int computeInternalData(int multiplier) {
return _state * multiplier;
}
friend class MyEnginePrivateTester;
};
FCF_TEST_DEFINE("Engine", "Internal", "PrivateMethodTest", MyEnginePrivateTester) {
MySecretEngine engine;
int result = engine.computeInternalData(2);
FCF_TEST(result == 84, result);
}
Step 2. Creating the Runner
The Runner is a lightweight standalone application that links to your library. You include the framework using the FCF_TEST_IMPORT macro. When executed, the framework automatically discovers all tests "embedded" within the linked library.
File: runner_main.cpp
#define FCF_TEST_IMPORT // Import registered tests from linked libraries
#include <fcfTest/test.hpp>
int main(int argc, char* argv[]) {
bool error;
// fcfTest automatically collects and runs tests from the linked library!
// Supports flexible CLI filtering (e.g., --test-part Engine)
fcf::NTest::cmdRun(argc, argv, fcf::NTest::CRM_RUN, &error);
return error ? 1 : 0;
}
Step 3. Create CMakeLists.txt
Here is a standard CMakeLists.txt to tie it all together:
cmake_minimum_required(VERSION 3.15...${CMAKE_VERSION})
project(FcfTestProject)
# Locate the external 'fcfTest' package.
find_package(fcfTest CONFIG REQUIRED)
# 1. Create a dynamic shared library
add_library(shared SHARED shared_engine.cpp)
target_link_libraries(shared PRIVATE fcf::fcfTest)
# 2. Create the runner executable
add_executable(runner runner_main.cpp)
target_link_libraries(runner PRIVATE fcf::fcfTest)
# Link the runner with our shared library
target_link_libraries(runner PRIVATE shared)
Step 4. Configuring and Installing fcfTest
You have two main ways to integrate fcfTest into your project:
1. Using vcpkg
This is the easiest way to manage dependencies.
Please note that the version on vcpkg typically lags a few days behind the current version on GitHub.
- Install the package:
vcpkg install fcftest
- Configure your CMake project by pointing to the vcpkg toolchain:
cmake .. -DCMAKE_TOOLCHAIN_FILE="~/path/to/vcpkg/scripts/buildsystems/vcpkg.cmake"
2. Manual Installation from GitHub
If you prefer to manage the source yourself:
- Clone the repository:
cd ~/path/to/projects/directory
git clone https://github.com/fcf-framework/fcfTest.git
- Configure your project by providing the path to the cloned directory:
mkdir ~/path/to/test/project/build
cd ~/path/to/test/project/build
cmake .. -DCMAKE_PREFIX_PATH="~/path/to/projects/directory"
Step 5. Build and Run
cd ~/path/to/test/project/build
cmake --build .
./runner
Expected Output:
Performing the test: "Engine" -> "Internal" -> "PrivateMethodTest" ...
[SUCCESS] Test completed successfully (0.000`003`562 sec)
[SUCCESS] All tests were completed.
Tests: 1 passed, 0 failed, 0 skipped, 1 total
Duration: 0.000`003`562 sec
Summary of Benefits
- Double Encapsulation Protection: Your library's public API remains clean for end-users, while internal logic is kept
privateand only exposed to specific, authorized tests. - No "CMake Magic" Required: You don't need to mess with complex private include paths in your test targets. The tests live exactly where the code lives.
- Smart Debugging: If a test fails, the
FCF_TESTmacro provides a detailed report including the file, line number, and the exact values of the variables at the moment of failure.
Example Failure Output:
Test error: result == 84 [FILE: shared_engine.cpp:25]
Values:
result: 82
Let's Discuss!
How do you usually test private logic in your C++ libraries?
Happy testing!
Top comments (0)