Testing Effectively Legacy Code

How to write proper unit tests to already written code.

These tests are also known as Characterization tests.

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.

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
{
Something like:
returnType = myBusinessLogic.applySomeLogic(input);
('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:

final class MyBusinessLogicTest extends TestCase
{
public function testApplySomeLogic(): void
{
myBusinessLogic = new MyBusinessLogic(
.createMock(.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);
('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
{
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);
($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, . 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);
(expectedValue, actual.getProperty());

Of course, . 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 that belongs to that context.

Just remember: keep your methods small.

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.

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.

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.es