Is It Worth It?
Implementing automated unit testing on a Large Legacy System is difficult. In order to test a particular piece of the system, you need to isolate that piece so you can control all the inputs and outputs. If your monolith is designed as a Big Ball of Mud, you may end up wasting a lot of time trying to add tests, or even abandoning tests altogether. With targeted refactoring monoliths can be manipulated to allow testing, but it takes time.
The key phrase above is “targeted refactoring”. It’s easy to get sucked down the rabbit hole of refactoring everything you come across because there are so many opportunities for improvement. If you are not careful, you can invest a lot of time adding tests that give little return.
So, the first decision to make is whether the system is worth refactoring at all. What is the future of this system? Is it causing problems? Is it frequently updated? If the system fails due to a bug, how serious a problem is that? Refactoring for unit testability is a long process, so you must ensure you will get sufficient return on the refactoring investment.
Keep the End In Mind
Once you’ve decided there is sufficient value in refactoring, you need to keep the proper goal in mind for the process. If you try to refactor from a Big Ball of Mud directly into a Clean Architecture, you’re going to have a bad time. You’ll need to do things in slow steps, breaking some rules in the short run until you can come back later and fix them. The goal is not to make it perfect but make it better than it was before you started.
Make a Plan
The first step is to plan where you are going to introduce the tests. In any application there are core objectives the app is trying to accomplish, so that is where you should spend your time.
Pretend you are building the app from scratch and decide what your core entities would be. This doesn’t have to be an exhaustive list; if you are familiar with the app it shouldn’t take more than 30 minutes to come up with a list. Pick 5 entities that would provide the most value if covered by testing and target them.
If this is your first attempt at refactoring the monolith for testing, you should avoid the most complicated entities even though they seem like they would provide the most value. Pulling apart the first few strands of spaghetti will be more difficult than normal, so picking a less complicated entity as your first one can make the process more manageable and show results more quickly.
Now that you have your refactoring targets that will provide the most value, you are ready to find the code to refactor, which is called a seam. Michael Feathers describes a seam as “… a place where you can alter behavior in your program without editing in that place.” In the legacy monolith arena, a seam is a convenient place where a module or method can naturally be broken off into a separate piece.
When you have found some appropriate seams, you are ready to start creating entity classes for your target entities. It’s best to keep these new entities in a separate project to help in tracking test coverage. You can set a rule for the team that any code in that project must have 80% coverage.
As you migrate methods to your new entities (adding unit tests along the way), you need to make these new method implementations available to your monolith code. To do this create a factory for your new entity. This lets you inject the factory into the monolith code instead of having a hard reference to the entity.
Just Be Careful
One thing to note about the method above is that all necessary inputs are passed into the method, or available as internal properties of the class. No access to global variables should be allowed. If the method needs a value that is in a global variable, pass it in anyway.
You are now ready to create your first testable method. A word of warning though. Fixing any defects that you may find during the unit test creation could have far reaching effects. Other parts of the monolith may rely on the method behaving in the “wrong way”, so don’t rely solely on the automated testing. Make sure QA checks all parts of the system that are related to the new method. Otherwise your good intentions will end up with you being the bad guy.