An Introduction to Test-Driven Development with Legacy code
Join the DZone community and get the full member experience.
Join For FreeTest-Driven Development, or TDD, is often quoted as an essential Agile best practice, and so it is. It works wonders on green-fields projects and new code bases where you can start afresh and ensure that all your code is both easily testable and well tested. But what about legacy code? (By legacy code, I mean any code that does not have a comprehensive set of automated tests, so you might be writing legacy code as we speak). For most of us, most of the code we will ever work on will not have originally been our own work. And, unfortunately for the software industry, only a small fraction of code can really boast comprehensive unit and integration tests. How can techniques like Test-Driven Development make our work as developers more productive and less frustrating?
In this series of articles, I'll be looking at Test-Driven Development with legacy code, and refactoring techniques that you can use with legacy code to make your code more testable, and progressively build up a unit test suite for your legacy application.
In fact, Test-Driven Development (along with other related techniques such as Behaviour-Driven Development and Acceptance Test-Driven Development) is a valuable tool for working with legacy code. Test-Driven Development is not just an Agile practice; it is actually just common sense. Would you fly in an airplane where the pilot only ran through the pre-flight check list from time to time, or not at all, and fixed any issues that came up in the air? No, not really. Automated unit tests are like the checklist in a plane - they let you spot problems quickly, automatically, and with little effort on your part. Using Test-Driven Development with existing code is like finding and fixing issues using a pilot's checklist, rather then climbing onto the wing inflight and tightening a bolt or two.
But using Test-Driven Development for legacy code is a little different from using TDD in a green-fields context. The general approach is identical:
- Write a test to reproduce the error (test bar red)
- Write code to fix the error (test bar green)
- and then refactor (test bar still green)
The key here is breaking the dependencies. To effectively test-drive legacy code, you need to be able to add effective unit tests, both to understand how the code works, and to reproduce the issues you are trying to fix. And there are many ways you can do this. These include for example introducing dependency injection to break dependencies and using sub-classes to override key methods that need to be isolated.
But rather than discussing theories, patterns and abstract practices, let's look at an example. Suppose we have a class in our legacy application that builds cars, called CarFactory. Users have complained that there is an error in the serial number being generated for certain models of car.
public class CarFactory {
...
public void buildCars(int number, String model, String brand) {
for(int i = 0; i < number; i++) {
String serialNumber;
//
// 20 lines of code to calculate new serial number
//
Car car = new Car(model,brand,serialNumber);
Car.save(car);
}
}
}
The domain class looks like this. The persistence logic is embedded in the save() method. This method opens a JDBC connection and writes to the production database.
public class Car {
private String model;
private String brand;
private String serialNumber;
...
static public void save(Car car) throws PersistenceException {
//
// JDBC code to connect to the production database and to
// write to the T_CAR table
//
}
}
We have a pretty good idea that the issue might be in the buildCars() method. But to reproduce this bug in a unit test, we would need to be able to call this method. As it stands, calling this method will write data directly to the database. Furthermore, we don't have a copy of the database locally, as it is too big. So running the method in isolation is out of the question.
Or is it. To do so, we need to be able to stub out the part of the CarFactory that writes to the database, so that we can test the rest. This is made more tricky by the fact that the save() method is static. Let's start by refactoring the CarFactory class slightly, to place the persistence logic in a separate class, called CarDao:
public class CarFactory {
private CarDao dao = new DefaultCarDao();
public void setDao(CarDao dao) {
this.dao = dao;
}
public void buildCars(int number, String model, String brand) {
for(int i = 0; i < number; i++) {
String serialNumber = Integer.toString(i);
Car car = new Car(model,brand,serialNumber);
dao.saveCar(car);
}
}
}
The CarDao interface and DefaultCarDao class could look like this:
public interface CarDao {
public void saveCar(Car car) throws PersistanceException;
}
public class DefaultCarDao implements CarDao {
public void saveCar(Car car) throws PersistanceException {
Car.save(car);
}
}
The original CarFactory class is functionally unchanged - by default, it creates a DefaultCarDao object which does exactly the same thing as the original code. However, now we can inject a mocked-out CarDao object for our tests. We have broken the dependency. We can now proceed to write a test that reproduces the issue related to the serial numbers:
public class CarFactoryTest {
@Test
public void carFactoryShouldGenerateTheRightSerialNumber() {
CarFactory factory = new CarFactory();
CarDao mockDao = mock(CarDao.class);
factory.setDao(mockDao);
// I know what the serial number should be
String expectedSerialNumber = "123456";
Car aToyotaPrius = new Car("Toyota", "Prius", expectedSerialNumber);
factory.buildCars(1, "Toyota", "Prius");
verify(mockDao).saveCar(refEq(aToyotaPrius)));
}
}
We can also use this approach to ensure that the CarFactory class is invoking the Car.save() method correctly. In the following test, for example, we check that the buildCars() method calls Car.save() the right number of times, and that it passes through the correct model and brand values:
public class CarFactoryTest {
@Test
public void carFactoryShouldCreateTheRightNumberOfCars() {
CarFactory factory = new CarFactory();
CarDao mockDao = mock(CarDao.class);
factory.setDao(mockDao);
Car aToyotaPrius = new Car("Toyota", "Prius","");
factory.buildCars(10, "Toyota", "Prius");
verify(mockDao, times(10)).saveCar(refEq(aToyotaPrius, "serialNumber"));
}
}
This is just one example of this sort of approach. In future articles, I'll look at other problems with legacy code and other approaches. But in all cases, you'll find that using TDD with legacy code will both help you fix bugs and add new features faster, and progressively improve the overall quality of your code.
If your are working with legacy code, Michael Feather's 'Working Effectively with Legacy Code' contains many valuable tips and tricks in this area. I also cover how to use TDD techniques with legacy code in the upcoming Test and TDD for Java Developers course, which will be running in Sydney and Melbourne in December, and in other sites next year.
Published at DZone with permission of John Ferguson Smart, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments