Skip to content

Isolating Dependencies With Seams

The last page gave you a vocabulary for stand-ins: dummy, stub, spy, mock, and fake. But a stand-in is useless if the code under test refuses to accept it. If analyze() reaches out and opens a file by name, there is nowhere to slip a fake in — the real disk is welded to the logic.

So test doubles are only half the story. The other half is design: the code has to let go of its heavy collaborators long enough for a test to hold them for it. The place where you can do that swapping is called a seam, and this page is about designing seams on purpose instead of discovering, painfully, that your code has none.

Michael Feathers coined the term in Working Effectively with Legacy Code (2004), and the definition is worth memorizing:

A seam is a place where you can alter behavior without editing the code at that place.

Read that twice. The power is in “without editing.” If the only way to make analyze() read from a string instead of a file is to open up analyze() and change its body, it has no seam. If you can change what it reads from the outside — by handing it a different argument, a different type, a different implementation — then that argument is a seam, and the code is testable.

NO SEAM SEAM
┌────────────────────┐ ┌────────────────────┐
│ analyze() │ │ analyze(lines, │
│ open("app.log") ◄┼─ welded │ filter) ◄───┼─ swappable
│ read + parse │ to disk │ parse + count │ from caller
└────────────────────┘ └────────────────────┘
test must test passes a
touch the disk &str or a Vec

In an object-oriented language a seam is usually an interface parameter or a subclass override. In Rust — the language this book leans on for real examples — the three seams you reach for are function parameters, traits (often as impl Trait or dyn Trait), and generic type bounds. All three do the same job: they turn a hard-coded dependency into a slot the caller fills.

You do not have to imagine this. The logwise log analyzer in this repository has a seam built into its core function on purpose. Here is the real signature:

pub fn analyze<'a>(
lines: impl Iterator<Item = &'a str>,
filter: &Filter,
strict: bool,
) -> Result<Report, LogError> {
// ... parse each line, build a per-level histogram, apply the filter ...
}

Notice what analyze() does not do. It does not take a file path. It does not call File::open. It does not know the difference between a file, a string literal, a network socket, or a vector held together with tape. It takes an iterator of lines and a filter, and it returns a Report. Every impure decision — where do the lines come from — has been pushed out to the caller.

That single design choice is what makes the unit test in src/analyze.rs read the way it does:

const SAMPLE: &str = "\
2026-06-26T09:00:01Z INFO server starting up
2026-06-26T09:00:05Z WARN cache miss rate high
2026-06-26T09:00:12Z ERROR failed to open blob";
fn analyze_sample(filter: &Filter) -> Report {
// `SAMPLE.lines()` is an iterator — exactly what analyze() asked for.
analyze(SAMPLE.lines(), filter, false).unwrap()
}
#[test]
fn level_filter_keeps_that_level_and_above() {
let filter = Filter { min_level: Some(Level::Warn), ..Default::default() };
let report = analyze_sample(&filter);
assert_eq!(report.summary.matched, 2); // WARN + ERROR
}

SAMPLE.lines() produces an impl Iterator<Item = &str>. The production caller in main.rs produces the same shape by reading a file and iterating its lines. Both feed the identical analyze(). The disk is not mocked — it is simply not present in the unit at all. This is the payoff of the pure/impure split: the seam is the exact line where impure input meets pure computation, and the test stands right on it.

This is also dependency injection, stripped of ceremony. “Dependency injection” sounds like a framework; here it is just the function takes its inputs as parameters instead of fetching them itself. No container, no annotations — a parameter is a seam.

filter: &Filter is a second, quieter seam. The rule for “which lines count” is not baked into analyze(); it is a value the caller constructs and passes in. A test injects Filter { min_level: Some(Level::Warn), .. }; production injects whatever the command-line flags decoded to. Same function, different policy, zero branching inside analyze() to tell test from prod apart. When a dependency is data like this, injecting it is almost free.

Abstracting an impure dependency behind a trait

Section titled “Abstracting an impure dependency behind a trait”

Iterators handle input nicely, but some dependencies are not data you can hand over — they are behaviors that touch the outside world. The classic one is the clock. Suppose a function decides whether a session has expired:

fn is_expired(created_at: SystemTime) -> bool {
SystemTime::now().duration_since(created_at).unwrap() > SESSION_TTL
}

SystemTime::now() is welded in. There is no seam, so a test cannot pin “now” to a known instant — it can only test against the real wall clock, which drifts every millisecond. The fix is to hide the impure call behind a trait and inject an implementation:

pub trait Clock {
fn now(&self) -> SystemTime;
}
pub struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> SystemTime { SystemTime::now() }
}
fn is_expired(clock: &impl Clock, created_at: SystemTime) -> bool {
clock.now().duration_since(created_at).unwrap() > SESSION_TTL
}

Now is_expired has a seam: the clock parameter. Production passes SystemClock. A test passes a fake clock that returns a frozen instant:

struct FixedClock(SystemTime);
impl Clock for FixedClock {
fn now(&self) -> SystemTime { self.0 }
}
#[test]
fn session_expires_after_ttl() {
let created = SystemTime::UNIX_EPOCH;
let clock = FixedClock(created + SESSION_TTL + Duration::from_secs(1));
assert!(is_expired(&clock, created));
}

The test is now deterministic — it satisfies the R in FIRST, Repeatable — because time no longer moves during the test. Notice the shape of the trick: identify the one impure operation (now()), name it with a trait, inject the trait. &impl Clock is a generic bound (a compile-time seam, monomorphized, zero runtime cost); &dyn Clock would be a runtime seam via a trait object. Both are seams; pick generics when you know the type at compile time and dyn when you need to store mixed implementations behind one type.

The cost: over-isolation and brittle tests

Section titled “The cost: over-isolation and brittle tests”

Here is the part most tutorials skip. Seams are not free, and more seams are not better. Every seam you add is a degree of freedom your test must now specify, and a mock-heavy test pays for that with fragility.

Consider a test that isolates everything — clock, database, logger, id-generator, email sender — behind five mocks, then asserts on the exact sequence of calls each mock received. That test is no longer checking what the code does; it is checking how the code is wired. Rename a method, reorder two independent calls, extract a helper — the behavior is identical, the users are unaffected, and yet the test goes red. This is a brittle, mock-heavy test, and it inverts the whole point of testing: it makes safe refactors feel dangerous.

GOOD SEAM OVER-ISOLATION
test asserts on the RESULT test asserts on the INTERACTIONS
(report.matched == 2) (mock.expect_parse().times(3))
│ │
refactor internals ──► still green refactor internals ──► red for no reason

The analyze() test asserts report.summary.matched == 2. It does not care whether analyze used fold or a for loop, whether it filtered before or after counting. Rewrite the internals freely; the test still passes because it pins the contract, not the mechanism. That is the target. Reach for a mock (which verifies interactions) only when the interaction genuinely is the behavior — for example, “on success, commit() is called exactly once and rollback() never.” Otherwise prefer a stub or a fake and assert on the output.

Rule of thumb: isolate at the boundary you own

Section titled “Rule of thumb: isolate at the boundary you own”

Where should the seams go? The durable heuristic:

Isolate at the boundary you own. Don’t mock types you don’t control — wrap them in a thin adapter first.

Mocking a third-party type — reqwest::Client, an AWS SDK struct, std::fs::File — welds your test to their API surface. When they ship a new version and change a method signature, your mocks break even though your logic never changed, and you cannot fix their type. Worse, a hand-mocked external client can drift out of sync with the real one, so your tests pass against a fiction.

Instead, define a small trait you own that expresses only what your code needs, and write one thin adapter that implements it by calling the real library:

// The seam you own — expresses YOUR need, not the vendor's whole API.
pub trait LogSource {
fn lines(&self) -> Result<Vec<String>, LogError>;
}
// One thin adapter over the type you DON'T own.
pub struct FileSource(pub PathBuf);
impl LogSource for FileSource {
fn lines(&self) -> Result<Vec<String>, LogError> {
let text = std::fs::read_to_string(&self.0)?; // the impure edge
Ok(text.lines().map(str::to_owned).collect())
}
}

Now the vendor’s churn is quarantined inside FileSource. Your tests target LogSource — a two-method trait you defined — and a fake is trivial to write. The adapter is the one place that touches std::fs, and it is thin enough to cover with a couple of integration tests rather than mocking. You own the seam; you control the blast radius.

  • Why does it exist? Because test doubles need somewhere to plug in. A seam is the socket; without one, a hard-coded collaborator (disk, clock, network) makes the unit impossible to isolate no matter how good your doubles are.
  • What problem does it solve? It lets a unit run without its heavy or non-deterministic dependencies, so tests are fast, repeatable, and focused on logic instead of infrastructure.
  • What are the trade-offs? Seams add indirection — a trait or an extra parameter — that a reader must follow. Overdo it and you get mock-heavy tests coupled to internal structure that break on harmless refactors.
  • When should I avoid it? When the dependency is already cheap, pure, and deterministic (a simple value, a pure function). Adding a trait to abstract 2 + 2 buys nothing but ceremony. Don’t introduce a seam you have no test reason to use.
  • What breaks if I remove it? The unit fuses to its collaborators: tests must touch the real disk, wait on the real clock, or hit the real network — becoming slow, flaky, and unable to reproduce failures on demand.
  1. State the definition of a seam in one sentence, and explain what the phrase “without editing the code at that place” is doing in it.
  2. analyze(lines, filter, strict) takes an iterator instead of a file path. Name the two seams in that signature and say what each lets a test swap.
  3. You have fn is_expired(created_at) { SystemTime::now() ... }. Why can’t a test make this Repeatable, and what specific change introduces a seam?
  4. Describe a mock-heavy test that would break on a refactor even though behavior is unchanged. What is it asserting on that it shouldn’t be?
  5. A teammate wants to mock reqwest::Client directly in twenty tests. Apply the rule of thumb: what should they do instead, and what risk does mocking the vendor type carry?
Show answers
  1. A seam is a place where you can alter behavior without editing the code at that place. “Without editing” is the whole point: if the only way to change what the code depends on is to open it up and rewrite its body, it has no seam — real testability means you can swap the dependency from the outside, via a parameter, type, or implementation.
  2. lines: impl Iterator<Item = &str> is a seam that lets a test pass SAMPLE.lines() (a string) where production passes file lines. filter: &Filter is a seam that lets a test inject a specific filter policy (e.g. min_level: Some(Warn)) where production injects whatever the CLI flags decoded to.
  3. SystemTime::now() is welded into the body and moves every millisecond, so two runs read different “now” values near any boundary — not Repeatable. Introduce a Clock trait with a now() method and pass &impl Clock as a parameter; production passes a real SystemClock, the test passes a FixedClock returning a frozen instant.
  4. A test that injects mocks for the clock, DB, logger, etc. and asserts on the exact sequence and count of calls each received. It asserts on interactions (how the code is wired) rather than on the result; reordering independent calls or extracting a helper changes the interactions without changing behavior, so the test goes red for no user-visible reason.
  5. Define a small trait they own (e.g. HttpClient with only the method their code needs) and write one thin adapter that implements it over reqwest::Client; test against the trait with a fake. Mocking the vendor type directly couples all twenty tests to reqwest’s API, so a version bump can break them, and the hand-mock can drift out of sync with the real client — passing against a fiction.