Chapter 7: Inheritance and Polymorphism


Java is an object-oriented language, as we know. In addition to object identity and data encapsulation, inheritance and polymorphism are other important concepts of the object-oriented paradigm. [1] These two concepts have not played a major role in the previous chapters of this book, except in the implementation of our test cases. As useful and convenient as inheritance and polymorphism may be for software development, the problems they can cause during testing can be equally big. But all complaints are useless: we have to deal with the positive and negative effects.

7.1 Inheritance

Well-Shaped Inheritance Hierarchies

Many inexperienced developers consider inheritance between classes mainly as a practical means to simplify their implementation: once they know a class that possesses the capabilities they need for a new class, they often extend this class. After all, the key word is extends. Then they add a method here, override another one there, and—voil —the new marvel is ready.

Using the inheritance mechanism in this way has a harmful effect, mainly since we take both the desired and the undesired properties of the superclass. And because Java links a subtype relationship to the extends relationship, like most other statically typed languages, the door is wide open for unintended use of the new, derived class. Inheritance is commonly used as a reuse mechanism and is one of the main reasons for poor maintainability of larger object-oriented systems. [2]

The problems with this kind of inheritance can be avoided by observing the Liskov Substitution Principle (LSP) [Liskov93, Martin96a] for the creation of subtypes when we build inheritance hierarchies. This principle states that an object of a subtype—and thus also an instance of a subclass —must be able to substitute the object of the supertype at any given time. Not following that rule will lead to nasty problems when using polymorphic operations (see Section 7.2).

At first sight, the LSP appears intuitive, but it has its pitfalls once we let ourselves become inspired by the specialization relationships found in the real world while building class hierarchies. This problem shows in the commonly used example of the relationship between a rectangle and a square. A programmer with basic knowledge of mathematics knows that a square is a rectangle with two equal sides. This programmer would probably write the following code:

 public class Rectangle {    private int x;    private int y;    public Rectangle(int x, int y) {...}    public int getX() {...}    public int getY() {...} } public class Square extends Rectangle {    public Square(int x) {       super(x, x);    } } 

So far, so good. The trouble begins when the programmer adds the method stretchX(int factor) to the Rectangle class. The decisive post-condition of this method is that x will be extended by factor, while y remains unchanged. The subclass Square can never meet this property, because its sides always have to keep the same length. For this reason, an instance of Square can no longer substitute an instance of Rectangle in all occurrences, thereby violating the substitution principle.

In this specific example, there are several solutions, which will not be discussed here. [3] What we should take home from this example is the fact that well-shaped inheritance hierarchies—those fulfilling the substitution principle—do not necessarily correspond to natural generalization and specialization hierarchies. Instead, they should be determined by our program's specific requirements.

In the general case, observation of the following two rules ensures a well-shaped hierarchy:

  1. A subclass can leave the post-conditions of a public method unchanged or strengthen them by introducing additional conditions. This applies equally to the class invariant, because we can consider it as an implicit post-condition for all public methods.

  2. A subclass can leave the pre-conditions of a public method unchanged or weaken them by removing or softening some conditions. At first sight, this appears unintuitive, but it is an immediate consequence of the substitution principle.

These two rules correspond to those of the design by contract approach (see Chapter 4, Section 4.7), which shows its strong side especially in testing inheritance hierarchies for well-shapedness. However, most difficulties arise from nonexplicit conditions.

In the further course of this chapter, we assume that we are dealing with well-shaped hierarchies, at least with regard to those features that are part of our test suite; otherwise, the reuse of test cases would hardly make sense. Depending on the individual case, violating the substitution principle may be justified, provided that we understand the consequences, which is to say, a more difficult testability.

Reusing Superclass Tests

A pleasant and intuitive assumption appears to turn testing of class hierarchies into an easy task. When a subclass obeys the rules of the substitution principle, namely, it is a real subtype of the superclass, then (a) unchanged methods should not have to be tested, and (b) overridden methods should allow adequate testing in the test suite of the superclass.

Unfortunately, both assumptions are wrong. This evolves from Weyuker's [88] three test axioms, which establish limits on the transferability of code coverage for test suites applied to modular—and thus including object-oriented—systems: [4]

  • The antiextensionality axiom states that a test suite that covers one implementation of a specification does not necessarily cover a different implementation of the same specification. A responsibility may be implemented in many ways.

  • The antidecomposition axiom states that the coverage achieved for a module under test is not necessarily achieved for a module that it calls. A test suite that covers a class or a method does not necessarily cover the server objects of this class or method.

  • The anticomposition axiom states that test suites that are individually adequate for segments within a module are not necessarily adequate for the module as a whole.

All of this has a few implications on our strategy for testing of class hierarchies. First, we also have to test unchanged methods of a subclass, if this class directly or indirectly invokes overridden methods. Second, the test suite of the superclass is often insufficient to test overridden methods of the subclass. The reason is that another implementation requires both new implementation-based and extended specification-based tests, if pre-or post-conditions have changed. The good news is that we can reuse at least part of the test suite of the superclass to test the subclass.

Let's use an example to put theory into action. Our example deals with a very simple hierarchy, involving two classes, as shown in Figure 7.1. The attributes in this example are not publicly accessible; they represent getter and setter methods. The diagram does not show that PriceOutOfBoundException can be thrown by the sell(double price) method. This method has the additional condition that it must not be invoked again after one successful attempt. Also, there is a pre-condition for Book.profit() stating that it may be invoked only after a successful sale. This pre-condition no longer exists for FixedPriceBook.profit(), because the amount of the (potential) profit is already known before a sale occurs. Let's first look at the test class for Book:

click to expand
Figure 7.1: A simple inheritance hierarchy.

 public class BookTest extends TestCase {    private Book book;    private final String NAME = "A Test Book";    private final double WHOLESALE = 10.0;    private final double RECOMMENDED = 12.0;    protected void setUp() {       book = new Book(NAME, WHOLESALE, RECOMMENDED);    }    public void testCreation() {       assertEquals(NAME, book.getName());       assertEquals(WHOLESALE, book.getWholesalePrice(), 0.00);       assertEquals(RECOMMENDED, book.getRecommendedPrice(), 0.00);       book = new Book("Another Book", 20.0, 23.0);       assertEquals("Another Book", book.getName());       assertEquals(20.0, book.getWholesalePrice(), 0.00);       assertEquals(23.0, book.getRecommendedPrice(), 0.00);    }    public void testSellAtRecommendedPrice() throws Exception {       book.sell(RECOMMENDED);       assertEquals(RECOMMENDED, book.getSoldFor(), 0.00);       assertEquals(2.0, book.profit(), 0.00);       book = new Book("Another Book", 20.0, 23.0);       book.sell(23.0);       assertEquals(23.0, book.getSoldFor(), 0.00);       assertEquals(3.0, book.profit(), 0.00);    }    public void testSellAtWholesalePrice() throws Exception {       book.sell(WHOLESALE);       assertEquals(WHOLESALE, book.getSoldFor(), 0.00);       assertEquals(0.0, book.profit(), 0.001);    }    public void testSellBelowWholesalePrice() {       try {          book.sell(WHOLESALE - 0.01);          fail("PriceOutOfBoundsException expected");       } catch (PriceOutOfBoundsException expected) {}    }    public void testSellAboveRecommendedPrice() {       try {          book.sell(RECOMMENDED + 0.01);          fail("PriceOutOfBoundsException expected");       } catch (PriceOutOfBoundsException expected) {}    } } 

The last two test cases show that the allowed price margin is limited to range between Wholesale Price and Recommended Price.

Considering that the subclass FixedPriceBook should have exactly the same public interface as Book, we also want to use the existing test suite of the Book class. The easiest way to achieve this, from the technical perspective, is a test class hierarchy that maps the structure of our application classes. Next, we substitute the constructor invocations in the tests by invoking an overridable factory method and encapsulating the access to our OUT in a getter and a setter. Now the reusability of existing test cases is child's play:

 public class BookTest extends TestCase {    ...    protected Book createBook(String name,          double wholesale, double recommended) {       return new Book(name, wholesale, recommended);    }    protected Book getOUT() {       return book;    }    protected void setOUT(Book newBook) {       book = newBook;    }    protected void setUp() {       this.setOUT(this.createBook(NAME,          WHOLESALE, RECOMMENDED));    }    public void testCreation() {       assertEquals(NAME, this.getOUT().getName());       assertEquals(WHOLESALE,             this.getOUT().getWholesalePrice(), 0.00);       assertEquals(RECOMMENDED,             this.getOUT().getRecommendedPrice(), 0.00);       this.setOUT(this.createBook("Another Book", 20.0, 23.0));       assertEquals("Another Book", this.getOUT().getName());       ...    }    ... } public class FixedPriceBookTest extends BookTest {    ...    protected Book createBook(String name,          double wholesale, double recommended) {       return new FixedPriceBook(name, wholesale, recommended);    } } 

And really, our FixedPriceBookTest suite runs perfectly, provided no method in FixedPriceBook got overridden. But that was exactly the purpose of this exercise: A Fixed Price book should (a) be sold only at the recommended price, and (b) allow the invocation of the profit() method even when there was no previous sale. Therefore, we have to check the inherited test cases for usefulness and add new test cases:

  1. testCreation(), testSellAtRecommendedPrice(), and testSellAboveRecommendedPrice() still appear to be meeting our specification and remain unchanged.

  2. testSellAtWholesalePrice() no longer corresponds to our intensified condition, and must be overridden:

     public class FixedPriceBookTest extends BookTest {    ...    public void testSellAtWholesalePrice() {       try {          this.getOUT().sell(WHOLESALE);          fail("PriceOutOfBoundsException expected");       } catch (PriceOutOfBoundsException expected) {}    } } 

  3. Although testSellBelowWholesalePrice() is not wrong, it actually refers to a boundary case of the superclass. But it doesn't hurt either.

  4. We need an additional test case to be able to do some testing directly below the recommended price:

     public void testSellBelowRecommendenPrice()    try {       this.getOUT().sell(RECOMMENDED - 0.01);       fail("PriceOutOfBoundsException expected");    } catch (PriceOutOfBoundsException expected) {} } 

  5. And finally, we need a test case to check for correct functioning of profit() without previous sale:

     public void testProfitBeforeSale() {    assertEquals(2.0, this.getOUT().profit(), 0.00); } 

The methods sell() and profit() in the class FixedPriceBook from Book have to be overridden to ensure that the modified test suite will end with a green bar. If we had decided not to let some of the tests from BookTest loose on instances of FixedPriceBook, then we would have had two options to choose from: either override the test method with an empty body, or extract all Book-specific test cases into a separate test class.

In the general case, it can happen that creating an instance of the subclass demands other parameters than those needed in the instantiation of the superclass. In that case, the OUT factory method (i.e., createBook(...) in this example) needs additional parameters, and not all of them can be used in all test subclasses.

If new functionality is added to the subclass, and thus new specific tests are added to the test class, then it is recommended to use a getOUT() variant, which already takes care of the necessary typecast. Figure 7.2 shows a class diagram of the parallel test hierarchy in general form.

click to expand
Figure 7.2: A parallel test hierarchy.

Test Class Hierarchies by Refactoring

In the last example, we added the tests for our class hierarchy in arrears. In this case, the top-down approach is easiest, because it allows us to evaluate from class to class which tests of the superclass are still meaningful and which are not.

The situation is slightly different in test-first programming. The decision whether or not to derive a class as a subclass from another class is taken in the course of refactoring. This means that we already have an independent test suite for the subclass. Here, too, we encounter the typical phenomenon described in Chapter 4, Section 4.9: First, there is a small refactoring step based on existing test cases. Next, we think about modifications and extensions that may be required for our unit tests.

In our Book example, there would probably have been an isFixedPrice attribute of the class Book to distinguish regular books from fixed price books, before introducing our class hierarchy. The test suite would have distinguished between tests where this attribute is set and those where it is not set. At some point in time, the introduction of our subclass would have caused the setter for that attribute to disappear. And upon this modification at the latest, we would have built our parallel test hierarchy. Subsequently, those test cases that concentrate on differences in behavior through isFixedPrice would have been decomposed and eventually moved into the test subclass.

Refactoring of tests often lags behind restructuring of the application code by one step. This is the opposite of the approach used to add functionality, where our test cases are always a step ahead of the application classes.

Testing Interfaces

Interfaces are Java's way to deal with the problem of multiple inheritance. As we know, each class can implement an arbitrary set of interfaces, and it additionally inherits all implemented interfaces of its direct and indirect superclasses.

From the tester's perspective, "MyClass implements MyInterface" is very similar to "MyClass extends MySuperclass ." The first difference is that an interface does not come with an implementation, thus we can derive only specification-based test cases from it. Second, the parallel test hierarchy described above fails as soon as MyClass implements more than one interface, or additionally extends MySuperclass, because Java does not support multiple inheritance in an implementation for classes.

We still have the idea in mind that a test suite MyInterfaceTest for MyInterface should be executed for all implemented objects. When trying to realize this idea in Java, a few thoughts prove useful:

  • Test classes can also be abstract.

  • Abstract classes can be made instantiable by using static internal classes.

  • JUnit allows us to implement a separate suite() method for each test class.

Consider the situation shown in Figure 7.3. We have two interfaces and two classes; one class implements both interfaces and the other one implements one of the two interfaces. Based on this structure, we would also like to have test classes (Figure 7.4). The implements relationship between the classes and interfaces should be replaced by a uses relationship of some kind between the corresponding test classes.


Figure 7.3: Interfaces.

click to expand
Figure 7.4: Interface test classes.

We now have to answer the question of what the implementation of the abstract interface test classes and the suite() methods might look like. The schematic suggestion below follows along the lines of our initial idea:

 public class InterfaceATest extends TestCase {    private InterfaceA out; // object under test    protected abstract InterfaceA createInterfaceA();    protected void setUp() {       out = this.createInterfaceA();    }    public void testXXXX() {...} } public class InterfaceBTest extends TestCase {    private InterfaceB out; // object under test    protected abstract InterfaceB createInterfaceB();    protected void setUp() {       out = this.createInterfaceB();    }    public void testYYYY() {...} } public class MyClassTest extends TestCase {    public static class MyClassInterfaceATest extends InterfaceATest {       protected InterfaceA createInterfaceA() {          return new MyClass();       }    }    public static class MyClassInterfaceBTest extends InterfaceBTest {       ... // accordingly    }    public static Test suite() {       TestSuite suite = new TestSuite(MyClassTest.class);       suite.addTestSuite(MyClassInterfaceATest.class);       suite.addTestSuite(MyClassInterfaceBTest.class);       return suite;    } } 

Note that suite() initially creates the standard test suite—new TestSuite (MyClassTest.class) —and then the interface test suites are appended. Considering that InterfaceATest and InterfaceBTest (can) include exclusively specification-based test cases, we need additional implementation-based test cases in MyClassTest for the implemented interfaces.

The technique introduced here for reusing abstract test classes by means of internal classes means reaching deeply into Java's bag of tricks, and it is hard to understand. For this reason, we will not use this technique mechanically for each interface to be implemented. Instead, we will use it only when there is actually a nontrivial specification-based test suite. There are often very few semantic requirements to the implementing class, apart from the requirement to make the interface public. In this case, the interface tests would be better off in the test suite of that class.

Testing Abstract Classes

The root and some of the classes in the center of a class hierarchy are normally abstract; that is, no instances can be created from them. Some design heuristics even require that only the leaves of an inheritance tree may be specific. [5]

From the test implementation perspective, abstract classes do not cause us major problems. We only have to ensure that the corresponding classes of the parallel test hierarchy are really abstract and that they are not admitted as independent test suites. [6] McGregor and Sykes [McGregor01] discuss whether or not exclusive testing of abstract classes in specific derivations is sufficient. Alternatively, they study a way to create a specific subclass exclusively for testing purposes. However, they arrived at the result that the complexity of an abstract class is rarely great enough to justify this effort. Instead, they recommend additional code inspections.

Our personal experiences support this recommendation, with one exception. If no specific subclass of our abstract class exists, then a specific derivation is mandatory to be able to test in the first place. We generally encounter this case only in a framework development situation.

[1]Some languages (e.g., the prototype-based Self) do without classes and class-based inheritance.

[2]The situation gets worse when multiple inheritance is used.

[3]For example, inverting the extends relationship, extracting a common superclass, or introducing value semantics.

[4]Taken from Binder [99, p. 505]. One can argue whether or not the term axiom is correct for these mainly empirical rules.

[5]A different discussion of this issue is found in Riel [96].

[6]This can indeed be a problem with aggregate suites created automatically.




Unit Testing in Java. How Tests Drive the Code
Unit Testing in Java: How Tests Drive the Code (The Morgan Kaufmann Series in Software Engineering and Programming)
ISBN: 1558608680
EAN: 2147483647
Year: 2003
Pages: 144
Authors: Johannes Link

flylib.com © 2008-2017.
If you may any questions please contact us: flylib@qtcs.net