Every organization has one: the critical software system that nobody dares to touch.
It powers your core operations, but its codebase is a mystery. Your development team calls it “untestable,” and every change is a high-wire act without a safety net. This reality stifles innovation, slows time-to-market, and introduces significant business risk.
The term “untestable” is often a misnomer. The code isn’t magically immune to testing; it was simply never designed for it. The business need was for speed and functionality, not long-term maintainability. The good news is that you don’t need to scrap and rebuild from scratch. With a pragmatic and strategic approach, you can gradually impose order on the chaos, reduce risk, and reclaim your ability to innovate confidently.
Shifting the mindset from fear to strategy
The first step is not a technical one; it’s a cultural shift. The goal cannot be to build a perfect, comprehensive test suite overnight. This big bang approach is doomed to fail, costing time and money without delivering tangible value. Instead, the strategy must be one of incremental improvement.
Think of it not as a project, but as a permanent change in how your team operates. The objective is to start building a safety net around the code, one thread at a time. This allows you to make the changes your business needs today while simultaneously investing in a more stable tomorrow. The return on investment is measured in reduced downtime, faster feature delivery, and lower stress for your valuable development teams.
The golden rule: Working with the code you have
A common trap is believing you must refactor the entire codebase before you can write a single test. This is a recipe for paralysis. The most effective strategy is to accept the code as it is and find ways to work within its constraints. Your team should adopt a simple rule: when you need to change a piece of the legacy system, you first find a way to get it under test. This means that the parts of the application that are being actively worked on, the parts that matter most to the business, become more stable first.
This approach aligns technical debt reduction directly with business priorities. You are not spending cycles testing code that never changes; you are fortifying the areas where change is happening, thereby directly de-risking your current development initiatives.
The initial focus: Characterisation testing
Before you can test for correctness, you need to understand what the code currently does. This is the purpose of characterisation testing. Think of it as a process of discovery and documentation. Your team writes tests not to check if the code is right, but to capture and lock in its current, observable behavior.
In practice, this involves probing the application. Given a specific input, what output does it produce? What data is written to the database? These observed behaviors become the baseline for your tests. The goal is not to justify the behavior, even if it seems wrong. The goal is to capture a snapshot of the system’s reality. This creates a safety net that will alert you if that behavior accidentally changes during a future modification. It is the foundational step that prevents regressions and gives developers the confidence to make changes.
Finding the seams for strategic intervention
To test a tightly wound piece of legacy code, you rarely start with the code itself. You must find the seams, i.e., places where you can observe the system’s behavior without immediately altering its internal structure. A seam is a boundary where you can gather data.
For many applications, the most effective seam is the user interface. While often complex, modern testing tools can interact with the UI to perform actions and verify results. This allows you to write broad tests that cover entire user workflows. Another powerful seam is the database. You can write tests that set up specific data in the database, trigger a process, and then verify that the correct changes were made to the data.
Starting at these higher-level seams allows you to create valuable tests immediately, without getting bogged down in the internal complexity of the code. These tests may be slower to run, but they provide immense business value by verifying that critical business processes still work as expected after a change.
The surgical strike: Targeted refactoring for testability
Once you have a safety net of broader tests in place, you can begin to make more targeted, internal improvements. This is where refactoring comes in—the process of carefully restructuring the existing code without changing its external behavior. The characterisation tests you wrote earlier are your guarantee that you haven’t accidentally broken anything.
The aim of this refactoring is not to add features, but to introduce testability. This might involve breaking apart a massive, thousand-line function into smaller, focused functions. It might mean clarifying confusing variable names. Each small improvement makes the code a little easier to understand and a little safer to modify. Over time, these small, surgical changes compound. A module that was once a tangled knot becomes manageable, and your team can write faster, more precise unit tests for it, further accelerating development and reducing risk.
Building a culture of sustained stability
Ultimately, taming legacy code is not a one-time project; it is an ongoing discipline. It requires business leaders to support a culture where quality is not sacrificed for short-term deadlines. It means empowering developers to spend time on foundational work and celebrating the wins when a previously fragile part of the system becomes robust and easy to change.
The journey from untestable to reliable is a marathon, not a sprint. But by starting with characterization, leveraging seams, and making incremental refactoring part of the daily workflow, you transform your legacy system from a liability into a stable asset. You unlock your team’s ability to innovate with confidence, finally turning the beast into a partner for growth.




