Testable Object Design and Collaborator Seams

Learn to design object-oriented code around behavior, collaborator boundaries, and stable dependency seams, with practical tests that resist refactoring churn.

published: reading time: 12 min read author: GeekWorkBench
Quick Summary

Testable object design keeps a test focused on a rule callers rely on, such as refusing an expired order without charging it. The post walks through where to put collaborators like clocks and payment gateways, when a fake is enough, and when a mock protects a real contract. It also covers failure handling and how to avoid tests that break whenever private code changes.

Testable Object Design and Collaborator Seams

Introduction

A class can have excellent unit test coverage and still be difficult to change. That usually happens when the tests know too much about how the class works: which private helper ran, how many times a mock was called, or the order in which internal steps happened.

Testable object design starts with behavior. Give an object a clear responsibility, make important collaborators visible at its boundary, and test the outcomes that callers rely on. This does not mean every dependency needs an interface or every test needs a mock. The goal is a small design whose meaningful rules can be checked without controlling its internals.

Behavior-Focused Tests

A behavior-focused test describes a result or externally meaningful interaction. It begins with a rule: an expired reservation cannot be confirmed, a payment is not captured twice, or a notification is sent after an order is accepted.

Consider an order confirmation policy. It needs to know the current time and whether payment can be captured. Those are real dependencies: the clock changes over time, and the payment service can fail. Put them at the boundary so a test can supply deterministic collaborators.

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;

interface PaymentGateway {
    boolean capture(String orderId, int amountInCents);
}

record Order(String id, int amountInCents, Instant expiresAt) {}

final class OrderConfirmer {
    private final Clock clock;
    private final PaymentGateway payments;

    OrderConfirmer(Clock clock, PaymentGateway payments) {
        this.clock = clock;
        this.payments = payments;
    }

    Confirmation confirm(Order order) {
        if (!clock.instant().isBefore(order.expiresAt())) {
            return Confirmation.expired();
        }
        if (!payments.capture(order.id(), order.amountInCents())) {
            return Confirmation.paymentDeclined();
        }
        return Confirmation.confirmed();
    }
}

record Confirmation(Status status) {
    enum Status { CONFIRMED, EXPIRED, PAYMENT_DECLINED }

    static Confirmation confirmed() { return new Confirmation(Status.CONFIRMED); }
    static Confirmation expired() { return new Confirmation(Status.EXPIRED); }
    static Confirmation paymentDeclined() { return new Confirmation(Status.PAYMENT_DECLINED); }
}

The constructor makes the two collaborators explicit. The Clock is a standard library abstraction, and PaymentGateway describes the capability this object needs. OrderConfirmer does not know whether payments are sent over HTTP or handled by a local test fake.

A test can check the public result and the business-relevant effect:

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;

class OrderConfirmerTest {
    @Test
    void expiredOrderIsNotCharged() {
        var clock = Clock.fixed(Instant.parse("2026-10-03T10:00:00Z"), ZoneOffset.UTC);
        var payments = new RecordingPaymentGateway();
        var confirmer = new OrderConfirmer(clock, payments);
        var order = new Order("ord-17", 2500, Instant.parse("2026-10-03T09:59:00Z"));

        var result = confirmer.confirm(order);

        assertEquals(Confirmation.Status.EXPIRED, result.status());
        assertFalse(payments.wasCalled());
    }

    private static final class RecordingPaymentGateway implements PaymentGateway {
        private boolean called;

        @Override
        public boolean capture(String orderId, int amountInCents) {
            called = true;
            return true;
        }

        boolean wasCalled() { return called; }
    }
}

This test protects two things a caller cares about: the order remains expired and no charge is attempted. It does not care whether confirm uses an isExpired helper or moves the time comparison into a value object. That freedom is the point.

Collaborator Boundaries

A collaborator boundary belongs where another object owns a meaningful capability or source of change. Common examples include persistence, time, network access, filesystem operations, message publishing, and policy decisions maintained separately from the current object.

Before adding a new abstraction, ask what varies and who owns that variation. PaymentGateway is useful here because the payment provider is external and can fail independently. An interface for a private string-formatting helper would add a seam without separating a real responsibility.

Objects should collaborate through the smallest contract that supports the use case. A service that only needs to capture a payment should not receive a full payment administration client with refund, reporting, and account-management operations. Narrow contracts make dependencies easier to reason about and test.

This responsibility-first approach connects with SOLID principles in practice and dependency injection and composition roots. Dependency injection is the wiring technique; the design decision is which dependency should be visible and replaceable.

Fakes, Mocks, and Other Test Doubles

A test double replaces or observes a real collaborator in a test. The choice depends on the behavior being checked:

Test double What it does Useful when
Fake Implements a lightweight working version, often with in-memory state The test needs realistic behavior, such as saving and retrieving records
Stub Returns prearranged values The test needs a collaborator to answer a specific question
Spy Records calls while providing behavior The test needs to inspect a meaningful effect after execution
Mock Verifies a specified interaction The interaction itself is part of the contract, such as publishing one required event

Prefer a fake when it can express the scenario clearly. An in-memory repository can be more readable than a mock script that says save must be called before find. Use a mock when the exact interaction is externally significant, not simply because a mocking library makes it easy to assert calls.

There is a practical distinction between “the payment gateway received a capture request” and “the order was confirmed.” The first is an interaction; the second is a result. If the domain rule is about the result, assert the result. Add an interaction assertion when it protects a meaningful promise, such as never sending a capture request for an expired order.

Dependency Seams and Their Costs

A seam is a place where behavior can be varied without rewriting the object. Constructor injection is a direct seam: production wiring passes a real adapter, while a test passes a fake. Other seams include a function parameter, a strategy object, or a small port interface.

graph LR
    UseCase[OrderConfirmer] --> Clock[Clock]
    UseCase --> Port[PaymentGateway port]
    Adapter[HTTP payment adapter] --> Port
    TestFake[Recording test fake] --> Port
    App[Application composition root] --> UseCase
    App --> Adapter

The arrows show the dependencies around the use case. Production composition chooses the HTTP adapter; a test chooses a recording fake. Both satisfy the same small contract. The use case owns the decision to capture, while transport details stay in the adapter. This shape is also familiar from hexagonal architecture ports and adapters.

A seam has costs: another type to name, another constructor argument to wire, and another boundary to maintain. Add one when it isolates a real source of nondeterminism, external failure, or policy variation. Avoid turning every object into a graph of interfaces before a concrete change or test need appears.

When to Use

Use an explicit collaborator seam when:

  • A dependency is nondeterministic, such as time, randomness, or generated identifiers.
  • A call crosses a process, database, filesystem, or other failure boundary.
  • Different implementations are needed in production, tests, or supported runtime modes.
  • The object needs a focused collaborator contract to express a domain decision.
  • A hard-to-reproduce failure needs deterministic coverage.

The behavioral patterns roadmap article covers another side of the same design problem: objects coordinate behavior through explicit roles and messages.

When NOT to Use

Do not add an interface just to make a class mockable when direct construction remains simple and stable. A small immutable value object, a local pure function, or a private calculation often needs no test seam at all.

Avoid a mock-heavy test when a test can exercise a stable public result using real values or a compact fake. Avoid a dependency injection container for a handful of objects if a composition root can wire them directly. And do not create a generic abstraction for one implementation unless there is a current variation, external boundary, or clear contract worth protecting.

Production Failure Scenarios

Tests around collaborator boundaries help expose failure behavior that a happy-path-only suite misses:

  1. Provider declines a payment. The order remains unconfirmed, and the caller receives a clear decline result. The code should not report success merely because the request was sent.
  2. Provider times out after capturing. The caller may not know whether payment succeeded. The application needs an idempotency strategy or reconciliation path; a unit test can verify that uncertainty is represented instead of converted to a false decline.
  3. Clock boundary is mishandled. A reservation expiring exactly at the current instant must follow one documented rule. A fixed clock makes this edge case repeatable.
  4. Adapter leaks transport details. An HTTP status or provider-specific exception should be translated at the adapter boundary so domain behavior does not depend on a vendor SDK.

A unit test cannot prove that a real provider honors an idempotency key or that a production database persists correctly. Use contract or integration tests at those boundaries as well; keep unit tests focused on the decision rules owned by the object.

Trade-Off Table

Design choice Benefit Cost Good fit
Direct construction Few types and easy navigation Hard to replace nondeterministic or external behavior Stable, local collaborators
Constructor injection Makes required dependencies visible More wiring and constructor parameters External services, clocks, repositories
Fake collaborator Exercises realistic behavior with little setup Fake can drift from production semantics In-memory repositories and deterministic adapters
Mock interaction test Precise check of a required message or side effect Can break when harmless call structure changes Required event publication or forbidden external call
Broad mocking of internals Can isolate tiny branches Couples the test to private implementation Rarely useful; reconsider the boundary

Observability Checklist

A testable design also makes production behavior easier to inspect. At collaborator boundaries, verify that the system can answer:

  • Which operation was attempted, and which correlation or order identifier ties it to the request?
  • Did the collaborator return success, a known decline, or an unknown outcome such as a timeout?
  • Can retries be distinguished from first attempts without logging payment details or secrets?
  • Are latency and failure counts available for dependencies that can affect user-visible behavior?
  • Do logs identify the adapter and outcome while omitting sensitive payloads?

Observability should follow the same boundary as the behavior. A domain object can return a meaningful outcome; the application layer can log and measure it without filling the domain model with logging code.

Security and Compliance Notes

Test doubles must not contain copied production credentials, customer records, or real payment tokens. Use synthetic fixtures and keep secrets in the adapter’s configured runtime environment. Tests should assert that sensitive values are not included in returned errors or logs where that behavior matters.

Keep payment data handling behind the narrowest useful adapter. A fake gateway should record only what a test needs, such as whether capture occurred, rather than preserving full request objects by default. Teams working under PCI DSS, privacy, or retention requirements should verify the actual integration and logging controls against their own policy; a unit test alone cannot establish compliance.

Common Pitfalls and Anti-Patterns

  • Asserting private helper calls: The test fails after an internal refactor even though callers see the same behavior. Assert an outcome or contract-level effect.
  • Mocking every dependency: A test with a long setup script often mirrors the implementation line by line. Replace only the boundary that matters to the scenario.
  • Testing only the mock expectation: A test can prove a call happened while missing that the service returned the wrong result or changed state incorrectly.
  • Oversized interfaces: Passing a whole SDK client into a domain object spreads vendor concepts and grants unnecessary capabilities. Define the small operation the object needs.
  • Test-only architecture: A seam with no production meaning can make the design harder to follow. Prefer a simple pure function or direct value when no boundary exists.
  • Duplicating production logic in a fake: A fake that reimplements provider rules may pass while the real adapter behaves differently. Cover adapter contracts separately.

Quick Recap Checklist

  • Name the behavior the test protects before choosing a test double.
  • Keep meaningful domain rules inside objects with clear responsibilities.
  • Make nondeterministic and external collaborators replaceable at a natural boundary.
  • Prefer a small fake for realistic stateful behavior; mock only contract-level interactions.
  • Assert public outcomes and important effects, not private method choreography.
  • Cover real adapters with integration or contract checks where unit tests cannot reach.
  • Remove a seam if its indirection costs more than the variation it supports.

Interview Questions

1. How do you decide whether an object needs an interface for testing?

I look for a real boundary or variation first: an external system, nondeterministic input, or a collaborator with an independently owned responsibility. If the object only needs a stable local calculation, an interface added solely for mocking usually makes the code harder to read.

2. When would you choose a fake over a mock?

I choose a fake when the test benefits from a small working implementation, such as an in-memory repository that stores and returns objects. I use a mock when a particular interaction is part of the contract, for example, ensuring an expired order never triggers a payment capture.

3. How can tests avoid coupling to private implementation?

Test through the object's public behavior: inputs, returned outcomes, state visible to callers, and meaningful effects at collaborator boundaries. If a harmless refactor breaks a test, that test may be asserting choreography instead of a contract.

Further Reading

Conclusion

Testable object design comes from clear responsibilities and deliberate collaborator boundaries. Inject the clock or external service when those dependencies affect behavior, use fakes when they keep a scenario realistic, and reserve mocks for interactions callers actually depend on. The best tests leave room to change the implementation while keeping the important rules visible.

Category

Related Posts

Refactoring Toward Patterns Safely

Refactor toward design patterns safely: follow evidence of change, preserve behavior with tests, and make small transformations before adding an abstraction.

#object-oriented-design #design-patterns #refactoring

Behavioral Patterns: Organize Collaboration

Compare all eleven GoF behavioral patterns by the change they isolate, the coupling they reduce, and the runtime costs they add to a system in real code.

#design-patterns #object-oriented-design #behavioral-patterns

Choosing, Combining, and Removing Patterns

Choose GoF patterns by tracing change pressure, comparing direct code with pattern roles, and removing abstractions when maintenance costs exceed their value.

#object-oriented-design #design-patterns #refactoring