Adding Tests to Untestable Legacy Code

Bomb disposal expert analyzing complex device with mirrors and identifying safe cutting points, representing careful legacy code refactoring with characterization tests

You’ve inherited a codebase with no tests. The classes are tightly coupled, dependencies are created inline, and methods run hundreds of lines. You need to make changes, but touching anything feels like defusing a bomb blindfolded.

Here’s the paradox every developer faces with legacy code: you can’t refactor safely without tests, but you can’t write tests without refactoring first. Every attempt to instantiate a class pulls in a database connection, an email client, three external APIs, and a configuration file. A previous developer tried to add tests and gave up after realizing every test required a running database, mail server, and network access to production systems.

The code is “untestable.” Except it isn’t.

Two techniques unlock almost any legacy codebase: characterization tests and seam identification. You don’t need to understand the code to test it, and you don’t need to refactor before you can write your first test. These techniques form the foundation that makes everything else possible.

Characterization Tests: Document Before You Judge

Traditional unit tests verify that code does what it should do—you write a test based on a specification, and the test fails if the code doesn’t match. Characterization tests flip this: they capture what the code actually does, regardless of intent. You’re not testing against a spec; you’re documenting observed behavior.

The distinction matters for legacy code. You don’t have a spec. The original authors are gone. The code has undocumented edge cases, implicit business rules buried in conditionals, and behaviors that might be bugs or might be features—you can’t tell. Characterization tests don’t try to answer “is this correct?” They answer “what does this do?” and lock it in.

The Discovery Process

The technique is deliberately low-tech: write a test with an obviously wrong expected value, run it, and let the failure message tell you the actual output. Then update the test with the real value.

RSpec.describe 'LegacyPriceCalculator (discovery)' do
  let(:calculator) { LegacyPriceCalculator.new }

  it 'discovers behavior for standard order' do
    order = OrderFactory.create(items: 3, subtotal: 100, customer: 'regular')
    expect(calculator.calculate(order)).to eq(94.50)
  end

  it 'discovers edge case - zero items' do
    order = OrderFactory.create(items: 0, subtotal: 0, customer: 'regular')
    expect(calculator.calculate(order)).to eq(5.00)
  end
end
Using failing tests to discover actual behavior.

That last test—the $5.00 minimum fee for empty orders—is exactly the kind of thing characterization tests reveal. Is it a bug? Intentional? You don’t know, and for now, you don’t care. You’re building a safety net, not making judgments. Document it with a comment, move on, and address it later when you have coverage.

This approach works because you’re observing rather than specifying. You run the code, capture what happens, and write it down. The tests become documentation of actual behavior. When you later refactor and a test fails, you know you changed something—then you investigate whether that change was intentional.

Info callout:

Characterization tests are not about correctness—they’re about documenting current behavior. If you discover a bug during characterization, document it with a comment but don’t “fix” the test. Fix the bug later, after you have your safety net in place.

Finding Seams: Injection Points Without Refactoring

Michael Feathers introduced the concept of seams in Working Effectively with Legacy Code, and it remains the most useful mental model for making untestable code testable. A seam is a place where you can alter program behavior without editing the code at that location. The seam itself doesn’t change—you change behavior at what Feathers calls the “enabling point.”

The distinction matters because legacy code often can’t be edited safely. You don’t have tests, so any edit risks breaking something. Seams let you substitute behavior for testing purposes without touching the production logic you’re trying to protect.

Consider a method that sends emails. The email-sending code is deep inside a 500-line method that also processes orders, updates inventory, and logs analytics. You can’t easily extract the email logic—too risky without tests. But if you can find a seam, you can replace the email sender with a test double that captures what would have been sent, without changing the method itself.

The Four Seam Types

Object seams are the most common and usually the cleanest. You pass a dependency through a constructor or method parameter, and the enabling point is the call site where you can pass a different implementation. This is the foundation of dependency injection. Most languages and frameworks have idiomatic ways to create object seams:

Language/FrameworkObject Seam Technique
RubyDefault arguments in initialize
C# / JavaConstructor overloads or optional parameters
PHPDefault parameter values
TypeScriptOptional parameters with nullish coalescing (??)
Spring Boot@Autowired with test @Configuration bindings
LaravelService container with $this->app->bind() in tests
.NETIServiceCollection with test service registration
Object seam techniques by language and framework.

Link seams operate at the module level. In Ruby, you can use stub_const to replace a class entirely. In TypeScript/JavaScript, Jest’s jest.mock() intercepts imports. The enabling point is the test setup. Link seams are powerful but fragile—they couple tests to implementation details like class names.

Subclass seams work by extracting behavior into a protected method, then overriding it in a test subclass. This technique is underrated for legacy code because it requires minimal changes—you extract one line into a method, and suddenly you have a seam.

Preprocessor seams apply anywhere you use environment-based branching. Rails’ Rails.env.test?, Laravel’s app()->environment('testing'), and Node’s process.env.NODE_ENV === 'test' are all effectively preprocessor seams. Use them sparingly—they litter production code with test concerns.

newsletter.subscribe

$ Stay Updated

> One deep dive per month on infrastructure topics, plus quick wins you can ship the same day.

$

You'll receive a confirmation email. Click the link to complete your subscription.

Identifying Seams in Legacy Code

When you’re staring at a tightly coupled class, finding seams requires a systematic scan. Look for object instantiation with new or .new—each one is a potential object seam waiting to be parameterized. Look for class method calls like SomeClass.do_thing—each one could become an instance delegator. Look for global access patterns: singletons, environment variables, file reads, network calls.

# BEFORE: Tightly coupled, no seams
class LegacyOrderProcessor
  def process_order(order_id)
    db = DatabaseConnection.new(CONFIG[:connection_string])  # <- potential seam
    order = db.query("SELECT * FROM orders WHERE id = '#{order_id}'")

    config = JSON.parse(File.read('/etc/app/config.json'))   # <- potential seam

    inventory = InventoryApi.check(order[:sku])              # <- potential seam

    mailer = SmtpMailer.new(config['smtp'])                  # <- potential seam
    mailer.send(order[:customer_email], 'Order Confirmation', template)
  end
end

# Each instantiation and external call is a seam candidate
# Object seams: inject db, config, inventory client, mailer via constructor
# Link seams: mock InventoryApi at module level
# Subclass seams: extract each call to protected method, override in tests
Identifying potential seams in legacy code.

When choosing which seam type to use, prefer object seams for long-term maintainability—they make dependencies explicit. But when you need tests now and can’t change constructor signatures, link seams or subclass seams get you there faster. You can always refactor toward cleaner patterns once you have tests.

Success callout:

Look for the new keyword and class method calls—these are often where seams are missing. Every .new inside a method is a dependency that’s hard to test. Every SomeClass.method call is a hidden dependency.

Putting It Together

These two techniques work as a one-two punch. Characterization tests give you the safety net—you can observe and document behavior without understanding every line. Seams give you the injection points—you can substitute test doubles for external dependencies without rewriting the code.

The workflow looks like this: First, identify the behavior you need to protect. Run the code through various inputs, capture what comes out, and write characterization tests that lock in those outputs. Don’t worry about whether the behavior is correct—that’s a problem for later. Next, find the seams. Look for new calls, class method invocations, and external dependencies. Pick the seam type that requires the least change: object seams if you can modify constructors, link seams if you need to mock at the module level, subclass seams if you need surgical precision. Finally, inject test doubles through those seams and verify the code routes to them correctly.

With both in place, you can isolate and test without understanding the full system. The code is no longer untestable—it’s testable through observation and substitution.

This is the foundation. Deeper techniques—Extract and Override for quick dependency breaking, Parameterize Constructor for clean DI patterns, Strangler Fig for system-level migration—all build on characterization tests and seams. But start here. Get your first characterization test passing. Find your first seam. The rest follows.

Free PDF Guide

Adding Tests to Untestable Legacy Code

Techniques for getting a test harness around code that was never designed for testability.

What you'll get:

  • Characterization test starter kit
  • Seam identification quick checklist
  • Dependency breaking pattern catalog
  • Legacy testing rollout plan
PDF download

Free resource

Instant access

No credit card required.

Conclusion

The myth of “untestable” code usually means “code that’s hard to test with conventional techniques.” Characterization tests and seams change the equation entirely—they let you observe, document, and isolate without first having to understand every line.

Start with characterization tests. Run the code, capture what happens, lock it down. Don’t judge whether the behavior is correct—just document it. Then find seams: the constructor parameters, the class methods, the environment flags that let you substitute behavior without editing the code you’re protecting.

These foundations enable everything else: dependency breaking, incremental extraction, system-level migration. But they’re also sufficient on their own to turn “untestable” into testable. The question isn’t can you test legacy code—it’s whether the investment is worth it for code that may never change.

Share this article

Found this helpful? Share it with others who might benefit.

Share this article

Enjoyed the read? Share it with your network.

Other things I've written