Refactoring is a critical factor in the successful evolution of a code base. Slow and small steps of refactoring help keep the code base healthy and clean.
I would classify refactoring into 3 levels ordered by increasing scope, and consequentially, effort.
1) class level refactoring
This is the type of refactoring typically done at the end of red-green-refactor cycle. As TDD practitioners are aware, you write a failing test for the business expectation, you fix the test and then refactor the code to make it ‘better’. Here are some examples:
a) rename methods/variables to clearly express their intent
b) extract duplicate lines of code into a method
c) memoize to avoid duplicate calls to expensive pieces of code
d) write idiomatic code. I was baffled when I first heard the word ‘idiomatic’ so let me take a minute to explain it. It means using the language constructs rather than the plain old constructs, that you find in almost every language. An example in Ruby would be: lets say you have a method that initializes an array, adds some elements to the array and returns the array. As you can imagine, these three steps would each correspond to a line in your method if written in a non-idiomatic fashion. If you were to write idiomatic code, you would use tap instead. Here is how it looks.
.tap do |fruits|
fruits << 'apple'
fruits << 'banana'
2) story level refactoring
This is the type of refactoring, you do when the story is “functionally” complete. Here are some examples:
a) convert procedural code into object oriented code. A symptom of procedural code is, manipulating the state of an object outside its own class. Here is an example:
In this case, some code outside the input class is making a decision of which formatter to apply to a input. This clearly breaks encapsulation by exposing the input data to something outside the input class. Doing this the right way would mean, when initializing the object you create the right type of class based on its input, like a NumericInput vs DateInput, write a format method on each of those classes and simply have the client call the format method on the instance of the class. The client should not care what instance it is calling the format method on, as long as the input type knows how to format itself.
Here is how it looks:
client code would be:
Believe it or not, a lot of your decision making statements will go away, if you create the right type of objects. To create the right type of objects, it is very important to identify the right abstractions for “state holders” and then push the behavior on to those abstractions(classes in object-oriented world).
b) Remove duplicate code from multiple classes and create common abstractions.
c) Watch out for performance-related refactorings like avoiding n+1 query problem, removing/merging duplicate database/service calls, caching expensive service/database calls, avoiding fetching more data than necessary from external services (example being fetching all the search results and counting them as opposed to directly getting count of results or getting database results and sorting the results as opposed to fetching sorted results), graceful handling of service/database error conditions.
3) big bang refactoring
This is the type of refactoring where you change a large part of your code, purely for technical reasons. The drivers for this type of refactoring are the exorbitant cost of changing a relatively small piece of code, performance being unacceptable, the code being too buggy and rather be rewritten than applying band-aid solutions or the code breaking in production and it is hard to tell what is going on. The effort is too big to be handled as a part of a ‘feature’ story and hence has to be done independently as one or more stories.
* Make separate unit tests for testing different intentions of a method. For an example, lets say a method returns a hash of attributes about a person object. So you would have a separate test for each item in the hash or at least a logical grouping of items in the hash. Here is an example
it "should format the name" do
person.to_hash[:name].should == 'Todkar, Praful'
it "should calculate the age based on the birth date" do
person.to_hash[:age].should == '29'
This way it makes it easier to understand the behavior being tested. Also if some of the logic changes or goes away it is easy to change or delete the test. Also, you could test the complicated parts more rigorously without testing everything that the method returns. You could put the test setup for the method in a before block to avoid duplication.
* Avoid unit testing of private methods of a class. Testing private methods is a smell and indicates that there is too much going on in the class being tested. The fact that you want to test the private method without going through the public interface means that you could easily create an abstraction for that code and inject it as a dependency. You could test the dependency separately and rigorously. For example, there is a method that finds the median age for a group of people stored in a database. Now as you can see, there are two separate pieces of logic here. The part where you read from the database is fairly easy to test where as testing the median age is more complicated and hence can be tested easily and thoroughly if it were its own abstraction. Also for some reason if your logic changed to find the mean age instead of the median age, your database test should remain the same.
* Try to do as much unit-level testing as possible to isolate problems during test failures. The other advantage of unit testing is that it helps reduce the combinations of integration/functional tests. Say you have to write a functional test to test a form that accepts age as one of it fields. If you have properly unit tested all the edge cases for the field accepting age as an input, then the form submission test does not have to deal with various combinations of age input. Almost always integration tests are an order of magnitude slower than unit tests. Unit tests give you faster feedback at relatively low cost.
* It is very important to have clearly defined outcomes for refactoring stories. I was on a project that had the first phase of the project dedicated to refactoring. Quickly we realized, we weren’t making much progress as it was hard to tell when a refactoring story was done. The boundary lines of the refactoring stories were blurry, hence causing confusion. I recommended that we tie refactoring effort to new feature stories, originally planned to be developed in phase 2. The approach we took was refactoring code in and around the feature we were working on. This way we had a better sense of story boundary and knew when to stop refactoring. Once the patterns were in place, it was easy to replicate those across the code base. The business also liked it as they started seeing features delivered sooner than they had anticipated.
* If you find too much mocking/stubbing of dependencies when testing a method in a class, it might be a good time to consolidate the dependencies into fewer abstractions, and delegate some of the testing to the new abstractions. Too much mocking/stubbing can be a result of a chatty interaction between multiple dependencies. It happens when you have data being passed around from one object to the other. It helps to have a owner of the data and push the behavior on to the data owner to avoid chatty interaction.
* One of the tips that I have found useful when naming classes is having the name be a noun as opposed to a verb. For example, when picking a name for a class that compares hotels, it should be called HotelComparison as opposed to HotelComparer since it feels more natural for a HotelComparison class to hold state like date of comparison, result of the comparison, requesting user. This helps in writing more object-oriented code.
* Always test the public interface. One of the biggest advantages is that unit tests don’t break when internal implementation changes. Also it strengthens the idea of having a public interface for a class.
* Functional/UI tests can be very useful when doing story level or big bang refactoring to make sure that the functionality is working as expected.
* When refactoring a large piece of code, follow a top-down refactoring approach. Make sure that you create the right abstractions first and establish the interactions between them in such a way that data lives as close as possible to the class manipulating it. Then move on to refactoring individual classes. If unit tests exist, it is easier to ignore them until you have stabilized on the classes and their methods.