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.
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.
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)
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.
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.
detail
NamespaceYou might wonder why we placed parseBirthdate()
inside the detail
namespace.
detail
, there are also alternative naming conventions like impl
that can serve the same purpose.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)
parseBirthdate()
independently, thereby locking in its behavior. This gives clarity about what the function is supposed to do.DbConnection
and control its behavior.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:
ParseBirthdateTest
with a test named ParsesValidDateString
."1991-24-12"
is provided.parseBirthdate()
function, which extracts the year, month, and day.EXPECT_EQ
assertions verify that the extracted values match the expected results.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.
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.
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.