Object-Oriented Testing Basics
Recall OO concepts and their impact on testing strategies — Pressman Ch. 23 | Binder (1999)
Learning Objectives
- Explain why OO systems present testing challenges that do not arise in procedural programs.
- Describe how encapsulation affects test design and what techniques allow testing of encapsulated state.
- Explain the "flattening" problem in inheritance and how it affects regression testing scope.
- Describe how polymorphism and dynamic binding introduce hidden execution paths that must be covered.
- Apply design-for-testability principles to OO class design.
- Select appropriate coverage criteria for unit testing OO systems.
Why OO Testing is Different
Traditional software testing assumptions were developed for procedural code: a function takes inputs and produces outputs, and testing means exercising input combinations. Object-oriented systems break several of these assumptions in fundamental ways.
Object behavior depends on the current state accumulated across prior method calls. Testing cannot simply exercise inputs — the object must be in the right state first, and state transitions must be verified.
A subclass reuses behavior from its parent but may override parts of it. Testing a subclass requires understanding which inherited behaviors are unchanged, which are overridden, and which are new.
A method call on a reference may resolve to different implementations at runtime depending on the actual object type. The execution path is not fully determined at compile time.
Objects collaborate through message passing. Defects often arise at the boundaries between objects rather than within individual methods. Unit testing a class in isolation may miss inter-object defects.
Encapsulation and Testing
Encapsulation is a foundational OO principle: implementation details are hidden behind a public interface. This is excellent for modularity but creates a testing tension. Tests must verify internal correctness without depending on private implementation details.
The Encapsulation Tension
If a class's internal state is fully private, how can a test verify that the state transitions are correct? Two approaches exist:
- Test through the public interface: Only observe externally visible behavior (return values, exceptions, side effects). This preserves encapsulation but may not directly observe internal state errors.
- Break encapsulation for testing: Add test-only accessors (getters), use reflection, or change field visibility to package-private. This provides direct state observation but weakens the boundary. Should be used sparingly.
Testing Through Public Interface
Preferred: state is observed through the designed public API.
Breaking Encapsulation (use sparingly)
Acceptable only when internal behavior cannot be observed externally and is critical to verify.
Inheritance and Testing
Inheritance allows subclasses to reuse parent class behavior. This creates a testing challenge: when a subclass inherits a method, is that method still correct in the subclass context?
The Flattening Problem
Testing a subclass correctly requires testing the full flattened behavior: not just new or overridden methods, but all inherited methods in the context of the subclass's state and overrides. A method that worked correctly in the parent may fail in a subclass if the subclass has changed the invariants the parent method depended on.
Rule: Each concrete class must be tested as a complete unit, including all inherited methods exercised with the subclass's specific state configuration.
The parent's deposit() test passes for BankAccount. But the parent's withdraw() test (which allows any withdrawal down to zero balance) will fail when run against SavingsAccount because the subclass has added a minimum balance constraint. The parent test suite must be re-run against the subclass.
Polymorphism and Testing
Polymorphism allows a variable of a parent type to hold objects of any subtype. This is powerful for extensibility but multiplies the execution paths that must be tested.
Hidden Execution Paths
Consider a method that calls shape.area() where shape is declared as Shape but could be a Circle, Rectangle, or Triangle at runtime. Each subtype has a different area() implementation. Testing with only one concrete type leaves the other implementations untested in this calling context.
Rule: For every polymorphic method call, at minimum one test must exercise each concrete subtype that could be bound to that call site.
Insufficient Test Coverage
Adequate Polymorphic Coverage
Dynamic Binding
Dynamic binding means the method actually invoked is determined at runtime, not compile time. This is the mechanism that enables polymorphism in OO languages. From a testing perspective, dynamic binding means that:
- Static analysis cannot fully determine the set of execution paths through a method that contains polymorphic calls.
- Method coverage measured by line coverage tools may mislead: a line is "covered" even if only one of several possible bindings was exercised.
- Defects can exist in a specific binding combination that is never exercised by the test suite if not explicitly targeted.
Designing for Testability in OO
Testability is a quality attribute of class design. Classes that are difficult to test are usually also difficult to maintain and extend. Good OO design and good testability are largely aligned.
Inject dependencies through the constructor or setter rather than creating them internally. This allows tests to inject mock or stub objects without requiring a live database, network, or external service.
A class with one clear responsibility is smaller, has fewer state transitions, and is easier to set up in test. A class that does many things requires complex setup and produces complex failure messages.
Program to interfaces rather than concrete classes. This enables substituting test doubles (mocks, stubs, fakes) for any collaborating object, enabling full isolation of the unit under test.
Static mutable state shared between objects makes tests order-dependent and difficult to parallelize. Prefer instance state and pass shared state explicitly through constructors or method parameters.
Dependency Injection Example
Unit Testing in OO Systems
In OO systems, the unit of test is the class (or closely related cluster), not the individual method. Testing a class means testing the complete behavior of its public interface across all relevant states.
- Arrange: Construct the object under test; set it to the required initial state; inject mock collaborators.
- Act: Call the method(s) being tested.
- Assert: Verify the returned value, the new object state (via public accessors), and interactions with mock collaborators.
- Each public method is tested for its documented behavior (happy path)
- Each public method is tested for documented error/exception behavior
- State transitions: test each valid transition and verify illegal transitions are rejected
- Object lifecycle: construction (valid and invalid arguments), use, disposal/finalization
- Polymorphic binding: each concrete type used at each polymorphic call site
Coverage Criteria for OO Systems
| Criterion | What It Measures | OO Relevance |
|---|---|---|
| Statement Coverage | Each executable statement executed at least once | Necessary baseline; insufficient alone for OO |
| Branch Coverage | Each conditional branch (true/false) taken at least once | Better than statement; still misses state-dependent paths |
| Method Coverage | Each public method called at least once | Minimal OO baseline; confirms no dead code |
| State-based Coverage | Each valid state of the object reached; each transition exercised | Essential for stateful objects (accounts, connections, sessions) |
| Polymorphic Message Coverage | Each concrete type bound to each polymorphic call site exercised | Critical for systems using inheritance and interfaces |
| Inheritance-based Coverage | Each inherited method exercised in the subclass context | Addresses the flattening problem |
Common Mistakes
Class Activity
OO Testing Challenge (25 minutes)
Consider the following class hierarchy for a vehicle rental system. Work in pairs to answer the questions below.
- List all state transitions for the
Vehicleclass. Which ones require tests? - What tests must be added for
Carbeyond those already written forVehicle? - What tests must be written specifically for
ElectricCar.rent()that do not exist for the parent classes? - If a method accepts a
Vehicleparameter, how many concrete types must be used to achieve polymorphic message coverage? - Rewrite
ElectricCarto improve its testability (hint: consider dependency injection for battery state).
Exit Ticket
- What is the "flattening problem" in OO testing, and how does it affect the scope of regression tests when a class is subclassed?
- Explain why polymorphism creates hidden execution paths. Give an example with two concrete types bound to one polymorphic call.
- Why is dependency injection important for unit testing OO classes? Provide a brief before/after code illustration.
- Which two OO-specific coverage criteria extend beyond standard branch coverage? What does each measure?
- Should you test private methods directly? Explain your reasoning.
Summary & Preview
- OO systems introduce hidden state, inheritance hierarchies, polymorphic calls, and dynamic binding — all of which extend testing complexity beyond procedural techniques.
- Encapsulation: prefer testing through the public interface; break encapsulation only when internal state is critical and not observable externally.
- Inheritance: each concrete class must be tested fully, including inherited methods; parent test suites must be re-run against subclasses.
- Polymorphism: each concrete type binding at each polymorphic call site must be exercised.
- Design for testability: dependency injection, single responsibility, interface-based design, and minimal hidden state.
- Coverage criteria for OO: statement/branch (baseline) + state-based + polymorphic message + inheritance-based.
Session 6.6 moves from concepts to techniques: state-based testing with state transition diagrams, class testing strategies (round-trip scenario testing, behavioural testing), and integration testing approaches for OO systems.