Module 6 Session 6.5 OO Testing Basics

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.

Hidden state
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.
Inheritance hierarchies
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.
Dynamic dispatch
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.
Object interactions
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.
Implication (Binder, 1999): Traditional coverage metrics (statement, branch) are necessary but not sufficient for OO systems. Additional criteria targeting state, inheritance, and polymorphism are required to achieve adequate fault detection.

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
class BankAccountTest { @Test void deposit_increases_available_balance() { BankAccount acc = new BankAccount(100.0); acc.deposit(50.0); // test via getBalance() - public method assertThat(acc.getBalance()) .isEqualTo(150.0); } }

Preferred: state is observed through the designed public API.

Breaking Encapsulation (use sparingly)
@Test void transaction_log_recorded_on_deposit() { BankAccount acc = new BankAccount(100.0); acc.deposit(50.0); // use reflection to verify private log Field f = BankAccount.class .getDeclaredField("transactionLog"); f.setAccessible(true); List log = (List) f.get(acc); assertThat(log).hasSize(1); }

Acceptable only when internal behavior cannot be observed externally and is critical to verify.

Design implication: Classes designed with testability in mind expose enough observable behavior through their public interface that reflection is rarely needed. If a class requires heavy reflection to test, it may be a sign of poor cohesion or missing query methods.

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.

Example: The Override Bug
class SavingsAccount extends BankAccount { private double minimumBalance = 50.0; @Override public void withdraw(double amount) { if (getBalance() - amount < minimumBalance) throw new InsufficientFundsException(); super.withdraw(amount); } }

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.

Testing implication: When a class is subclassed, the parent's test suite must be applied to the child class. Tests that pass in the parent but fail in the child reveal Liskov Substitution Principle (LSP) violations — which are also design defects.

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
// Only tests Circle - misses Rectangle @Test void calculateArea_returns_correct_value() { Shape s = new Circle(5.0); ShapeCalculator calc = new ShapeCalculator(); assertThat(calc.totalArea(List.of(s))) .isCloseTo(78.54, within(0.01)); }
Adequate Polymorphic Coverage
@ParameterizedTest @MethodSource("shapeProvider") void totalArea_correct_for_each_shape_type( Shape shape, double expected) { assertThat(calc.totalArea(List.of(shape))) .isCloseTo(expected, within(0.01)); } // provider covers Circle, Rectangle, Triangle

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.
Testing implication: For systems using extensive polymorphism (e.g., strategy patterns, plugin architectures), supplement line/branch coverage metrics with explicit tracking of which concrete types were exercised at each polymorphic call site.

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.

Dependency Injection
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.
Single Responsibility
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.
Interface-Based Design
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.
Minimize Hidden State
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
// Hard to test: creates its own dependency class OrderService { private EmailSender sender = new SmtpEmailSender(); public void placeOrder(Order o) { sender.send("Confirmation"); } } // Testable: dependency injected through constructor class OrderService { private final EmailSender sender; public OrderService(EmailSender sender) { this.sender = sender; } public void placeOrder(Order o) { sender.send("Confirmation"); } } // Test: inject mock EmailSender - no real email sent

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.

The Arrange-Act-Assert (AAA) pattern for OO unit tests:
  1. Arrange: Construct the object under test; set it to the required initial state; inject mock collaborators.
  2. Act: Call the method(s) being tested.
  3. Assert: Verify the returned value, the new object state (via public accessors), and interactions with mock collaborators.
What makes an OO unit test suite complete?
  • 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

CriterionWhat It MeasuresOO Relevance
Statement CoverageEach executable statement executed at least onceNecessary baseline; insufficient alone for OO
Branch CoverageEach conditional branch (true/false) taken at least onceBetter than statement; still misses state-dependent paths
Method CoverageEach public method called at least onceMinimal OO baseline; confirms no dead code
State-based CoverageEach valid state of the object reached; each transition exercisedEssential for stateful objects (accounts, connections, sessions)
Polymorphic Message CoverageEach concrete type bound to each polymorphic call site exercisedCritical for systems using inheritance and interfaces
Inheritance-based CoverageEach inherited method exercised in the subclass contextAddresses the flattening problem
Practical guidance: For most OO projects, target 80%+ branch coverage as the minimum measurable metric, supplemented by explicit test cases for state transitions and all concrete type bindings at polymorphic call sites. Tools like JaCoCo measure branch coverage; state and polymorphic coverage require manual test design.

Common Mistakes

Testing only the happy path: In OO systems, state-dependent behavior means that defects often manifest only in specific state sequences. Testing only the straightforward use case misses most real defects.
Assuming inherited tests are sufficient: Running only the parent class test suite against a subclass misses new state invariants and override interaction effects. Always re-run the parent suite against each subclass and add subclass-specific tests.
Over-testing private methods: Private methods are implementation details. Test them indirectly through the public interface. If a private method is so complex it needs its own tests, it is a candidate for extraction into a separate class.
100% line coverage as the sole goal: Line coverage is gameable (empty assertions achieve coverage) and misses state and polymorphic paths. It is a floor, not a ceiling.

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.

abstract class Vehicle { protected String status = "available"; public void rent() { if (!status.equals("available")) throw new IllegalStateException(); status = "rented"; } public void return_() { status = "available"; } public abstract double dailyRate(); } class Car extends Vehicle { private boolean premiumInsurance; public Car(boolean premiumInsurance) { this.premiumInsurance = premiumInsurance; } public double dailyRate() { return premiumInsurance ? 80.0 : 50.0; } } class ElectricCar extends Car { private int batteryLevel; public ElectricCar(boolean premium, int batteryLevel) { super(premium); this.batteryLevel = batteryLevel; } @Override public void rent() { if (batteryLevel < 20) throw new LowBatteryException(); super.rent(); } }
  1. List all state transitions for the Vehicle class. Which ones require tests?
  2. What tests must be added for Car beyond those already written for Vehicle?
  3. What tests must be written specifically for ElectricCar.rent() that do not exist for the parent classes?
  4. If a method accepts a Vehicle parameter, how many concrete types must be used to achieve polymorphic message coverage?
  5. Rewrite ElectricCar to improve its testability (hint: consider dependency injection for battery state).

Exit Ticket

  1. What is the "flattening problem" in OO testing, and how does it affect the scope of regression tests when a class is subclassed?
  2. Explain why polymorphism creates hidden execution paths. Give an example with two concrete types bound to one polymorphic call.
  3. Why is dependency injection important for unit testing OO classes? Provide a brief before/after code illustration.
  4. Which two OO-specific coverage criteria extend beyond standard branch coverage? What does each measure?
  5. Should you test private methods directly? Explain your reasoning.

Summary & Preview

Key takeaways from Session 6.5:
  • 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.
Coming up — Session 6.6: OO Testing Techniques
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.