Back to Course

DIP Series · 3 of 3

Test Seams

Where test seams come from, why a class that is hard to test is usually a class with a hidden dependency, and how Rails' test adapters show the inversion pattern at scale.

Where this rule comes from

The term "seam" comes from Michael Feathers' 2004 book Working Effectively with Legacy Code. Feathers defined a seam as "a place where you can alter behavior in your program without editing in that place". The everyday version: a seam is a spot where a test can swap in a different implementation, without modifying the code being tested.

Seams matter because untested code rots faster than tested code. A class that cannot be tested in isolation accumulates bugs that only show up in production, and refactoring it gets riskier with every change. The classes that survive years of evolution in big Rails apps are the ones that were testable from day one. Testability is not a side effect of good design; it is one of the principal forces shaping good design.

The Dependency Inversion Principle is the engineering rule that creates seams. When a class accepts its collaborators as constructor arguments (DIP 1) and reads its configuration from a central namespace (DIP 2), every collaborator and every configuration value is a seam. Tests can pass in fakes; production passes in the real thing. The class itself is unchanged across both.

This lesson is about the seams Rails ships with — queue adapters, mail delivery methods, storage services, the test ActionCable adapter — and what makes them work. Once you see the pattern, the same shape is yours to use in application code.

The anti-pattern

A class with no seam looks like this:

class GenerateMonthlyReport
  def perform(month)
    orders = Order.where(created_at: month.all_month)
    pdf    = ReportRenderer.render(orders)

    S3Client.new.put_object(
      bucket: ENV.fetch("REPORT_BUCKET"),
      key:    "reports/#{month.strftime("%Y-%m")}.pdf",
      body:   pdf
    )

    AdminMailer.report_ready(month).deliver_now
  end
end

Imagine writing a test for this class. To exercise perform, you need to:

  • Create real Order records in the database (or set up factories).
  • Render a real PDF, which requires the renderer library to be installed and working.
  • Stub or mock S3Client so it does not make a real API call.
  • Set REPORT_BUCKET in the test environment, or the ENV.fetch raises.
  • Switch ActionMailer's delivery method to :test so the mailer goes to ActionMailer::Base.deliveries instead of SMTP.

The test is doing more setup than testing. Worse, every other test of this class repeats the same setup. The eventual code path is "this class talks to S3, sends an email, hits the database, and reads ENV." Anyone trying to understand it has to know all four facts, before they ever look at the actual logic.

The deeper problem is that the class has no seams. Every collaborator is named directly, looked up directly, or read directly. The only way to swap any of them is to monkey-patch — to "edit in that place," which is exactly what Feathers's definition rules out.

The fix: every collaborator is a seam

Apply DIP 1 and DIP 2 together. Each external collaborator gets injected; each configuration value comes from a named source.

class GenerateMonthlyReport
  def initialize(
    storage:  AppConfig::Storage.report_bucket_client,
    mailer:   AdminMailer,
    renderer: ReportRenderer
  )
    @storage  = storage
    @mailer   = mailer
    @renderer = renderer
  end

  def perform(month)
    orders = Order.where(created_at: month.all_month)
    pdf    = @renderer.render(orders)

    @storage.upload(key_for(month), pdf)
    @mailer.report_ready(month).deliver_later
  end

  private

  def key_for(month)
    "reports/#{month.strftime("%Y-%m")}.pdf"
  end
end

Now the test reads like this:

# illustrative test

class FakeStorage
  attr_reader :uploads
  def initialize; @uploads = []; end
  def upload(key, body); @uploads << [key, body]; end
end

class FakeRenderer
  def self.render(orders); "PDF bytes for #{orders.size} orders"; end
end

test "uploads the PDF and sends the email" do
  storage = FakeStorage.new
  mailer  = mock
  mailer.expects(:report_ready).returns(mailer)
  mailer.expects(:deliver_later)

  service = GenerateMonthlyReport.new(
    storage:  storage,
    mailer:   mailer,
    renderer: FakeRenderer
  )
  service.perform(Date.new(2026, 4, 1))

  assert_equal [["reports/2026-04.pdf", "PDF bytes for 0 orders"]], storage.uploads
end

Three short fakes, one assertion that captures the real behavior. The test does not need a real S3 client, a real PDF renderer, or a real mailer. The seams give the test full control over what gets called and with what arguments. The production class is unchanged.

How Rails ships seams at framework scale

The Rails framework is built around test seams. Every adapter pattern in Rails is also a seam pattern. The "test adapter" for each adapter family is the canonical example:

ActiveJob's :test adapter. In production, SomeJob.perform_later enqueues to Sidekiq or Solid Queue. In tests, config.active_job.queue_adapter = :test swaps in an adapter that records jobs in an in-memory array instead of running them. Tests assert on SomeJob.assert_enqueued_with(...); production code is identical. The seam is the adapter; the swap is one config line.

ActionMailer's :test delivery method. In production, UserMailer.welcome(user).deliver_now sends real SMTP. In tests, config.action_mailer.delivery_method = :test swaps in a delivery method that appends to ActionMailer::Base.deliveries instead. Tests assert on the array; production code is identical.

ActiveStorage's :test service. In production, user.avatar.attach(io) uploads to S3 (or Disk, or GCS). In tests, config.active_storage.service = :test swaps in an in-memory service that holds the bytes for inspection. Tests assert on user.avatar.attached?; production code is identical.

Three frameworks, three different domains, one pattern. The high-level code (the job, the mailer, the model) depends on an abstract role (the adapter). The low-level details (Sidekiq, SMTP, S3) implement the role. The test environment substitutes a different implementation by changing one config line. That is Dependency Inversion at framework scale, with the test environment as the substitution case.

Three patterns of test seam, ranked

From cleanest to messiest:

1. Constructor injection (DIP 1). The class takes its collaborators as keyword arguments. Production code provides defaults; tests pass in fakes. This is the cleanest because the seam is visible in the class's signature. Anyone reading the constructor sees that the class can be reconfigured.

2. Config-based swap (DIP 2). The class reads its collaborator from a central config namespace. Tests override the config value (often automatically, via environment-specific files). Cleaner than monkey-patching, less explicit than injection. Best when the swap is environment-wide rather than test-by-test.

3. Monkey-patch / stub (last resort). The test reaches into the class being tested and overrides a method or replaces a constant. This works, but the test now knows about the class's internals. A refactor that renames the method or moves the call site breaks every test that stubbed the old shape. Use only when the class cannot be changed (e.g., third-party gems with no documented test mode).

A class that needs heavy stubbing is a class that wants injection. Every stub in a test file is a place where the production class hardcoded a dependency it could have accepted as an argument. The next time you find yourself writing Klass.any_instance.expects(:method) or stub_const("Klass::URL", "..."), that is the production class telling you to add a seam.

Why this design holds up

Four payoffs come from designing classes with seams from the start.

Tests are fast. Real S3 calls take 100ms. A fake storage that records uploads in an array takes 0.01ms. A test suite that uses fakes everywhere runs in seconds where the un-seamed version takes minutes.

Tests are deterministic. Real services are flaky. A fake never is. The test suite that flakes in CI three times a week is usually testing against real collaborators that should have been seams.

Tests describe behavior, not implementation. A test that asserts on storage.uploads describes what the class does. A test that asserts on S3Client.any_instance.expects(:put_object).with(...) describes how it does it. The first kind of test survives refactors; the second kind breaks every time you reorganize.

Production gains optionality. A class with seams can swap its real S3 for GCS by changing the config. A class without seams requires a code change. The same flexibility that makes tests cheap makes production migrations low-risk.

When to NOT inject for testability

Two cases where adding a seam is over-engineering:

  • The collaborator is already a test seam. ActiveRecord is one — you do not need to inject Order because the test database is the seam. ActionMailer is one — you do not need to inject the mailer class because delivery_method = :test already handles it. Inject what crosses an external boundary, not what is already framework-controlled.
  • The collaborator is so simple that injecting adds more code than it saves. A pure function that formats a string does not need to be injected. A constant that is only a string does not need a layer of indirection. Reserve seams for things with real behavior that you might want to vary.

The principle at play

Dependency Inversion ends, in practice, at the test environment. The test suite is the second consumer of every class you write, alongside production. When the class is designed so both consumers can use it without modification, the design is good. When only production can use it and tests have to monkey-patch, the design has a missing seam.

The deeper move is to recognize that testability is a window into design quality. A class that is painful to test is almost always a class that hardcoded a collaborator it should have accepted. Listening to that pain is one of the most reliable signals a senior developer gets that the design needs an adjustment.

Rails encodes this insight at framework scale. Every major piece of Rails has a test adapter: ActiveJob, ActionMailer, ActiveStorage, ActionCable. Each one is the framework's acknowledgement that production code should be the same code that runs in tests, with the dependency swapped underneath. Your application code can borrow the same shape, at a smaller scale, with the same payoff.

Practice exercise

  1. Open a test file you wrote recently. Count the .expects, .stubs, or allow(...).to calls at the top.
  2. For each one, ask: is this stubbing a collaborator that the production class hardcoded? If yes, the production class is missing a seam.
  3. Pick the worst offender. Refactor the production class to accept the collaborator as a constructor argument with a sensible default. Replace the stub with a small fake class.
  4. Re-run the test. Notice that the test is shorter, faster, and reads more like real behavior than like an internals-aware mock script.
  5. Bonus: read activejob/lib/active_job/queue_adapters/test_adapter.rb in your bundle. It is 80 lines. That is the entire seam between "real Sidekiq" and "in-memory test queue." Notice how small the contract is.

Closing the SOLID series

Across nineteen lessons and five principles, the Senior Track has now walked through SOLID in real Rails code. SRP showed seven extractions for fat models and fat controllers. OCP showed five shapes for adding behavior without modifying existing code. LSP showed how Ruby's duck typing makes substitutability the default and how inheritance breaks the contract when used carelessly. ISP showed how to split fat concerns and design role-based interfaces. DIP showed how injecting collaborators and centralizing configuration turn classes into testable, swappable units.

Five principles is a small number, and SOLID is a small acronym. What makes seniors seniors is not knowing the acronym but having internalized the moves until they no longer feel like patterns at all. Reading a Rails codebase, a senior sees a fat controller and immediately sees the service extraction inside it. They see a soft-delete column and immediately wonder if it is hiding a Liskov violation. They see a class with hardcoded collaborators and immediately know the test file will be heavy with stubs. The patterns become a way of reading code, not a checklist.

The best way to internalize them is the same way every other senior did: read real code that uses them well. Mastodon, Forem, Discourse, Gumroad, and Rails core itself are the canonical references for the Mastodon/Forem/Gumroad school of Rails. Basecamp's Fizzy and Campfire are the canonical references for the 37signals school. Both schools are senior. The skill is recognizing which school a team writes in, and being fluent in either when it is your turn to ship.

The principles are not the goal. The goal is fluency. The principles are signposts on the way there.