24 November 2021
How to ease the pains of testing legacy code?
Practically every programmer in their career struggled with working on a legacy project or one in which at least part of the job involved some kind of legacy code. I will show you some tips and tricks which will make writing unit tests for legacy applications much easier and less hurtful. Let’s go deep into testing legacy code!
We all know that working with legacy code can be a reaaal pain in the… you know what:
– But wait a second! I’m new in IT and I don’t know what legacy code is!
Let’s explain what it really means.
Legacy code – what is it really?
Every code or project has a lifecycle. First it’s developed, then it’s released and finally – it’s supported. The project is live for a couple of years and many things change: a new version of the language is released, the approach to writing code is changed (methodology). Perhaps, the code itself is obsolete, ugly and programmers do not want to work with it anymore.
Another commonly used definition of legacy code is code written without proper tests. The first definition (“ugly code”) does not necessarily rule out the second one (“code without tests”).
We can say that there are many different cases when code can be called “legacy”. A fair number of programmers think that assigning them to a legacy project is punishment. Well, I agree it can be perceived like this. However, at the end of the day somebody has to do it.
It could also be considered a “challenge”. Making a legacy project better, refactoring it to more commonly used, predictive and readable solutions can bring a lot of valuable experience and satisfaction.
This article will focus on creating unit tests. If you’re not familiar with those, here is a helpful article on unit testing.
Test-driven development (TDD)
There are two main approaches to writing unit tests.
One is test-driven development. In simple words, it means we’re writing tests before implementation and then the implementation needs to pass those tests. By following the rules of TDD, we should not worry about code coverage because it should be very high. That’s because we do not create implementations without tests.
Another approach is writing a lot of code and then providing 1000 unit tests because the client asked about test coverage. The PM said 80% and there was no other choice but to do it.
It’s clear as day that that kind of approach will not lead to anything good… The dev team will be forced to raise test coverage to 80%. They will be doing it in a hurry so quality will be lower than expected – not a very good idea. But in many legacy projects, even those developed with TDD, there can be elements in the code where there was this one small feature that had to be released ASAP, and everybody agreed there is no need to test that.
Repeat that many times over and, boom, we got legacy code with a lack of tests for many features.
Most common problems with testing
Let’s start with the environment.
The environment used in examples
Since I work as a PHP developer, I will use the PHP language version 8.0.2. The library used for writing unit tests is rather mainstream PHPUnit version 9.x. In many examples, I’ll use Mockery, an additional library extending the possibilities of PHPUnit itself.
Let’s assume you’re a programmer assigned to a legacy project and there is 10% coverage of code with unit tests and your task is to make it better. I’ll show you examples of problematic situations which can occur while you write those tests and possible solutions to these problems.
Testing final classes
When we browse the code of legacy PHP applications, there will be a high possibility to find classes that are declared final but do not offer/implement an interface. From a language point of view, it’s not forbidden. The code will work. But it can be problematic when we will be writing unit tests for a class that will be using this as a property passed and set in the constructor. To understand why marking classes with “final” without an interface is bad, you can read this article on declaring classes final.
Let’s take a look at an example:
The cook class, which will be tested:
The cook class dependency cooker, which should be mocked because it’s not the point of interest of the test case:
The unit test for the cook class:
The cooker class is declared final so this test will fail. The result will be:
The problem is that PHPUnit cannot create a double for the final class. There are a few solutions for this problem, the most professional and suitable solution is the first method as it is improving code quality instead of just bypassing wrongly used final methods.
Let’s see possible solutions:
The code can be refactored and improved. Using this solution requires changing the existing code, and it’s not always granted. We can add an interface for the final class and use it as a dependency of the Cook class. Then, we can mock this interface to bypass the final declaration inside the test case. The changed code will look like this
The Cooker class changed its name to ElectricCooker. It’s still the final class and it implements the Cooker interface:
Because Cooker is now the interface, the rest of the classes haven’t changed. Now, after launching the test, we should see the expected green result:
We can also use bypass-finals. This tool can be used to remove final declarations on the fly while running unit tests. Using this tool is very simple. It has to be enabled before the test. We can use the setUp function of PHPUnit for that purpose. Let’s look at an example:
As the name suggests, it’s a kind of bypass that should not be used if it’s not necessary, but can be very helpful and can save a lot of time. On the other hand, it can slow down your unit testing but it really depends on the project. I recommend trying it on your own to see if it has a negative impact on the time needed to run a full unit tests suite.
Another way to do it is using the mockery package. With this package, we get a lot of useful tools such as the proxied partial mocks, which can be used to bypass the final keyword. The created mock acts like a proxy to a passed object in the constructor. In other words, it will reroute calls from mock to mock target objects.
This solution also has a catch when we decide to use this type of mock. We cannot check an instance of class anymore as it’s not an expected class but rather it is a mock targeting object of this class. As in the example, when we get the type hinting in the method signature, we cannot use it as it’s not the type expected by signature. But in legacy code, there will be a situation when there will be no typehint before the function parameter. In that case, it can be used. But be aware – since it’s not a mock, it can have a side effect. Let’s look at an example:
Here we got a modified Cook class without cooker typed in the construct:
And modified test usage of Mockery mocks is similar to PHPUnit mocks. Let’s look at an example:
Another commonly used practice inside legacy projects is using a native PHP function such as file_get_contents for manipulating filesystem. Generally, it’s not recommended as we have many packages providing an abstraction layer for those operations, which let us write better tests. The process of mocking dependencies is easier/faster. While writing tests, we have to be sure that the tests have no side effects. We can ensure that in many ways.
Testing with temporary files in var/tmp
We can provide some temporary locations for file operations, which will not be risky. For example, in linux/mac /tmp or on windows /temp both catalogs got max permissions. Anybody in the system can write to those. Those locations are cleared regularly by the system but we will make sure that after each test, the state of our temporary location will be the same.
For resetting the catalog system, we can use built-in methods in PHPUnit setUp and tearDown. The setUp method is fired before running each test inside testClass. The tearDown method is different in that it’s called after each test from testClass. Let’s look at an example of a service that does a simple filesystem operation and at a corresponding test case.
The service making filesystem operation:
The test class:
The service class is rather simple and it does not need any explanation. Let’s go through the test class. In the test function, we’re passing a location for a risk-safe file operation. The manager creates a file and we’re checking the result of the service method call if the file exists in the required location and if the file content is as provided to the manager.
In the setUp method we’re making sure that a valid risk-free location is created. And with tearDown, we’re removing the content of this location and the location itself to “reset state”. This way, after each test, we go to a clear playground for the next test.
filesystem abstraction layer
In my opinion, the previous solution is a case of reinventing the wheel. For better readability and more flexibility, we can use a package created by the community. It is well-known and tested. This way, we can focus on our goal and rather than on how to do that properly as we will have a commonly accepted solution. Let’s take a look at an example usage:
The code is simplified. We installed the flysystem library and now, inside the construct, we’re requesting filesystem abstraction. Flysystem supports many different drivers such as Amazon s3 driver or FTP or as used in the example localfilesystem driver.
Let’s see how we can rewrite the test for our case:
Tests are simplified so we don’t have to create any file in the temporary directory. We rely on a commonly used library, which is well-tested itself. We only have to test a valid flow of using it. In this case, one test was added.
What if something goes wrong? Because the write method can throw an exception, we simply handle it via try-catch, and this situation is treated as a false result. As I said, it’s simplified so we should handle it a bit differently in a real project. But it’s showing how it can be done.
Behavior testing of final methods
PHP allows developers to use the final keyword in two contexts. The first was already introduced (declaring class final) and the second one is declaring a method final. The method declared as final cannot be overridden. It can be useful to restrict some methods in the code, but it can also be a troublemaker when it comes to writing tests with mocks. When we create a mock class with a method declared as final, we cannot expect it to call and declare a return in the mock. The example code below represents this problem:
The cook class:
The cooker class with the final method “preheat”:
The cook test class:
In the test method, we try to create the cooker class mock and add behaviour on calling the preheat method, which is declared final. Tests will fail and errors will be displayed.
Let’s see solutions for this problem.
It might be the most suitable solution because there is a possibility to refactor this code. We can do that by creating and adding a suitable interface to the final class. Then we can mock this class using the contract described in the interface.
Once again, we can use bypass-finals. It will drop those final keywords in runtime – no final no problem. Just add this method to the test class:
Proxy partial mocking
If we expect a mixed type inside the method signature where the type with the final method is passed, we can take advantage of a proxy partial mock. This kind of mock will reroute every call to a function that has not set any expectation. It means that we can mock the final method, but it has a big disadvantage: it’s not extending the mocked type.
This limitation makes it hard to use in modern well-written code. To create this kind of mock, we just have to pass an instance of the object to which calls will be routed.
New operator mocking
What if in the tested class we got an instance of some kind of model with a new operator? Take a look at this example:
The function is preparing toast from predefined ingredients. it’s creating instances of ingredients inside the Toaster class. It’s not the responsibility of the class and it’s making writing well-covered tests harder.
The simplest solution for that is creating an ingredients factory and then testing a mock of this factory inside. Now the Toaster class has a new dependency – IngredientFactory. It will be responsible for creating ingredients. A simplified implementation of the factory:
Now, we can easily write tests for Toaster class as we can mock factories to mock creating ingredients:
Testing legacy code – summary
Most of the problems described in this article are caused by bad design. If code had been consistent with object-oriented programming principles like SOLID, this would not have happened in the first place. Easy to say, harder to do. Here are a couple of final takeaways:
- It’s almost impossible to have perfect SOLID code in a big application.
- Every bad code can be refactored and this should be the first choice before using some kind of bypass.
- The refactor solution usually is the most time-consuming, but it will have more benefits in the future. We should write a code that we will not be ashamed of.
- Working with legacy code, especially writing tests, can be challenging. Many people are scared of this kind of task. They’re scared of technical debt and they feel this is not a valuable experience. I don’t agree that it’s not valuable.
By working with legacy code, we can learn from errors. With the tips and tricks I presented, testing legacy code should be a little less painful and time-consuming. The cases in this article focused on writing unit tests, but we should not think that writing these is enough. Every project will benefit from higher-level tests. One case will be best taken care of using unit tests, another is more suitable for integration tests. In other words, we have to find a perfect balance. It’s not possible or nearly impossible to cover everything with tests but at least we have to try.
If you want to learn more about other types of tests, please take a look at an article on PHP unit testing written by one of my colleges. Even in today’s projects, there will be legacy code to deal with sometimes. As developers, we should know how to work with it and be less skeptical about it.
Are you also interested in working with microservices?
Check out our State of Microservices report, which includes a chapter on debugging microservices.