Testing Legacy Code
We all been told how good the unit tests are for the project, the positive impact from it is hugely overweight the time it requires. And we believe that the next project will be perfect with all the fancy technologies on the market, CLEAN design, and testability in mind. Usually, it ends up like a dream again. As our expertise grows we are hired to fix the existing project, to fight against the rotting design and
Let’s talk about the projects which are already deployed for a long time, already decaying, and no dependency injection as well. We can easily call this a legacy code. Legacy code is not bad, it is everywhere, almost every large business run on legacy codebase or at least some part of it. Entry points to introduce tests are hard to find, everything is tangled or uses singletons excessively. One may think to refactor the code first to test it later but it’s a trap. Source code is trustful and battle-proof, tested by consumers, and stable. Changing it for sake of tests is shooting yourself in the foot. One of the techniques while working with the legacy code is to accept source code as a trustful source and write tests against it. Refactor will make code more fragile, no one and nothing can verify the correctness of the program because the only correct thing in that kind of situation is the existing legacy code. First, cover existing code with the tests and then refactor gradually, every little step of refactoring will be guarded by the test, and the only thing we will care about be the green mark of tests. Similar techniques can be found in this popular book — Working Effectively with Legacy Code — by Michael C. Feathers. Must-read book for everyone in software engineering.
Quoting Martin Fowlers famous article “Mock Aren’t Stubs”
Dummy — objects are passed around but never actually used. Usually they are just used to fill parameter lists.
Fake — objects actually have working implementations, but usually take some shortcut which makes them not suitable for production (an in memory database is a good example).
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.
Spies — are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.
Mocks — are what we are talking about here: objects pre-programmed with expectations which form a specification of the calls they are expected to receive.
Given examples are for Android projects but the same applies to others, just change the language and testing framework.
Keep in mind mocking objects is challenging and seductive, we should always strive to keep balance, find other ways to reduce mocking requirements.
Mocking is required when the decomposition strategy has failed.
Don’t unit test I/O. Leave it for integrations. Use integration tests, instead.
Here are some techniques which showcase how legacy parts of the code can be handled but do not try to apply those to the fresh project.
Testing private functions with spies
We all know that testing private functions considered bad. We shouldn’t test private methods directly, but only their effects on the public methods that call them. Unit tests are clients of the object under test, much like the other classes in the code that are dependent on the object. A class contains important logic and part of the logic is spread to private functions. It points to bad OOP design first but the damage has done, now we need to cover it.
Replace dependencies for tests
The base class has a property that allows us to swap providers in runtime from the child classes. Obviously, this code snippet represents inheritance over composition situation. In the best-case scenario, dispatchers should be injected through the constructors but in that case, we still can override
contextPool from tests and assigned different dispatchers.
Async HTTP calls from a constructor
Unlike the previous example, not all classes might be easy to test. In legacy codebases, we often see asynchronous operations from a constructor. Following the previous scheme, we can not swap dependency on the fly because the constructor is already executed while creating the object. So the last resort is to extend an existing class, override the initial variable, and then write a test against it.
Mocks to trace the invoke flow
Relaxed mocks as dummy test doubles
relaxed mock is the mock that returns some simple value for all functions. This allows skipping specifying behavior for each case, while still allowing us to stub things we need. Using this method we can mock callbacks passed to other functions.
Sometimes the best solution is to introduce minimal edits into source code which gives us a greater advantage in testing. In that case, set a custom testing instance from the test code and restrict the visibility via annotation. This is not the first “goto” method but definitely should be considered while handling singletons.
At TBC Bank we actively use all the techniques listed above and many more to tackle the legacy part of the code. Making the project easy to maintain and onboard for new engineers.