Creating Seams to Test Hard-to-Change Code

A method can contain a simple business rule and still be difficult to test. The difficulty often comes from everything attached to the rule: the current clock, a network client, a filesystem call, a global configuration object, or a constructor that creates its own dependencies.

When rewriting the surrounding code would be risky, a seam can give you a smaller move. A seam is a place where you can change which behavior the code uses without changing the code that makes the business decision. In tests, that lets you replace an awkward dependency with controlled behavior.

This article shows how to recognize a useful seam, introduce one with a small change, and avoid turning testability work into unnecessary abstraction.

The mental model: separate the decision from what supplies its inputs

Consider a renewal service that decides whether an account is overdue. The rule is straightforward, but the method reads the current time directly:

renew(account):
    if system_clock.now() > account.expires_at:
        return EXPIRED

    extend_subscription(account)
    return RENEWED

A test for the boundary around expires_at now depends on the real clock. A test that passes at 10:00 may exercise different behavior later. Waiting for a particular instant is not a reasonable testing strategy.

The business decision needs a time value. It does not inherently need to know how the process obtains that value.

A seam separates those concerns:

renew(account, clock):
    if clock.now() > account.expires_at:
        return EXPIRED

    extend_subscription(account)
    return RENEWED

Production code passes a clock backed by system time. A test passes a clock that returns a fixed instant.

The seam is the point where clock can vary. The rule remains the same.

Start at the dependency that blocks the test

When legacy code is difficult to test, it is tempting to redesign the whole class before writing a single test. That increases the amount of unverified code being changed at once.

Instead, identify the specific dependency preventing the test you need.

Suppose an invoice method creates a mail client internally:

send_invoice(invoice):
    message = render_invoice(invoice)
    client = MailClient(load_mail_settings())
    client.send(invoice.customer_email, message)

If the goal is to test which message is sent, the hard-coded MailClient is the obstacle. The rendering logic does not need to be redesigned at the same time.

A narrow seam can move client creation behind a replaceable boundary:

send_invoice(invoice, mailer):
    message = render_invoice(invoice)
    mailer.send(invoice.customer_email, message)

Production assembly becomes responsible for constructing the real mailer:

mailer = MailClient(load_mail_settings())
send_invoice(invoice, mailer)

A test can provide a recording implementation:

mailer = RecordingMailer()
send_invoice(invoice, mailer)

assert mailer.last_recipient == invoice.customer_email

This simplified pseudocode leaves out error handling and richer assertions. Its purpose is to show the structural change: dependency construction moves out of the behavior being tested.

A useful seam changes one reason the test was difficult

A seam is valuable when it gives the test control over something that was previously fixed.

Common candidates include time, randomness, network access, filesystem access, environment-dependent configuration, process-wide state, and constructors that create concrete collaborators internally. The shared problem is not that these dependencies are inherently bad. The problem is that the code under test cannot choose an alternative when controlled behavior is needed.

For example, a retry policy that chooses random delay jitter may be hard to test at its boundaries if it reaches a global random-number generator directly. Passing a small source of randomness lets a test supply known values. The production behavior can still use ordinary randomness.

The same reasoning applies to filesystem code. If a parser both opens a fixed path and interprets the file contents, tests must arrange files merely to exercise parsing rules. Passing a stream or text value to the parsing part creates a boundary where the pure interpretation can be tested independently. Whether that extraction is the right seam depends on what behavior you need to verify.

Choose the smallest mechanism that creates the seam

A seam is a design property, not a particular pattern. You do not need an interface and a dependency-injection framework every time you want one.

A function parameter may be enough:

is_expired(account, now):
    return now > account.expires_at

For a stateful collaborator used by several methods, constructor injection may make the dependency clearer:

class RenewalService:
    constructor(clock):
        this.clock = clock

In code where changing every caller is currently unsafe, a protected factory method or another language-appropriate interception point can sometimes create a temporary seam. That can be useful during incremental work, but it also hides the dependency more than an explicit parameter does.

The decision should follow the scope of the dependency. Prefer the mechanism that exposes enough control for the test while adding the least new structure.

Put the seam at a stable boundary

Not every replaceable function makes a good seam. A seam placed inside an implementation detail can make tests depend on the exact steps of an algorithm.

Imagine a pricing method that calls three private helpers. Replacing each helper in tests may make the method easy to isolate, but those tests can break whenever the implementation is rearranged even if pricing behavior stays correct.

A stronger boundary usually represents an external capability or a meaningful input to the decision:

pricing rule -> exchange-rate provider
pricing rule -> current time
pricing rule -> customer repository

These dependencies describe information or effects the rule genuinely needs. The test can control them without prescribing every internal step.

This distinction matters for maintainability. A seam should make behavior easier to exercise, not turn implementation details into a second public API that tests must preserve.

Keep production behavior visible

Creating a seam introduces at least two possible paths: the real collaborator and the test-controlled collaborator. The production path should remain obvious.

If a service requires a clock, for example, application assembly should clearly supply the system clock. Avoid a design where production silently falls back to one dependency while tests mutate global hooks to install another. Global replacement points can leak between tests, behave poorly under parallel execution, and make it difficult to tell which dependency a running instance actually uses.

Explicit construction also helps code review. A reviewer can see that the behavior did not change from “use system time” to some new time source; only the location where that choice is made has changed.

Use seams to get tests in place before larger changes

The most useful seam is often not the final design. It can be a temporary step that creates enough control to characterize existing behavior before a broader refactor.

Suppose a large order method reads configuration globally, queries a repository, calculates a discount, and sends a notification. You need to change the discount rule, but there are no focused tests.

A safer sequence is:

  1. Identify the dependency that prevents a useful test of the current discount behavior.
  2. Introduce the narrowest seam around that dependency without changing the rule.
  3. Add tests that capture the behavior you intend to preserve.
  4. Make the business change under those tests.
  5. Refactor further only if the resulting design still justifies it.

This sequence limits how much unprotected behavior moves at once. It also gives each refactoring step a concrete purpose rather than treating abstraction as a goal by itself.

Watch for seams that make the design worse

Testability can be over-engineered. If every small function receives a dozen providers, factories, and interfaces solely because they can be replaced, the code may become harder to understand than the original.

A few warning signs are especially useful.

The test replaces pure computation. If a helper is deterministic and cheap, calling the real helper is usually simpler than creating a seam around it.

The seam exposes implementation order. Tests that expect stepA, then stepB, then stepC may be checking an algorithm rather than externally meaningful behavior.

The abstraction has only a hypothetical purpose. Do not create extension points for dependencies nobody needs to vary. Add a seam when there is a real testing or change problem.

The replacement behaves unlike production. A fake repository that ignores uniqueness constraints, ordering, or failure behavior can make tests reassuring for the wrong reason. The seam makes substitution possible; it does not guarantee that the substitute models relevant behavior accurately.

The dependency boundary is too broad. Passing an entire application context because one method needs the current time hides the actual requirement. A narrow dependency usually makes both the production code and the test easier to reason about.

A seam does not remove the need for integration tests

Replacing a network client or repository lets you test your code’s decisions without depending on the real external system. It does not prove that the real integration works.

If production uses a payment client, for example, a controlled replacement can verify that your service requests a charge with the expected amount. Separate tests may still be needed to verify serialization, authentication, protocol assumptions, error mapping, or other behavior at the real boundary.

Think of the seam as giving you control over one layer of testing. It reduces the number of reasons a focused test can fail, but it should not erase tests that protect integration contracts.

When a seam is the right move

A seam is a good fit when existing code has behavior worth preserving, one or two fixed dependencies prevent useful tests, and a large redesign would create more risk than the current change warrants.

It is less useful when the code is already small and deterministic, when an ordinary end-to-end test is cheap and precise enough for the problem, or when the proposed seam exists only to satisfy a preference that every dependency must be mockable.

There is also a point where repeated seam creation reveals a broader design problem. If every change requires another interception point around the same large component, the component may have too many responsibilities. Once tests provide enough safety, extracting cohesive responsibilities can be clearer than continuing to add escape hatches.

Make the next change smaller

When hard-to-test code blocks a necessary change, you do not have to choose between editing it blindly and rewriting it first. Find the fixed dependency that makes the behavior difficult to control, create the smallest stable boundary around that dependency, and use the boundary to get meaningful tests in place.

The practical test for a seam is simple: after adding it, can a test control the troublesome dependency while still exercising the real decision you care about? If yes, you have reduced the risk of the next change without pretending the rest of the codebase has already been redesigned.