If you’ve ever worked with me, you probably know I’m a big proponent of test-driven development, TDD. It’s benefits are great, and drawbacks minimal. Often, the downsides exist in the critic’s imagination or come from someone who has little experience with TDD. But if TDD drives our design, what if we already have a design, however bad. How can TDD help with technical debt? Allow me to elaborate.
The Usual WorkFlow
As a short reminder, TDD works as follows. Before you start writing (or changing) code, you write a failing test. Then, you add or change the code as little as possible until the test succeeds. Of course, any existing tests must also still succeed. Once you have that, you look at your code and see if you can refactor it, i.e. improve it. As you add more tests, you will see more opportunities for refactoring.
This is the famous red-green-refactor flow:

To get your code under test, you will have to write it in such a way that you end up with a better design than when you wouldn’t have written tests. Usually, this will mean loosely-coupled pieces of code that are small enough to be easily changed. It’s no guarantee, but it will help.
Also, an experienced developer can often write loosely-coupled code with a good design that is testable, without writing the tests. It’s just that TDD improves your chances.
This works great for new code, or code that has already been written with TDD. It’s a little harder if you or your predecessors have (knowingly or unknowingly) taken on technical debt and made things harder to test.
How Technical Debt Ruins the Party
When we take on technical debt, it often means we cut corners. Code tends to be more tightly-coupled, things are hard-coded, we don’t abstract as much as we should, etc. We end up with code that is difficult to test, save for a manual test in a staging or QA environment.
Your tests might need a database, or cause your code to call external services. Or maybe it’s too big a task to get the application in a state that can act as a starting point for your test. Whatever it may be, developers might dread the work ahead of them and so they opt not to write tests.
They don’t take the TDD route and just add or change the code that is necessary. But as they do so, they make it that extra bit harder to add a test. Fast-forward a few months or years, and the application seems practically impossible to test. But not entirely.
Why TDD Can Help With Our Technical Debt
With technical debt and legacy code, some pieces of the code might be near impossible to get under test. But others could be fairly easy. Every codebase that seems to be a disaster has some smaller methods or pieces of code that are ideal candidates to get started. Simple helper methods that require minimal input, contain minimal logic and produce a value without side-effects (e.g. no database or third-party service calls).

Start with these methods. Then expand from there. Extract pieces of code from large methods into smaller methods that you can test. There are many techniques to do this, but that’s not the subject of this post.
The reason you should apply TDD to your technical debt is that it will help you get it under control. A piece of code that is under test will not deteriorate even further. It helps you halt the debt. In fact, it will also help you pay back the debt. When bigger pieces of your code are under test, you have a safety net to do the refactoring. You can improve the design of the code, i.e. pay back the technical debt which you have been postponing for so long.
We’re often afraid to refactor big pieces of complex code, but if we have it under test, we gain increased confidence to make changes. And if a new feature needs to be added, it will be easier to write tests for the new feature. Applying TDD to your technical debt is a virtuous cycle.
Test First or Test Later?
You could argue that with technical debt, you’re not doing TDD because you’re writing the tests after the code. True, for the existing code. But you’re still doing the third step of the TDD flow: refactoring. So we’re just doing green-refactor:

The green tests are what allow you to do the refactoring step, a critical part of TDD. And refactored enough, you’ll notice that new features are easier to develop with the full TDD cycle of red-green-refactor.
TDD Always Helps
Technical debt often makes code hard to test. To be able to test it, you might need to pay back some debt first. But adding tests also makes it easier to write tests. The two forces strengthen each other. The more you do one, the easier the other will be and vice-versa.
Whether you’re writing new code or tackling legacy code, TDD will always help you. I’m not going to discuss how you should or should not do TDD or what kind of tests you should be writing. Those are separate discussions, especially in the light of legacy code and technical debt.
But that TDD will help you out with your technical debt, that’s a reality I’ve experienced multiple times.