Bringing C++ Legacy Code Under Test

19.03.2025 15:15:33 by Andre
Focused function which was brought under test

Why Should We Bring Legacy Code Under Test?

In long-standing software projects, making changes without introducing bugs can be tricky. For newcomers to a codebase, understanding existing logic and modifying it safely is often a challenge. New employees may hesitate to refactor code if they aren’t sure whether it still functions as expected. Sometimes, even the original intent of the code isn’t fully documented or known. This uncertainty makes every change risky.

One way to gain confidence in code changes is through Test-Driven Development (TDD). Unit tests provide quick feedback on whether modifications work as expected—not only during runtime but throughout development. By writing tests for existing code, we effectively “lock in” its current functionality. This makes it clear what the code is supposed to do, serving as both a safety net and documentation. When new team members start working on the project, they can rely on these tests to understand and verify behavior, reducing the risk of unintended changes.

However, anyone who has worked with legacy code knows that introducing tests isn’t always straightforward. Dependencies, side effects, and tight coupling can make unit testing nearly impossible. If you’ve ever inherited a project with zero test coverage, you’ve likely faced this frustration.

But is untestable really the right word? More often than not, the problem isn’t that the code can’t be tested—it’s that it wasn’t designed with testability in mind. And that’s something we can change.

Testing the Untestable

A common challenge in testing legacy code is dealing with external dependencies, such as databases or file systems. These dependencies make it difficult to isolate business logic in unit tests.

Take this function as an example:

#include "db/config.hpp"
#include "db/connection.hpp"
#include "main/user.hpp"

#include <sstream>
#include <string>
 
auto validateUsersPassword(const Password& user) -> void
{
    const DbConfig config{"127.0.0.1", 3306, "dbuser", "password123"};
    const DbConnection db{config};
    const auto result{db->query("SELECT birthdate FROM users WHERE id %s", user->getId())};
    
    const std::string birthdate{result->asString("birthdate")}; // Example: "1991-24-12"

    // Parse birthdate
    std::istringstream iss(birthdate);
    int year, day, month;
    char separator1, separator2;
    iss >> year >> separator1 >> day >> separator2 >> month;

    // Further processing...
}Code language: C++ (cpp)

What’s the Problem?

A First Step: Extracting a Function

Instead of refactoring everything at once, we start small. One way to break dependencies is to extract pure logic into a separate function.

Goal: We want to separate business logic from infrastructure code (like database access), which also serves to document and stabilize what the code is expected to do.

Step 1: Moving Logic to a Free Function

namespace detail {
auto parseBirthdate(const std::string& birthdate) -> std::tuple<int, int, int>
{
    std::istringstream iss(birthdate);
    int year, day, month;
    char separator1, separator2;
    iss >> year >> separator1 >> day >> separator2 >> month;
    return {year, month, day};
}
} // namespace detail
Code language: C++ (cpp)

Now, parseBirthdate() can be tested in isolation without requiring a database.

The Role of the detail Namespace

You might wonder why we placed parseBirthdate() inside the detail namespace.

Step 2: Adjusting the Original Function

auto validateUsersPassword(const Password& user, const DbConnection& db) -> void
{
    const auto result{db->query("SELECT birthdate FROM users WHERE id %s", user->getId())};
    const std::string birthdate{result->asString("birthdate")};

    const auto [year, month, day] = detail::parseBirthdate(birthdate);

    // Further processing...
}Code language: C++ (cpp)

Why Is This an Improvement?

Adding a Unit Test Example Using GTest

To illustrate the benefits of this refactoring, here’s an example unit test using Google Test (GTest) to test our parseBirthdate() function. This test verifies that the function correctly parses a given date string:

#include <gtest/gtest.h>

#include "main/date_parser.hpp"  // Making detail::parseBirthdate function available

#include <tuple>
#include <sstream>
#include <string>

TEST(ParseBirthdateTest, ParsesValidDateString) {
    const std::string input{"1991-24-12"};
    const auto [year, month, day] = detail::parseBirthdate(input);

    EXPECT_EQ(year, 1991);
    EXPECT_EQ(month, 12);
    EXPECT_EQ(day, 24);
}
Code language: C++ (cpp)

Explanation of the Test:

This GTest example demonstrates how isolating business logic into a pure function allows for straightforward and reliable unit testing—even when legacy code originally depends on external systems.

Conclusion

We’ve taken the first step toward making legacy code testable without massive refactoring.

What’s Next? This approach can be applied to other areas of the codebase. Each small improvement increases test coverage and makes future refactoring easier. Ultimately, by fixing the functionality with unit tests, we provide clear documentation of what the code does—empowering new and existing developers to work with confidence.

Got a better example of untestable code? Feel free to leave a comment below if you have a different example or additional insights on tackling untestable code. Your feedback helps improve the discussion and guides others facing similar challenges.

Did you like this article?

We’re available for contract work! Feel free to call us (+49 5063 7993999), send us an email, or use our contact form. We’d love to learn about your challenges and look forward to a pleasant tea session.


Leave a Reply

Your email address will not be published. Required fields are marked *