Unit Testing Effectively

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

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

“Legacy Code is code without tests”

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.

  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

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store