Legacy Code To Testable Code: Introduction
The word “legacy” has a lot of connotations. Mostly bad ones. We seem to forget that our beautiful code gets to “legacy code“ status three days after writing it. Michael Feathers, in his wonderful book “Working Effectively With Legacy Code” defined legacy code as code that doesn’t have tests, and there is truth in that, although it took me a while to fully understand it.
Code that doesn’t have unit tests rots. It rots because we don’t feel confident to touch it, we’re afraid to break the “working” parts. Code rotting means that it doesn’t change, staying the way we first wrote it. I’ll be the first to admit that whenever I write code for the first time, it comes in its ugly form. It may not look ugly immediately after I wrote it, but if I wait a couple of days (or a couple of hours), I know I will find many ways to improve it. Without unit tests I can rely either on the automatic capabilities of refactoring tools, or pure guts (read: stupidity).
Most code doesn’t look nice after writing it. But nice doesn’t matter.
Because code costs us, we’d like it to help us understand it, and minimize debugging time. Refactoring is essential to lower maintenance costs, and therefore unit tests are essentials.
Refactoring is where you start paying
The problem, of course, is that writing unit tests for legacy code is hard. Code is convoluted, full of dependencies both near and far, and without proper unit tests its risky to modify. On the other hand, legacy code is the one that needs unit tests the most. It is also the most common code out there – most of time we don’t write new code, we add new functionality to an existing code base.
We will need to refactor the code to unit test it, in most cases. Here are some examples why:
- We can’t create an instance of the tested object.
- We can’t decouple it from its dependencies
- Singletons that are created once, and impact the different scenarios
- Algorithms that are not exposed through public interface
- Dependencies in base classes of the tested code.
Some tools, like PowerMockito in Java, or Typemock Isolator for C# allow us to bypass some of these problems, although they too come with a price: lower speed of unit test runs and code lock-down.
The lower speed come from the way these tools work, which makes them slower compared to other mocking tools. The code lock-down comes as a side effect of extra coupling to the tested code – the more we use the power tools’ capabilities, they know more about the implementation. This leads to coupling between the unit tests and the code, and therefore make the unit tests more fragile. Fragile unit tests carry a bigger maintenance cost, and therefore people make the least effort to change them, and the code. While this looks like a technical barrier, it manifests itself, and therefore can be overcome, by procedure and leadership (e.g., once we have the unit tests, encourage the developers to refactor and improve the code and the unit tests).
Even with the power tools, we’ll be left with some work. We might even want to do some work up front. Some tweaks to the tested code before we write the unit tests (as long as they are not risky), can simplify the tests. Unless the code was written simple and readable the first time.
We’ll need to do some of the following:
- Expose interfaces
- Derive and implement classes
- Change accessibility
- Use dependency injection
- Add accessors
- Extract method
- Extract class
Some of these changes to the code is introducing “seams” into it. Through these seams, we can enter probes to check the impact of the code changes. Other changes just help us make sense of it. We can if these things are known refactoring patterns or not. If we apply them wisely, and more important SAFELY, we can prepare the code to be tested more easily and make the unit tests more robust.
Check out the ever expanding posts in the series:
The Legacy Code To Testable Code Series
|General patterns||Accessibility||Dealing with dependencies||Advanced patterns|
|Introduction||Add setters||Extract method||Static constructors (initializers)|
|Renaming||More accessors||Extract class||More static constructors|
|Add overload||Introduce parameter||Instance constructors|
|Testable object||Conditionals to guard blocks|
Image source: http://memecrunch.com/meme/19PWL/exploring-legacy-code
rliesenfeld · October 6, 2014 at 6:23 pm
I don’t think that JMockit (a mocking tool more powerful than PowerMockito) has a performance problem. It certainly seems fast enough to me.
As for “code lock-down”, please try and provide some real evidence, rather than just hand-waving.
The actions/refactorings you listed are all fine, as long as they are done for valid reasons, not just as workarounds for technological limitations, specially if said limitations can be avoided through the proper testing tools.
Gil Zilberfeld · October 7, 2014 at 6:21 am
Thanks for the comment. I understand it well, since I also gave (and continue giving it), having worked at Typemock on mocking tools that don’t require changing the code. My aim is for well designed code, whether using with Mockito or JMockIt. Once you select the tool, you should continue on that path. Refactorings obviously help, and as you know, extracting a method for the sake of mocking 1 method rather than 5, is good for both testing purposes and usually the comes back better.
I’ll write a post on lockdown in the future. But in a nutshell, the more powerful the mocking tool, the more coupling between the code and test increases. You’ve probably heard it in “you misuse the tool” form (I know I have many times), but it does happen. Once this coupling happens, and you have a working test, it becomes a question of convenience whether to modify the code or keep it like that, just that I wouldn’t need to change the test.
Yes, it’s never the tool, it’s the programmer. And yes, I’ve seen many tests and code remain the same once the test passes.
PS Nice work on JMockito!