Unit testing legacy code the lazy way
When it comes to maintaining or expanding a legacy code, it is no surprize that we often find ourselves struggling with a mess of magically changing global variables, 1000-lines long methods, which do hundred different things inside and keep all logic to itself. Sometimes changed requirement or just popped production bug demand a limited incision – and often you afraid to touch the fragile masses of code around you and just try to minimize the potential impact and hope for the best.
More often than not method you need to change was developed in unenlightened age, when people used computers made of wood did not concern themselves with principles of cohesion or separation of concerns or thoughts about testability. It is hopelessly long and complex and business functionality is mixed with calls to database or services. Testing such a method is a dreadful task, which easily can eat a lot of time and bring the wrath of management on your head. But it still is no excuse to leave this code without any test coverage – so try to cover at least functionality which you are adding or even affecting. There are tricks you can use to test the legacy code fore cheap and with little overhead, quickly getting to the important logic testing.
Let’s consider the following (greatly simplified) legacy method.
It contains some logic which we would like to test, but also two calls to the complex methods, on of which hits database – CallDB and another, which calls outside service – RequestService. Those we would like to keep aside and maybe test them in separation, if time permits.public bool ProcessEligibility(Person person) { // Some functionality which we leave outside of our testing // A bunch of business logic which we would like to test bool result = CallDB(new ConnectionManager(), person.Age, person.Position); //Another bunch of business logic which we would like to test ServiceResponse response = RequestService("http://real.com/svc", person); return response.IsSuccessful; }
Let’s use Template Method derivative to minimize our testing efforts. Our goal is convert undesired method to dependencies which can be injected to improve testability.
First, let’s refactor the functionality, which we will test, out to a protected method:
Next we lay a foundation for our methods to be extracted as a parameters. We can use a Func<T1, T2, TOutput> type to describe functions in our specific case, but unfortunately Func delegate allows maximum three input parameters while old-fashioned methods can accept 10 or more! In this case we have to create a delegate type ourselves:public bool ProcessEligibility(Person person) { // Some functionality which we leave outside of our testing return ProcessEligibilityConcrete(person); } protected bool ProcessEligibilityConcrete(Person person) { // A bunch of business logic which we would like to test bool result = CallDB(new ConnectionManager(), person.Age, person.Position); //Another bunch of business logic which we would like to test ServiceResponse response = RequestService("http://real.com/svc", person); return response.IsSuccessful; }
Now let’s refactor the main and sub-methods further, injecting methods as dependencies (let’s describe one as a Func<T1, T2, TOutput> and another as a custom delegate):public delegate TOutput HyperFunc<T1, T2, TOutput>(T1 input1, T2 input2);
Now we can test the ProcessEligibilityConcrete method in isolation, providing our own mock or stub methods as substitution:public bool ProcessEligibility(Person person) { // Some functionality which we leave outside of our testing return ProcessEligibilityConcrete(person, CallDB, RequestService); } protected bool ProcessEligibilityConcrete(Person person, Func<ConnectionManager,int,string,bool> dbCaller, HyperFunc<string,Person,ServiceResponse> serviceCaller) { // A bunch of business logic which we would like to test bool result = dbCaller(new ConnectionManager(), person.Age, person.Position); //Another bunch of business logic which we would like to test ServiceResponse response = serviceCaller("http://real.com/svc", person); return response.IsSuccessful; } public delegate TOutput HyperFunc<T1, T2, TOutput>(T1 input1, T2 input2);
I said at the beginning that we got this test coverage for cheap. It is not quite true: we paid with adding redundancy and some obscurity to the original code. We performed some refactoring but we clogged the final result a bit, reducing code readability, although all refactorings were safe, especially if you used tools like Resharper.[TestMethod] public void TestEligibility() { var result = ProcessEligibilityConcrete(new Person(), fakeDbCaller, fakeServiceCaller); Assert.IsTrue(result); }
To do it right, we should do a real refactoring and use mocking or other more sophisticated techniques. But between Fast, Good and Cheap we can pick only any two and weight out our trade-offs.
1 comment:
Nicely done.
Post a Comment