DEV Community

Cover image for Testing Private Logic in C++ Libraries Without Breaking Encapsulation
VladimirM
VladimirM

Posted on

Testing Private Logic in C++ Libraries Without Breaking Encapsulation

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

  1. Install the package:
   vcpkg install fcftest
Enter fullscreen mode Exit fullscreen mode
  1. Configure your CMake project by pointing to the vcpkg toolchain:
   cmake .. -DCMAKE_TOOLCHAIN_FILE="~/path/to/vcpkg/scripts/buildsystems/vcpkg.cmake"
Enter fullscreen mode Exit fullscreen mode

2. Manual Installation from GitHub

If you prefer to manage the source yourself:

  1. Clone the repository:
   cd ~/path/to/projects/directory
   git clone https://github.com/fcf-framework/fcfTest.git
Enter fullscreen mode Exit fullscreen mode
  1. 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"
Enter fullscreen mode Exit fullscreen mode

Step 5. Build and Run

cd ~/path/to/test/project/build
cmake --build .
./runner 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Summary of Benefits

  1. Double Encapsulation Protection: Your library's public API remains clean for end-users, while internal logic is kept private and only exposed to specific, authorized tests.
  2. 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.
  3. Smart Debugging: If a test fails, the FCF_TEST macro 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
Enter fullscreen mode Exit fullscreen mode

Let's Discuss!

How do you usually test private logic in your C++ libraries?

Happy testing!

Top comments (0)