Test Last development: A primer for unit testing of legacy code.
Test Last development: A primer for unit testing of legacy code.
Test driven development or TDD is a proven and effective means of software development but it assumes you are beginning with a blank slate. What is not understood about TDD is that some of the approaches when implemented against legacy code during Test Last Development or TLD may potentially harm your code base.
If you have ever developed against legacy code then you have no doubt come across applications that were so tightly coupled that refactoring of the original code was ruled entirely off limits. This document will attempt to provide you with best practices around creating unit tests for legacy code, with the goal of enabling your group to progress toward developing the code coverage you need to have confidence in your legacy code base.
Before you begin
While creating tests for legacy code is important to future development, it is even more important to take care when creating tests for legacy code that is already in a production environment. The following items should be considered before writing any new tests.
What are the unknown dependencies or prerequisites? There are often uncovered dependencies or prerequisites that methods assume exist and you will need to carefully track these. The most obvious and easiest to uncover involves references to 3rd party code libraries, the code will not compile if these are not present, but dependencies can also mean configuration settings that must be set up before testing can occur. You should understand configuration settings that must be in place for the tests to work.
Are there specific environment variables and local only simulations required? You may need to simulate the production environment in order to run the legacy code. This can involve changing connection strings and updating configuration files so you can simulate your production environment. You’ll need to document and track these dependencies carefully because you want to ensure they don’t get checked in to the code base. You’ll also want to ensure you comment them in your tests.
Can you change code? In TDD you create unit tests as you go which means that testability is an inherent consideration, in Test Last Development however, testability is rarely a consideration. It is necessary to understand and get agreement on whether or not you can make changes to the code as you are creating your unit tests. For instance, if you are creating test cases between builds, it may not be possible to change the code and you’ll need to work within that framework. If however you are between versions, you should get consensus to make small changes to the code.
Is the code testable in the current state? Does the code consist of large methods that do many complex tasks, or are there a large number of subroutines that are not easily testable? Often times making code more testable involves breaking up large complex methods into many smaller or “chunkier” ones. Sometimes you will see code full of subroutines, which return void and are for the most part un-testable by traditional TDD standards. You will need to find ways of ensuring code compatibility along the way, one way of doing this is to keep the original method signature the same.
Focus on what is possible rather than what isn’t. Don’t get caught up in the fact that you can’t test every line of code, in fact, even when practicing Test Drive Development it isn’t always possible to achieve 100% code coverage. Just start with what you can reasonably test and go from there. Once you begin creating your first tests, you’ll find yourself identifying and prioritizing code that was previously thought un-testable.
Creating your tests
Picking a component to test. For legacy code, you want to start by identifying the largest and most sweeping test you can make that will give you the most “bang for the buck”. This may mean identifying one single method that does the most critical job in the component, but it can also mean identifying a method or object that is most critical or commonly used by an application. If you are testing a stand-alone application, you may want to start with the Main method, since a failing Main will cause the entire application to break. If you are testing a library, you will want to identify the chief function the library performs and test that functionality first. This may be the factory methods or conversion methods that define the library’s purpose.
Create Fixtures Once you have created your first test, you will begin to identify a common framework for creating other tests, and hopefully identified common dependencies. Fixtures are the set up and tear down methods that run in your test harness before and after your unit tests. The setup method prepares the environment for your tests and the teardown methods perform cleanup.
Cover as many tests as possible. The TDD method mandates that you only write enough code to make a test pass, fail then pass again testing method boundaries as you go. When you are dealing with thousands of lines of legacy code however, the TLD method mandates you get as much coverage as possible in the shortest amount of time. In TLD your methods are already written and many times already in production so initially you only want to focus on passing tests, boundary testing at this point should wait until you are ready to re-factor the legacy code.
Debugging and re-factoring your code. If the legacy system is proven, most of your tests will pass initially, however any time you are creating tests for a previously untested code base you are likely to find a few bugs hidden in the code. When this happens you will need to decide whether or not to fix the bugs. The TDD’s “clean as you go” methodology says to re-factor until the test passes, with legacy code this isn’t a good practice. You may introduce bugs into the rest of the code base that go unnoticed due to the lack of coverage. Many times you don’t have the budget or the time to re-factor legacy code and many times the mandate will be to keep legacy code as it is. Whenever you re-factor legacy code, you’ll want to avoid it as much as possible during the test creation process or keep it to an absolute minimum.
Testing top-down functionality. The TDD programming requires you to analyze the task in hand using top-down problem decomposition and come up with a list of tests based upon those scenarios. Legacy code has a defined set of tasks, so you can start immediately writing your tests at the highest level rather than looking at individual methods. You may find you are writing too many tests and need to move some of your tests into the fixtures later but that’s ok. Initially try to take note of what the application is doing and write tests for each thing it does first, and move shared tests into fixtures later.
Considering alternate pathways through the code. Try to organize up your tests into cohesive groups. Think of testing related functionality and divide this by libraries or packages, then classes, then methods and finally lines of code. In other words, you start writing tests for critical functionality in a single library that does some function. Start with one critical class in that library with a goal of writing tests for every class in that library, and every method in each class in the library and eventually with the ultimate goal of having coverage for each line of code in each method.
Testing against the data store. Many times you will find numbers of subroutines that return void. While this may seem counterintuitive, you’ll still need to find creative ways of testing that these subroutines do what they promise. Sometimes these subroutines change state of objects and sometimes they are linked to data operations like executing stored procedures or inserting data into a data store. While TDD methodology insists that you mock up objects or separate the data store from the tests entirely, this is not always possible or practical in legacy code without a major refactoring and as stated earlier, we want to avoid refactoring until we have sufficient code coverage to ensure success. This may mean you will need to test directly against a data store until you have enough code coverage to begin refactoring or mocking up data. This may make tests more complicated but ignoring methods returning void and depending on preexisting data is not an option
Don’t depend on preexisting data. Depending on pre-existing data in a data store is not desirable and may yield false results. If you rely on pre-existing data is deleted or changes from the expected state, all of your tests that depend on that data will yield false results. If you need to test against a data store, you can use Fixtures to insert or prepare the data in the correct state and remove or reset the data after the test is finished. Preparing your data first will ensure you always get the results you expect.
Testing dead methods. During the process of testing you will find that you come across dead methods or methods whose functionality is no longer needed. It may surprise you to see how much of your code is no longer needed. If you come across code that is suspect you can easily verify this with TFS by searching for all references of that code. Once you have verified the code is dead you can safely remove it. The less code you have to maintain, the better off you and everyone responsible for the code will be.
Don’t worry about achieving 100% code coverage, think of the coverage percentage as a moving target, after all, each time the component is changed or a method is added your percentage will also change. Pick a target number that will confidently allow you to re-factor the legacy codebase, and work your way up, ensuring you have covered the critical functionality in each component along the way. Once you have reached your goal number, confidence in your code will increase and the amount of time you spend looking for bugs in the wrong places will decrease significantly.