Versions Compared

Key

  • This line was added.
  • This line was removed.
  • Formatting was changed.
Comment: Migrated to Confluence 4.0

...

  • Edit and pray - analyze thoroughly beforehand. minimize changes. Use functional testing: test, test, and retest afterward, test cross-cutting parts of the system afterward. Gives the impression that is a very cautious technique, but is it effective?
  • Cover and modify - Give yourself a safety net by adding quality unit tests around the section of code being modified. We will know instantly if we have broken known good behavior. ("code that bites back")

Software Vise

vise (n.). A clamping device, usually consisting of two jaws closed or opened by a screw or lever, used in carpentry or metalworking to hold a piece in position. The American Heritage Dictionary of the English Language, Fourth Edition

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.

...

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 on external systems, data models, etc. are baked into the code we need to modify - the logic we need to test hasn't be sufficinetly abstracted.
  • We can't physically instantiate a particular class in our testing harness:
    • It tries to pull in external libraries and APIs that can't run in the testing harness.
    • Constructing of the class requires passing objects we can't create. (an "irritating parameter").. Examples: DB Connection, network socket, etc.
    • The code we need to test is tied directly to event handlers in GUI or other UI code that cannot be executed independent of user action.

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.

  • Fake objects can break the rules of good design. Use public properties and methods to make it easy to set and retrieve values from test code.
  • Fake objects are not as sophisticated as full-blown mock objects. Mocks provide a more complete simulation of the object being substituted and have the built in ability to set assertions for acceptable interactions from the test code. Mocking frameworks exist for most OO languages and can be quite useful, however simple fake objects will be acceptable in most situations.

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:

  • Preprocessing seams - use the macro facility built into the language to substitute in fake implementations of dependencies while under test and the real implementations in production. (i.e. in C/C++, #ifdef TESTING, etc.)
  • Link seams - substituting in alternate implementations relying upon the linker. The alternate implementation must use an identical interface. Can be done at compile time, i.e. as part of the -l options passed to the compiler in the Makefile for C or C++, or at run time i.e. by setting the Java classpath variable.
  • Object seams - the most powerful and cleanest seam available in OO languages. Allows us to substitute in a new implementation by creating a test class that inherits from the same same expected base class or implements the same interface. not all method calls or seams. A seam requires an enabling point, so a case where we create an object instance and make a method call within a single method is not a seam. However if we pass in the object to be operated on as a parameter, then the argument list for the method can be an enabling point.

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