Developers learn to write tests in controlled environments. You take a class, write unit tests for simple functions with no dependencies, and everything works beautifully. Then you face real code—code with dependencies, databases, file systems, APIs—and the techniques you learned suddenly feel inadequate.
I’ve been there. The gap between learning to test and actually testing existing code can feel insurmountable. That’s where characterization tests come in.
What Michael Feathers Taught Me
Years ago, I read Working Effectively with Legacy Code by Michael Feathers. The book introduced me to characterization tests, and the technique fundamentally changed how I approach untested code.
The core idea is simple: before you understand what the code should do, document what it actually does. You’re not writing tests to verify correctness—you’re writing tests to capture current behavior.
Here’s how I internalized it: focus on an area you want covered by tests, then write just enough code to get a clean pass through that code path. If it’s a method in a class, I write code to instantiate the class, call the method with parameters, and run the test without throwing an exception. That’s it. A clean pass.
To make it slightly better, I might add an assertion: if the method returns a value, I’ll verify it’s not null. I’m not asserting on specific properties or values yet. Just proving the code runs.
The Real Work: Dealing With Dependencies
This is where it gets interesting. Depending on the class, just getting that clean pass can keep you busy for a while.
When instantiating the class, I’ll pass null values or arbitrary values for dependencies. I run the test. It blows up. As soon as it tries to call a method on one of those null dependencies, I get an exception.
Now I see what I need to overcome.
If the dependency is an interface, it’s easy—I pass in a mock. That’s the ideal situation.
If it’s not an interface, I need to instantiate the real dependency. But that dependency might have its own dependencies. So I work my way through it.
Sometimes I’ll introduce an interface to break the dependency chain. But I don’t want to break the method’s contract. So what I might do is duplicate the test, give it a different name, and then update the contract to allow me to take another step. I replace the concrete dependency with an interface and let the compiler tell me which methods or properties the interface should have.
I work through each dependency until I no longer get exceptions. Once the test hits the bottom of the method and passes, I move to the next phase.
Capturing Current Behavior
Now I write assertions that verify the returned values for the input I passed. And here’s the key: I’m not concerned whether the values are accurate or correct.
Let’s say I’m calling a sum method, passing 2 and 2, and it returns 5. My assertion will say exactly that: when I call this method with 2 and 2, I get 5 back. I run the test. It passes.
Maybe there’s business logic inside that method that rounds up to odd numbers. Maybe there’s a bug. I don’t know yet. The purpose is to get a passing test that proves: given this input, the code currently returns this output.
Then I duplicate that test, change the input, capture the output, and update the assertions. I do this until I have code coverage I’m comfortable with—mainly ensuring I’m going through most, if not all, branches in the code.
At this point, I have a suite of passing tests that characterize the current behavior.
Refactoring With Confidence
Now the real work begins. With characterization tests in place, I can refactor the code to understand what it’s actually doing. I extract code into separate methods or classes. I understand how dependencies are used. I figured out why 2 plus 2 is giving me 5.
Those tests are my safety net. They prove that what’s currently there doesn’t throw exceptions and always returns the expected results for known inputs.
As I refactor and better understand the code, I also refactor the tests. I make them express different behaviors based on different inputs. I identify issues, miscalculations, or unexpected logic. And once I have that clarity, maintaining both the code and the tests moving forward becomes much easier.
Legacy Isn’t About Age
One more thing: don’t think legacy code means code written 20 years ago. It can be code written two days ago. If it’s convoluted and cumbersome, it’s legacy code.
For me, legacy code isn’t code without tests. It’s code people want to run away from. Even if there are tests, if they’re messy, people still won’t want to work on that code.
Characterization tests give you a way in. They let you establish a baseline, understand what’s really happening, and gradually transform code people avoid into code people can work with.
It’s one of the most valuable techniques I’ve learned for writing tests for legacy code. And it works whether the code is two decades old or two days old.






Leave a Reply