This post is part of an ongoing series about Unit Testing.
This entire series has been about writing good tests and writing them first. This is great for “green-field” code, where we’re working on something new. But often, we’re modifying existing systems. These systems might or might not have tests, and the tests may or may not be of good quality.
What do we do then?
Systems With Good Tests
If the system we work on already has good tests, we’re in a great place. We need to keep writing good tests. Maybe there are some ideas from this series that can make the tests even better.
Systems With Bad Tests
If our system has tests but they’re not very good, we can begin working to improve the situation.
We can write better tests for new code we work on.
As we touch parts of the system, we can make incremental improvements to the tests we have to deal with. Over time, the test suite (and system as a whole) will get better.
The best part of this strategy is that the parts of the code that change the most will get the most attention and improvement. Over time, the “nice” parts of the system will expand and we won’t have to go into the dark, nasty corners as often.
Systems With No Tests
If our system has no tests, we have what is known as “legacy code”. I’ve written about refactoring legacy code, but not about writing tests for it.
This is a huge topic that could fill an entire book. Fortunately, I don’t have to write that book because Michael Feathers already did. His Working Effectively With Legacy Code is the go-to guide for this topic. I highly recommend that you read it if you have to work with legacy code.
We spent some time writing tests for legacy code during my RailsConf workshop.
I’ll talk about a few ideas here.
Break the Rules
Legacy code is often difficult to test because it wasn’t written with tests in mind. There will be dependencies that are hard to work around, weird corner cases, and more.
Because of that, we should feel free to break all the rules of writing good tests. With legacy code, the goal is just to get some kind of test coverage in place. That test coverage will allow us to refactor the code to make it more testable. Then we can write better tests and throw out the tests we used to get there.
Certainly, we should try to write good tests where we can. But if we can’t, we shouldn’t be worried about making a mess in the tests. We just need a safety net in place. The initial tests, which Feathers calls characterization tests, are a scaffold that comes down when the job is done.
Break Even More Rules
Sometimes, we’ll need to test some code and there won’t be any way of getting at the thing we need to test. It might be hidden away in an object somewhere, or otherwise inaccessible to our tests. Feathers calls these “sense points” and talks about a number of ways to safely change the code in order to expose sense points.
Some languages provide better tools than others for getting at sense points. In Ruby for example, we have several tricks at our disposal.
We can use Ruby’s reflection capabilities to get at an instance
variable that has no reader defined by using
Or, we can use “monkey-patching” to re-open the class we’re testing and add a couple of methods that can give us access to what we’re interested in.
We won’t want to keep this code around for long, but it can be a great tool to get started.
Test Bigger Pieces
Our initial tests in legacy code might be at a high-level, testing several integrated components rather than individual units. That’s OK. The code is probably not built in a way that allows us to test isolated units like it would be if we test-drove the code initially.
In the worst case, we might have to test the input and output of the entire program because there’s no intermediate level that allows us test access.
Same, Not Right
When we’re writing characterization tests, we don’t really care if the code is giving the right answers. We only care that we keep getting the same answers as we start making changes.
For this reason, the Golden Master or Guru Checks Output approach that I cautioned against in my post on anti-patterns is a great tool for legacy code. We can just capture the Golden Master; we don’t have to care much about the contents of it - we just want to make sure nothing changes. We do need to make sure that the Golden Master is consistent. It shouldn’t change from test run to test run. Things that can cause problems are timestamps and object ids, among others. Make sure your Golden Master output is isolated against those, or that the comparison you do against the Golden Master ignores the variable parts of the output.
Katrina Owen has written a really nice Ruby gem, called Approvals, that makes Golden Master testing convenient. I used this gem to good effect in my legacy code workshop at RailsConf.
Getting tests around a legacy system of size is going to take time and the world is not going to stop to allow us to fix everything at once.
We need to make constant small improvements over time. We can write tests for every new thing. We can add tests for any piece of code we need to modify for our current task.
Over time, we’ll learn more about the system. Cleaning up little things will allow us to see some of the deeper structural problems.
There is a lot more I could say about testing existing code, but I’ll stop here and defer the rest of the discussion to Michael Feathers’ book.
Next week, I’ll wrap up this series on testing.