Notes from Working Effectively with Legacy Code by Michael Feathers

The four reasons to change software

The common thread for all of these is preserving existing behavior. Only a small subset of behavior is actually changing – for cases 3 and 4 no outward behavior changes.

Preserving existing behavior is one of the largest challenges in software development. Even when we are changing primary features, we often have very large areas of behavior that we have to preserve.

Bad risk mitigation practices

The author notes that many developers faced with legacy code try to minimize risk by avoiding necessary re-factoring of the system. Code is added to existing methods and existing classes are extended in order to minimize the amount of physical modifications that are required. This has the net effect of reducing the maintainability of the system.

Simply minimizing the quantity of change required is not a sustainable way to minimize the risk of change.

How do you know if your changes broke useful behavior?

Software Vise

When we have tests that detect change, it is like having a vise around our code. The behavior of the code is fixed in place. When we make changes, we can know that we are changing only one piece of behavior at a time. In short, we're in control of our work.

True unit tests

A unit test that takes 1/10th of a second to run is a slow unit test.

True unit tests do not:

Don't forget the importance of less frequently run higher-level tests for the system as a whole:

Unit tests are great, but there is a place for higher-level tests, tests that cover scenarios and interactions in an application. Higher-level tests can be used to pin down behavior for a set of classes at a time. When you are able to do that, often you can write tests for the individual classes more easily.

Developing tests for legacy code

Problems that can arise trying to put legacy code into a test harness. We are trying to create tests that run fast and don't have side effects.

Dependency is one of the most critical problems in software development. Much legacy code work involves breaking dependencies so that change can be easier.

The Legacy Code Dilemma

When we change code, we should have tests in place. To put tests in place, we often have to change code.

Sensing and separation

Why do we need to break dependencies in our code base in order to implement effective unit tests?

  1. To separate the code we want to put under unit test from other dependencies that make it impossible/extremely difficult to run under the test harness. Example: A web application being dependent on the Java servlets API makes it difficult to instantiate the servlets in our test environment without a web container.
  2. To sense the effects of our code on other components in the system. Using the same example of a web application, our tests are going to need to inject certain HTTP requests into the component under test and detect if certain responses are emitted. We might do this by creating a wrapper around our application that provides a simplified interface to substitute for HttpServletRequest and HttpServletResponse.

Fake objects - A way to substitute dependencies that can't be instantiated in the test environment for a facsimile that emulates the behavior of the dependency sufficient for the test. Fake objects also allow us to sense the effects of our code under test.

Seams

A seam is a place where you can alter behavior in your program without editing in that place.

Every seam has an enabling point, a place where you can make the decision to use one behavior or another.

Types of seams:

A structured way to change legacy code

The stepwise procedure:

  1. Identify change points.
  2. Find test points.
  3. Break dependencies.
  4. Write tests.
  5. Make changes and refactor.

Common problems in changing software