Coupling and Testability

Coupling is the degree to which one section of code depends on another. Clearly, test code depends on the code it tests based on the nature of that relationship. For black-box testing, test code depends on the interface of the test target as well as any data types or dependent interfaces or signatures used in the interface. If a method takes a type in its parameter list, the calling code—and therefore the tests—need to be able to obtain or create that type. When a method returns a type, the calling code uses that value, expressing a degree of knowledge about that type. The same holds true even when interfaces are used because interfaces specify types. Functional, procedural, and dynamic languages are not immune to this effect. The signatures—the return types and parameter lists—of callbacks, listeners, functors, etc. all introduce additional coupling to our code.

White- or gray-box tests may create additional dependencies as we test restricted methods that are not part of the public contract and as we test implementations that use types that are not otherwise exposed. It is not uncommon to have internal and entirely private abstractions that support the implementation.

All of these relationships between test code and the code under test increase the degree of coupling between the test and the test target. Thorough testing tends to maximize the potential coupling between the two participants.

Software design aims to minimize coupling. Coupling measures dependencies between components. Dependencies may represent a necessary use of provided functionality to achieve the business purpose of the software. However, the decomposition of software for any given problem into components is only one of many possible solutions. Often the initial implementation needs to change as the software grows and the functionality increases. If the interfaces were designed with enough foresight and luck, the component can be refactored, preserving the external behavior and contract for all callers. No such guarantee exists for gray- or white-box tests, as refactoring does not commit to preserve the implementation details. In more extreme cases, we must rewrite the component, likely breaking or invalidating all calling code, including the black-box tests.

The evolution of a software product forces changes in code, particularly in tests. Often people justify lower levels of testing based on this effect. That does not need to be the case. Neither the existence of tests nor the fact of code change causes tests to become brittle. Just as we design our software to be less brittle internally as it evolves, we can apply principles of design for testability to make our test code less brittle as well. This requires changing our concept of “good design” to extend to characteristics of testability as first-order drivers. Coupling is the enemy of scaling a testing practice, especially for unit testing.

Let’s look at some aspects of this coupling in more detail. Ideally, a unit test couples to the unit under test2 and only that unit. However, unless we constrain ourselves entirely to the fundamental data types of our language, that rarely happens in practice. Interfaces like the one from Java shown in Listing 4-1 or the one from JavaScript in Listing 4-2 often refer to other constructs in our most fundamental abstractions.

2. I will use the term “unit” in this section because the principles apply regardless of whether we are testing objects, components, modules, functions, or some other elemental building block, regardless of programming paradigm.

Listing 4-1: A Java interface from the Swing package showing use of other classes. Note that the PropertyChangeListener comes from a package that is independent of Swing.

package javax.swing;
import java.beans.PropertyChangeListener;
public interface Action
    extends java.awt.event.ActionListener {
  java.lang.Object getValue(java.lang.String s);

  void putValue(java.lang.String s, java.lang.Object o);

  void setEnabled(boolean b);

  boolean isEnabled();

  void addPropertyChangeListener(
    PropertyChangeListener propertyChangeListener);

  void removePropertyChangeListener(
    PropertyChangeListener propertyChangeListener);
}

Listing 4-2: A JavaScript functional interface from jQuery showing the use and representation subset of an event object. Even though JavaScript is dynamically typed, the convention of the contents of the event object couples callers to the signature.

jQuery.on( events [, selector] [, data],
    handler(eventObject) )
{
  target:
  relatedTarget:
  pageX:
  pageY:
  which:
  metaKey:
}

We overlook that the use of a type in an interface constitutes part of the contract of that interface. The use of the type can couple users of that interface to the additional type, increasing the overall coupling of the system. Because tests maximally use the features of the interface to get good code coverage, the tests use all of the parameter and return types. Typically, tests must create instances of parameters, invoking constructors, setters, and other methods, each coupling the test to those implementations and representations. Builders can abstract that coupling, but they introduce coupling to the builder. Test factory methods consolidate the dependencies, providing a shallow layer of insulation against coupling, but the coupling remains.

Similarly, tests use the return values from the methods in the interfaces to verify the results of operations. In the best cases, the tests can verify the return values through inherent equivalence (e.g., the Java Object.equals() method), a frequently hidden but relatively innocuous coupling. More often tests verify individual attributes or consequences driven by the need for partial comparison. Perhaps the effects of the aspect being tested do not require complete equality or there are less deterministic aspects of the class state (e.g., GUIDs or UIDs) that vary from instance to instance but relate to inherent equality nonetheless. Existing comparator objects and methods can minimize the coupling by substituting a lesser dependency in its place. Test-only comparators consolidate and mitigate the coupling as an improvement. But not all verification relies entirely on state, requiring use of the operations on these secondary types in the return interfaces.

One of the more common and insidious manifestations of these concepts occurs through the simplest of object-oriented conventions: attribute encapsulation. Typically, developers idiomatically write accessors for each of their attributes. The rationale is that it insulates the caller from the internal representation, allows for virtual or computed attributes, hides side effects, enforces immutability constraints, and so forth. Most commonly, though, the getter simply returns the internal representation, possibly providing direct access by reference to more complex internal structures. As we have seen, verification of those internal representations sometimes occurs through interface access. Where null safety is either guaranteed by the representation or ignored by the test, we see code like

A.getB().getC().getD()

Despite the blatant violation of the Principle of Least Knowledge,3 we frequently find code like this—of course we do not write it ourselves!—in tests and production. Although the object states of A, B, and C are clearly encapsulated according to object-oriented standards, this construct of getter chaining transitively couples A to the implementations of B, C, and possibly D.4

3. Also known as the Law of Demeter, the Principle of Least Knowledge provides useful heuristics for minimizing coupling in software: http://en.wikipedia.org/wiki/Law_of_Demeter.

4. Note that this is distinctly different from call chaining in which each call returns a reference to the representation being acted on as is often implemented for compact setting of state with a functional flavor.

All of this serves to exacerbate the tension between the principles of good design and the practical forces driving development. Normally, we might write off a translation between internal representations and interface representations as a pedantic performance drain. This chapter motivates us to consider how the future maintainability of our test regimen impacts those design decisions.

Fortunately, there are lightweight ways to address these concerns. Through refactoring, we can introduce them on an as-needed basis as we grow our code and tests. The point here is not that we should cripple our performance and magnify our code to thoroughly insulate our tests from coupling with more than their direct test target. Rather, we need to approach our design and implementation in a way that factors these considerations into the balance intentionally.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.96.86