Preserve Your Legacy
Enable unit testing of legacy code through refactoring patterns
by Russell Gold
April 14, 2005
Java developers are rapidly discovering the advantages of refactoring to fix architectural problems in existing code. Refactoring properly requires fast unit tests that can verify the refactoring doesn't break existing functionality. However, many of us work on systems that do not already have unit tests, and in many cases those systems do not easily lend themselves to unit testing as they stand.
This situation leads to an apparent deadlock in which refactoring cannot be done without tests, but tests cannot be written until refactoring is done. Therefore, let's look at a pattern-based approach to supporting legacy code, retrofitting unit tests, and enabling refactoring.
The first step is to recognize that you really can change your code safely, even without tests. Don't be surprised. Most developers have been doing exactly that for years, relying on occasional manual tests, long development cycles, and external testing to catch the worst problems. Refactoring makes this process much safer in that it involves well-defined steps, each of which is virtually guaranteed to improve the structure of the code while maintaining its behavior. Many of these steps can be automated. IntelliJ IDEA, Eclipse, and the latest version of Oracle JDeveloper are among the development environments that support several common refactorings, allowing for very safe code changes.
If you must refactor before adding tests, you want to do as little as possible, focusing on the specific goal of making future changes safe and easy. Taking time to consider what makes systems easy to change is well worth the effort.
Code Modularity
When you call a method that returns a value, the exact means by which it computes that value is generally not relevant to the calling code; what matters is the answer and any change to the state of the system that might cause different answers in the future. The more information the calling code needs to know about how that method works and what it needs, the greater the coupling between it and the method and the more difficult it is to change one without affecting the other. For example, this code shows very little coupling between the getLatestBetAmount() method and the caller:
int getLatestBetAmount() {
return _bets.selectBet(
BetList.LAST).getAmount();
}
:
if (getLatestBetAmount() >
BET_LIMIT) {
doSomething();
}
You could easily change the algorithm without modifying the calling logic at all; for example, it might prove worthwhile to cache the latest bet as it is made or modify the bet collection to return the latest amount directly. These changes would not affect the calling code. On the other hand, if you write:
int getBetAmount(
BetList bets, int whichBet ) {
return bets.selectBet(
whichBet).getAmount();
}
:
if (getBetAmount(
_bets, BetList.LAST ) >
BET_LIMIT) {
doSomething();
}
it would be very difficult to modify the getBetAmount() method or the BetList class without also changing the caller.
As it happens, this greater coupling also complicates the job of unit testing. In the first example, it would be relatively straightforward to test the calling code alone by supplying it with an implementation of the method that returned a known value and did not involve the BetList class at all. In the second example, the test would need to include the BetList class. Legacy code tends to be full of such couplings, which makes both changing it and unit testing it difficult. Changing legacy code is especially problematic when code you want to modify or test relies on large classes that are expensive to set up, or on external systems or hardware.
Any functional test would allow you to set up whatever the system needs, so using functional tests rather than unit tests is an obvious approach to try when working with legacy code. Functional testing can work in the short term, but it is not a long-term solution.
Functional tests are great for QA and testing groups because they are dependent only on what will be delivered to customers and end users; they are not very useful for developers because developers don't use tests the same way. Typically, professional testers do acceptance testing; their focus is on verifying that the system is good enough to be released. Because a release does not happen very often and catching any problems before other groups see the code is so important, acceptance testing stresses thoroughness over speed.
Developers, however, are interested mostly in making changes to their code quickly and safely, and therefore they need rapid feedback from tests. In a typical code-and-test cycle, you make several changes to the code and then test to see if you did it correctly. If something goes wrong, you need to fix it before proceeding. The more code you change between tests, the more code you have to undo or correct. If it takes 20 minutes to verify your changes, you will be unwilling to run the tests repeatedly without doing at least 20 minutes or more of coding in between. On the other hand, if you can verify your changes in less than a minute, it makes sense to test after doing just a little bit of coding and then simply reverting rather than debugging if something goes wrong.
Get Functional
Functional tests in general cannot run this quickly. Most applications have a fair startup time (certainly in multiples of seconds), and sometimes the classes to be tested need to be set up differently for different tests, which necessitates shutting down the application and starting it up several times. Many inputs to a system go through multiple layers before reaching the classes of interest, and the logic of the intervening layers often requires multiple passes before the classes to be tested and the resources they use are in the appropriate state. All of these factors add to the time testing requires.
In contrast, a unit test that needs to instantiate only the classes to be tested (and a few other small classes on which they depend) can run in milliseconds. The key, then, is to eliminate any coupling on expensive resources, whether they are large classes or external hardware.
Now let's look at some of the most common sources of such coupling and ways to eliminate them.
The strategies for removing coupling are dependent on the form of that coupling, but each strategy can be described as a sequence of short refactoring steps, many of which you can perform simply by highlighting a block of code and selecting a menu item. In the examples presented here, the names of the refactoring steps correspond to refactoring menu items in IntelliJ IDEA. Other tools may use slightly different names. Refactoring steps that correspond to entries in Martin Fowler's online refactoring catalog appear as hyperlinks.
Perhaps the most common source of coupling to problematic objects is through method or constructor parameters. When you have a class that is difficult to instantiate being passed to the code you want to isolate, it may be simple enough just to ignore it. For example:
Connection getConnection(
String target, ArrayList pool,
ConnectionFactory factory ) {
for (Iterator i =
pool.iterator; i.hasMore;) {
Connection c = (Connection)
i.next();
if (c.hasTarget( target )) {
i.remove();
return c;
}
}
return factory.createConnection(
target );
}
Note that in this case, the ConnectionFactory class is not even accessed if a suitable connection is in the pool. If you're simply testing fetching connections from the pool and you're not interested in the code path that creates a new one, you can ignore the ConnectionFactory class entirely and just pass a null value. If it doesn't work, you will get a NullPointerException in your tests and have to use a different technique. However, passing a null value is generally worth trying unless you know that it cannot work.
A more common case was shown in the example for illustrating coupling:
int getBetAmount(
BetList bets, int whichBet ) {
return bets.selectBet(
whichBet).getAmount();
}
Note that all our method needs from the BetList class is to be able to invoke its selectBet(int) method. Assuming that you can modify the BetList class, you can simply use Extract Interface to create an interface that supports this method and then modify the getBetAmount() method to use the new interface:
int getBetAmount(
BetSelector bets,
int whichBet ) {
return bets.selectBet(
whichBet).getAmount();
}
This modification doesn't require any changes to the callers of getBetAmount(), which is more complicated when the parameter is recorded in an instance variable or passed to other code. In such cases, you may need to add several methods to the interface. The easiest way to find out is to try. Extract the interface, modify the type of the parameter, and recompile. You will quickly discover any accesses that cannot be satisfied with the methods you have added to the interface and can resolve them by adding more.
Singletons
If this change compiles, your test can provide an alternative implementation of the interface, typically some kind of stub whose behavior is part of the test. In the preceding case, this implementation might suffice:
class TestBetSelector
implements BetSelector {
public Bet selectBet(
int which ) {
return new Bet(
0, 0, 5.00 );
}
}
The test could then verify that getBetAmount() returned 5.00, the expected value.
It is not uncommon to find heavy objects stored as singletons. You can handle them by using a slight variation on the preceding technique:
public RaceComputer(
int var1, String var2 ) {
_var1 = var1;
_var2 = var2;
}
public void displayRaceMessage(
String message ) {
ToteBoard.getToteBoard().
displayMessage( message );
}
You change this step by step, using your refactoring editor to minimize the chance of error. First, use Extract Field to pull the singleton into a local variable, selecting "Initialize in Class Constructor":
private ToteBoard _toteboard;
public RaceComputer(
int var1, String var2 ) {
_var1 = var1;
_var2 = var2;
_toteboard =
ToteBoard.getToteBoard();
}
public void displayRaceMessage(
String message ) {
_toteBoard.
displayMessage( message );
}
You can now create an additional constructor that takes the ToteBoard as one of its parameters and avoid duplication by having the existing constructor call it:
public RaceComputer(
int var1, String var2 ) {
this( var1, var2,
ToteBoard.getToteBoard() );
}
RaceComputer( int var1,
String var2,
ToteBoard toteBoard) {
_var1 = var1;
_var2 = var2;
_toteBoard = toteBoard;
}
Now you have the same case as earlier—a troublesome object passed as a parameter. You can either pass null or perform an Extract Interface step to work with the ToteBoard parameter.
Singletons are a special case of coupling introduced by static methods, but they are certainly not the only case. The general case can certainly be more difficult; however, there is a general-purpose technique that handles a wide range of couplings. You simply introduce a façade that wraps the static class. This technique is at least similar to Introduce Local Extension. Consider this code:
public void displayRaceResults(
raceNum ) {
for (int i = 0;
i < numDisplays; i++)
Displays.getDisplay(i).
displayLine( line );
}
}
First, use Extract Method to isolate the static method call:
public void displayRaceResults(
raceNum ) {
for (int i = 0;
i < numDisplays; i++)
getDisplay(i).
displayLine( line );
}
}
private static Display getDisplay(
int i) {
return Displays.getDisplay(i);
}
Then make the newly created method public, create a new DisplayList class, and use Move Method:
class DisplayList {
static public
Display getDisplay(i) {
return Displays.
getDisplay(i);
}
}
Now you can instantiate the class in the original method:
public void displayRaceResults(
raceNum ) {
DisplayList list =
new DisplayList();
for (int i = 0;
i < numDisplays; i++)
list.getDisplay(i).
displayLine( line );
}
}
This approach gives you what is effectively a singleton, so you apply the techniques used there: move the local variable to a field that is initialized in the constructor, extract a constructor that lets you specify the field, and supply a stub in your tests.
Further Steps
As you use these techniques to remove your couplings, you will probably want to combine any façades and interfaces you create, to minimize duplication. Doing so will result in a slightly different and more modular design for your code—at least the code near the classes you are trying to test. As you continue, you will probably decide that the interfaces you have now created are not what you want in the long term, and you will want to change them. Fortunately, with the tests you were able to create, it will be safe and easy to make such changes.
As you can see, unit testing to facilitate development is important, and we've discussed some obstacles to unit testing legacy code as well as some techniques for overcoming those obstacles. In particular, there are three main sources of coupling: parameters, singletons, and static methods. Using refactoring patterns can decouple code in each case.
About the Author
Russell Gold is a consultant member of the technical staff in Oracle's Server Technologies division and a member of the architecture team for Oracle Application Server Containers for Java. He is also the author and maintainer of HttpUnit, an open source library that supports test-first development of Web sites. Contact Russell at .
|