A Practical Guide to Testing Object-Oriented Software
An object interaction is simply a request by one object (the sender) to another (the receiver) to perform one of the receiver's operations and all of the processing performed by the receiver to complete the request.[1] In most object-oriented languages, this covers the vast majority of activity in a program. It includes messages between an object and its components and between an object and other objects with which it is associated. We assume these other objects are instances of classes that have already been tested in isolation to the extent that the class's implementation is complete. [1] We assume a class interface is defined solely using operations and not data. If data is accessible by collaborators, then approach the testing of that access as if operations existed to set and get the value of the data. Since multiple object interactions can occur during the processing of any single method invocation on a receiving object, we want to consider the impact of these interactions both on the internal state of the receiving object and on those objects with which it has an association. These effects can range from "no change" to changes in certain attribute values in one or more of the objects involved to state changes in one or more of the objects, including the creation of new objects and the deletion of existing objects.
Basing interaction testing solely on specifications of public operations is considerably more straightforward than basing it on implementations. We will limit interaction testing to just associated, peer-to-peer objects and take a public interface approach. This is reasonable because we assume the associated classes have already been adequately tested. However, this approach does not remove the obligation to look behind the specification to verify that a method completed all of the computation required. That means verifying the values of the receiver's internal state attributes, including any aggregated attributes that is, attributes that are themselves objects. Our focus will be to select tests based on the specification of each operation in a class's public interface. Identifying Interactions
Interactions are implied by a class specification in which references are made to other objects. In Chapter 5, we discussed the testing of primitive classes. A primitive class can be instantiated and the instance used without any need to create any other instance of any other class, including the primitive class itself. Such objects represent the simplest components of a system and certainly play an important role in any program execution. However, there are relatively few primitive classes in an object-oriented program that truly model the objects in a problem and all the relationships between those objects. Nonprimitive classes are common in and indeed essential to well-designed object-oriented programs. Nonprimitive classes support or perhaps require the use of other objects in some or all of their operations. Identify the classes of these other objects based on association (including aggregation and composition) relationships in the class diagram. These associations translate into class interfaces and the way a class interacts with other classes[2] in one or more of the following ways: [2] The proper way to state this concept is that an instance of a nonprimitive class collaborates with one or more instances of other classes. Since the specification and implementation for a class determine the full behavior of any instance, we will use the more prevalent expression of this relationship in terms of classes. However, keep in mind that collaboration is an object relationship, not a class relationship.
These interactions can be implemented in a variety of ways in programming languages. Collaborators may be addressed directly for example, using a variable name or they may be addressed by a pointer or a reference. If a pointer or a reference is used, the dynamic type of the object may be different from the static type associated with the pointer or reference. In other words, pointers and references are polymorphic, thus they are bound to an instance of any number of classes. In the context of Figure 6.1, a C++ implementation for Timer most likely stores pointers to instances of any of the subclasses of TimerObserver. A Java implementation stores references to instances of any class that is a subclass of TimerObserver or implements a TimerObserver interface. Polymorphism increases the number of the kinds of objects that could interact with a class under test. The pre- and postconditions for operations in the public interface of a class typically refer to states and/or specific attribute values of any collaborating objects. We can categorize a nonprimitive class based on properties of interaction that is, based on a degree of interaction with other instances. Some classes maintain associations with instances of other classes, but never actually interact with those instances. We refer to such a class as a collection class. We refer to a class with more extensive interactions as a collaborating class. A much smaller number of classes will "collect" other objects. Next, we will describe how to test these collection objects, and then we will discuss testing collaborating classes. Collection Classes
Some classes use objects in their specifications, but never actually collaborate with any of them that is, they never request any services from them. Instead, they do one or more of the following:
Collection classes can be identified by a specification that refers to other objects, but that does not refer to values computed based on the state or attribute value of those objects. Within the design of Brickles, the PuckSupply class (see Figure 6.2) is a collection class. A PuckSupply object, as part of its construction, instantiates an appropriate number of Puck instances and returns a pointer to one of those instances upon request. A PuckSupply instance never uses operations associated with a Puck except for constructors. By contrast, for example, the Timer class stores references (pointers) to implementers of the TimerObserver interface, such as a Puck, when they are attached. A Timer sends a tick() request to each attached observer whenever a Timer event occurs during execution. Figure 6.2. The PuckSupply classClass libraries that accompany compilers and development environments usually include a set of container classes. C++ has the standard template library (STL) and Java has a set of collection classes. The classes in these libraries include lists, stacks, queues, and maps (dictionaries). These collection classes hold the objects they are handed and return them in specific orders or find them based on specific criteria. Collaborating Classes
Nonprimitive classes that are not collection classes are collaborating classes. Such classes use other objects in one or more of their operations and as part of their implementation. When a postcondition of an operation in a class's interface refers to the state of an instance of an object and/or specifies that some attribute of that object is used or modified, then that class is a collaborating class. The BrickPile class in Brickles (see Figure 6.3) is a collaborating class. This class models the rectangular arrangement of bricks in the game and is responsible for identifying, but not processing, any collisions between the puck in play and a brick. It serves as a container for bricks, but collaborates with a playfield, a hint (in which all changes to the brick pile are recorded so the image of the brick pile can be rendered efficiently on the display), and sprites particularly a puck that moves into the brick pile. When a brick pile is constructed, it is positioned at a point in some playfield. The classes with which BrickPile collaborates are as follows:
Figure 6.3. The BrickPile class header fileTesting basic interactions between two objects is only the beginning. The number of potential collaborations can become impossibly large quickly. Often the bugs that are most serious do not arise from the interaction of two objects, but from the interactions between a complete set of objects. A BrickPile object may work perfectly well when tested with a PlayField object, but failure can result when BrickPile interacts with Hint to record the breaking of a brick. The question that arises then is whether to test each interaction individually or as a group. Choosing the correct "chunk" size for testing depends on the following three factors:
Specifying Interactions
In the discussion in the next section on testing interactions, we will assume that operations defined by a class are specified by preconditions, postconditions, and class invariants. We will use the Object Constraint Language (OCL). From a testing perspective, it is important to know whether defensive design or design by contract has been used in creating the specification of the particular interface to be tested. These approaches change the way senders and receivers interact. We will make a simplifying assumption that for any given class, all of the operations in the interface have been specified using only one of these approaches. If a class you want to test mixes the approaches, then you can mix the techniques we describe in a straightforward way.
|