Test-driven development is a technique to drive the development of your project. TDD enables you to verify your code, it provides confidence for refactoring, and it enables a cleaner architecture. But what if you already have an existing codebase that wasn’t developed with TDD? How can you get started with TDD in such a (legacy) project? There are several approaches to this, so let’s dive in!
Many projects I encounter have grown from some small beginning to a large money-making entity that won’t just accept a different way of programming. Developers feel the pain every time they need to work in this project. There are many reasons why development can be a pain:
- The project lacks a decent test suite and you never know if a change will break something
- There are many weird quirks in the system
- There are known bugs that can’t be fixed easily
- Developers often need to invest a lot of time to produce what should have been a simple feature or change
This gets worse when developers want to improve the situation and get started with TDD. Unfortunately, they can’t find an easy way of plugging in automated tests and the team ends up with one or more abandoned attempts.
So let’s explore some options for getting started with TDD.
Before getting into the technical details, it’s important that I talk about some team dynamics. Introducing TDD in your application will work better if it’s a team effort. If not everyone is on board with this TDD thing, you’ll have a harder time. I’ve seen developers flat out ignore failing tests or just remove them. If one part of the team is trying to increase the number of tests while another is sabotaging this attempt, this will lead to lowered morale, frustration, and quarrels.
You could always lead by example—and this works great when everyone is on board—but not everyone knows where to begin. Having someone write tests regularly will often inspire others to up their game too. It also provides examples for people less experienced with TDD.
The Challenges in Existing Projects
Starting with TDD in a greenfield project is easier than in a legacy project. Several challenges present themselves:
- Team members may lack the necessary knowledge of and experience in TDD
- The code might not lend itself to TDD very well
Getting Set Up
The first step that you can take is fairly non-intrusive. Create a new directory, package, or project (depending on your tech stack) for your tests and put a simple test in it. This could be as easy as asserting that true is true:
Of course this is silly, but it’s just to get the infrastructure all set up. See if you can successfully run this. This test should be green, and if you change one of the booleans to false it should be red. Update any scripts that you have to run the tests. For example, in NodeJS projects you will update the “npm test” script in your package.json file.
Next, make your CI build run this simple test. Double check to see if it also fails the build if the test fails.
Now you can start writing tests. Where do you start? This is a matter of personal preference, experience, and available resources. I’ll present some options.
If you’re at a loss of how to start with TDD, a great place to start is with small helper methods that have no dependencies. All applications have utility methods to help out with minor tasks that are repeated across the codebase. Examples are:
- Domain specific calculations
- Input verification
I once had a method that checked if two date ranges overlapped. We used this in multiple places so we put it in a static function (this was C#). This was very simple to unit test as it didn’t have any dependencies.
A next step from the previous option is to extract pieces of code into new methods that you can test. A long method may be hard to test, but maybe you can pull out a piece of it and put it in its own method. This new method may be a lot easier to test. However, you must be sure to pull out pieces that logically belong together.
Work Your Way In
A different approach is to work your way in from the outside. The idea is to write a larger integration test or end-to-end test to make sure you won’t break anything later. Then you have a safety net to start refactoring the underlying code into pieces that are easier to unit test.
While I have successfully applied this approach, I wouldn’t recommend this to teams with less experience with TDD. There is a real danger that the team will not refactor the underlying code and keep writing large tests. This often makes you end up with a testing ice cream cone.
The idea is to refactor code that is hard to test into components that are easier to test. But to reduce the chance that you are breaking things, you first write a test. Because the code is hard to unit test, an integration or end-to-end test may be easier to write. Afterward, it’s OK to throw away the initial test if you want to.
Avoid Time Periods Dedicated to Writing Tests
Customers often ask me if it would be a good idea to have a regular time slot dedicated to writing tests. They see that their current project is severely lacking tests, and hope to catch up by having the entire team write tests every Friday, for example. I’ll always say this is a bad idea (though I won’t say it so harshly).
If you’ve ever been tasked to write tests for existing code for an entire day (I have!), you know how boring and unrewarding it is. It’s a great way to demotivate a developer. In the worst case, they’ll lose any belief they had in TDD!
But I also advise against it for another reason. I’ll make a bold statement: any code in production that hasn’t (indirectly) been mentioned in a bug tracker can be assumed to be bug free and of good (architectural) quality. What I mean is that, if nobody is having problems with a piece of your software, you currently have no reason to change it, not because of a bug and not because it’s written in a way that violates all SOLID practices at once. The only reason to change it is because a new feature or a modification is required.
Maybe the code should have been written with tests in the first place, but as it stands, there are no tests for it. As long as nobody touches it, it will continue to work as it does. And if there aren’t any bug reports for it yet, there’s no reason to touch it.
Things get interesting when there is a bug report or a change request. But if these never come, you could regard writing tests for it afterward as a waste of money. Therefore, it doesn’t make a lot of sense to spend a dedicated period of time writing only tests. They provide little business value (unless you’re hoping to sell your code and having a lot of tests increases the price you can ask).
Expand As You Change
This is the strategy I recommend for legacy projects. In contrast to the previous approach, make a point of writing tests for every change you make, just like TDD implies.
Accept that the existing code is what it is, including missing tests. When a feature request arrives, implement it using TDD. That you might have to refactor your existing code in order to do so is an advantage, not a disadvantage. It will improve the quality and testability of your code over time.
If there’s a new bug report, try to reproduce it with a test first. You might have to spend some time debugging the application manually before you find the exact issue. But after you do, write a test for the method that contains the bug. If you have to extract part of the method first, that’s OK. Once you have a test that can reliably reproduce the issue, fix the bug to make that test pass.
If you take this approach, you will gradually expand your test suite, improve the quality of your code (because you’re decoupling pieces), and increase the safety net for changes. Over time, this should increase the speed at which you develop. You’ll have more confidence to do a refactoring or to add new features. New developers will be more assured that they didn’t break anything.
Don’t Stress Over the Hard Parts
If you’re taking the above approaches, but are having a hard time applying it to some crazy piece of code, don’t waste too much time on it. Maybe you need some more experience with TDD. Maybe you need more experience with that specific piece of code. Or you might not be getting any great ideas that day. Often, these hard parts also need some more refactoring at the edges before you find a way. Keep on writing tests and refactoring the other parts of your application and maybe that hard method will no longer be so hard after a few months.
It’s OK to still implement some change without TDD in legacy projects.
One last piece of advice for people beginning with TDD: do it as much as possible. Starting TDD isn’t always easy in greenfield projects, let alone in existing applications. That’s why practice is so important. Even if you’re writing something that is so simple that you don’t think it needs a test, it is still valuable to write one because:
- The piece of code could become more complex over time
- What’s simple for you might not be for someone else
- People may change the code and break something by accident
- You’re getting better at TDD with every test you write
Mark Seemann has a great article about what to test and what not to test. I definitely recommend reading it, but I’ll quote at least this:
When you’re still learning TDD, stick to the principles, particularly when it’s inconvenient. Once you’ve done TDD for a few years, you’ve earned the right to be pragmatic.
Start Now and Don’t Look Back!
Pardon the cheeky title, you can definitely look back later. I would just like to finish by encouraging you to get started as soon as possible. Get together with your team, talk about it, and get something simple set up to begin with. Then continue by steadily writing more and more tests, all while still adding business value.