Testing Effectively Legacy Code

How to write proper unit tests to already written code.

Jose Maria Valera Reales
4 min readAug 17, 2020

These tests are also known as Characterization tests.

A characterization test describes the actual behavior of an existing piece of software, and therefore protects existing behavior of legacy code against unintended changes via automated testing. This term was coined by Michael Feathers.

They enable and provide a safety net for extending and refactoring code that does not have adequate tests. A test can be written that asserts that the output of the legacy code matches the observed result for the given inputs.

How to start?

These are my learnings one year after reading Working Effectively with Legacy Code and applying it to the different projects I’ve been working on since then.

1. What do you want to test?

Find out the assertions. Create a test file for your class and a testing method for the function that you want to test.

Hint:
If we have the following method `applySomeLogic(): ReturnType`,
the test we will write should be `testApplySomeLogic(): void`.

final class MyBusinessLogic
{
private DependencyInterface dependencyInterface;
private ConcreteDependency concrete;

public function __construct(
DependencyInterface dependencyInterface,
ConcreteDependency concrete
) {
this.dependencyInterface = dependencyInterface;
this.concrete = concrete;
}

public function applySomeLogic(Input input): ReturnType
{
// black box responsible to create a ReturnType
// based on the given Input
return returnType;
}
}
final class MyBusinessLogicTest extends TestCase
{
public function testApplySomeLogic(): void
{
// I want to assert that "applying some logic"
// from MyBusinessLogic with the given Input
// I will receive a concrete ReturnType with a
// certain value as its property.
Something like:
returnType = myBusinessLogic.applySomeLogic(input);
assertEquals('expected', returnType.getProperty());
}
}

2. Instantiate the concrete/final class that you want to test.

Do not mock your concrete classes. Especially your business domain. Mock only interfaces. Otherwise, you can be hiding bugs unintentionally (with green/passing tests!). Treat your business domain classes as final.

Either mock the interface or instantiate an anonymous class if you want to create a Stub:

Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what’s programmed in for the test.

final class MyBusinessLogicTest extends TestCase
{
public function testApplySomeLogic(): void
{
myBusinessLogic = new MyBusinessLogic(
this.createMock(DependencyInterface.class),
new ConcreteDependency(/* ... */)
);

// OR

myBusinessLogic = new MyBusinessLogic(
new class implements DependencyInterface {
public function getSomeValue(): string
{
return 'A value for your test';
}
},
new ConcreteDependency(/* ... */)
);

// ...
}
}

3. Call the method from that class providing the desired input.

The output will be determined by the initial state of the business logic class that we want to test PLUS the input arguments that we are using.

input = new Input(/* ... */);
returnType = myBusinessLogic.applySomeLogic(input);

4. Assert the output with the expected value.

From step-1 you need to know what you want. Apply the assertion(s) now.

final class MyBusinessLogicTest extends TestCase
{
public function testApplySomeLogic(): void
{
myBusinessLogic = new MyBusinessLogic(
this.createMock(DependencyInterface::class),
new ConcreteDependency(/* ... */)
);

input = new Input(/* ... */);
returnType = myBusinessLogic.applySomeLogic(input);
assertEquals('expected', returnType.getProperty());
}
}

5. You might want to assert different expected values.

You can easily provide different arguments to your business logic either via the logic construction or different given arguments. To do so, use the @dataProvider annotation. The “dataProvider” method must be public and return any iterable.

final class MyBusinessLogicTest extends TestCase
{
/**
*
@dataProvider providerApplySomeLogic
*/
public function testApplySomeLogic(
array concreteMapping,
string argInput,
string expectedValue
): void {
myBusinessLogic = new MyBusinessLogic(
this.createMock(DependencyInterface.class),
new ConcreteDependency(concreteMapping),
/* ... */
);

input = new Input(
argInput
/* ... */
);

actual = myBusinessLogic.applySomeLogic(input);
assertEquals($expectedValue, actual.getProperty());
}

public function providerApplySomeLogic(): Generator
{
yield [
'concreteMapping' => ['key' => 'value'],
'argInput' => 'something',
'expectedValue' => 'expected-value-A',
];

yield [
'concreteMapping' => ['key2' => 'value2'],
'argInput' => 'something-else',
'expectedValue' => 'expected-value-B',
];
}
}

Lastly: clean what you did.

Yes, clean the tests. They deserve to be as clean as your production code. Otherwise, they will rot as time pass by and remain dirty for your colleagues and your future self!

For example, you can apply extract method refactoring to move out the implementation details (of the creation of the different objects) and keep the same abstraction level while reading the test code.

myBusinessLogic = this.createBusinessLogic(concreteMapping);
input = this.createInput(argInput);
actual = myBusinessLogic.applySomeLogic(input);
assertEquals(expectedValue, actual.getProperty());

Of course, everything depends on the context. Does it really make sense to extract into a private method the createBusinessLogic() or even createInput()? Well, that’s up to you. It depends on the number of lines and, most importantly, the abstraction level that belongs to that context.

Just remember: keep your methods small.

Now you can refactor the production code that you covered with tests without that fear of breaking it.

My copy of the book “Working Effectively with Legacy Code”.

“Legacy Code is code without tests”

Of course, there is way more to learn about testing and working with legacy code. In fact, especially when dealing with legacy code, you will encounter situations where the code is coupled somehow that you might want to mock your concrete classes because there is no interface (yet) for it.

This book presents to you a lot of techniques about when, why, where, and how you can apply these changes.

When working with code you need feedback. Automated feedback is the best. Thus, this is the first thing you need to do: write the tests.

First, add tests, then do your changes.

Change as little code as possible to get tests in place with the recipe:

  1. Identify “change points” to break your code dependencies.
  2. Break dependencies.
  3. Write the tests.
  4. Make your changes.
  5. Refactor.

--

--

Jose Maria Valera Reales

Aka: Chema. I love writing about stuff that I find interesting and bring some value to my life, so I can share them with you. https://chemaclass.com