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.

Partial Class Testing

In an iterative, incremental development approach, a class is often developed in stages. Only the functionality needed to satisfy the requirements of the current increment are specified and/or implemented. The relationships between classes often are such that it is not possible to sequence the development of a class so that all the classes it needs to interact with are totally developed and tested. Furthermore, a project's schedule is usually based on delivery of end-user functionality that corresponds to portions of the functionality of individual domain classes, but seldom requires the complete functionality of any of them. Lower level that is, more primitive implementation classes are more likely to be completely developed at one time and tested as a complete unit. Other classes are therefore developed and tested incrementally.

Classes are tested to the extent that they are developed. Evolve tester classes toward completeness just as the production software does. Identify the test cases you can and then implement a Tester class to implement those test cases. Keep a record, by test case naming conventions or other documentation, of the origin of each test case so that for the next round of testing you can identify the effect changes in specification and implementation of a class under test has on the test cases and its Tester class.

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.

  1. A public operation names one or more classes as the type of a formal parameter. The message establishes an association between the receiver and the parameter that allows the receiver to collaborate with that parameter object. The attach() and detach() operations in the Timer class shown in Figure 6.1 illustrate this kind of relationship. A Timer instance can receive a request to attach a TimerObserver instance. The notify() method in Timer will send a message to the attached TimerObserver instances to invoke a method in this case, tick(). In this example, a receiver saves the association as part of its state and messages these other objects in subsequent operations. Another scenario is for the receiver to message the parameter, directly or indirectly, as part of the processing of a message.

    Figure 6.1. Parameter interaction

  2. A public operation names one or more classes as the type of a return value. The position() operation of class Sprite shown in Figure 6.1 is an example of this type of interaction. The specifying class may be responsible for creating the returned object or may be passing back a modified parameter. In an environment such as C++ in which heap storage management is programmed explicitly, the specification should detail whether the receiver retains responsibility for any storage management of a returned object or delegates that to the sender. Tester class methods should observe such responsibilities.

  3. The method for a class creates an instance of another class as part of its implementation. In Figure 6.1, MovableSprite has a method to process a collision with another sprite. The code for this method needs to create some instances of CPoint and other classes to use as temporaries to determine what happens in a particular collision. Objects such as PlayField to which MovableSprite has a peer-to-peer relationship are not allowed to know about these other objects. Remember, we will not analyze any further down a composition hierarchy. However, when executing tests, there may be a failure in the instance of some class C within a subobject, such as a CPoint instance. Validating the results of the test will include checking the state of C.

  4. The method for a class refers to a global instance of some class. Of course, good design principles reduce the use of globals to a minimum. If a class's implementation references some global object, treat it as an implicit parameter to the methods that reference it.

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:

  • store references (or pointers) to these objects, typically representing one-to-many relationships between objects in a program

  • create instances of these objects

  • delete instances of these objects

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 class

Class 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:

  • PlayField. A brick pile occupies part of a play field.

  • Hint. A brick pile records broken bricks in a hint.

  • CPoint. A brick pile's location in a playfield is specified by a point that determines the upper left corner of the brick pile.

  • Brick. A brick pile creates bricks as part of its own construction and tracks which bricks are broken and which are unbroken.

  • MovableSprite. A brick pile recognizes collisions between its bricks and a puck, which is a kind of movable sprite.

Figure 6.3. The BrickPile class header file

Testing 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:

  1. We distinguish between those objects that have a composition relationship with an object under test and those that are merely associated with that object. During a class test, the interaction of the composing object with its composed attributes is tested. The interaction between an object and its associated objects are tested as successive layers of aggregation are integrated.

  2. The number of layers of aggregations created between interaction tests is closely related to the visibility of defects. If too large a chunk is chosen, there may be intermediate results that are incorrect, but they are never seen at the level of test-result verification. This may not be a problem for the chosen test parameters. However, a slight change in test parameters would result in a failure. More layers of aggregation introduces more possible test parameters.

  3. The more complex the objects, the fewer that should be integrated prior to a round of testing. This complexity is seen in the number of parameters for each method, the number of methods and the number of state attributes in each object. As with the layers of aggregations, trying to test a chunk that is too complex often results in defects that successfully hide from the tests.

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.

Implications of Defensive and Contract Designs for Testing

Defensive design assumes that little or no checking of parameter boundaries occurs prior to a message being sent. This reduces the number of clauses in preconditions, requires checks internally for violations of attribute constraints, and increases the number of clauses in postconditions. A larger number of postcondition clauses results from a larger number of exceptions that arise to identify the different constraint violations. This translates into more interaction test cases oriented toward checking boundaries around inputs that produce exceptions.

Design by contract assumes that appropriate preconditions are checked prior to a message being sent and that the message is not sent if any of the parameters are outside acceptable limits. This increases the number of clauses in preconditions, requires no checking internally for violations of attribute constraints, and reduces the number of clauses in the postcondition clause. This means more test cases are needed to try to get an object under test to send a message for which preconditions are violated. Alternatively, we use code reviews to prove to ourselves that preconditions indeed cannot be violated, thereby eliminating the need for more test cases at the cost of a manual review.

Категории