Skip to content

Testing Needs a Seam, Not an Interface

Sponsor: Do you build complex software systems? See how NServiceBus makes it easier to design, build, and manage software systems that use message queues to achieve loose coupling. Get started for free.

Learn more about Software Architecture & Design.
Join thousands of developers getting weekly updates to increase your understanding of software architecture and design concepts.


In my last video, I said that clean architecture was killing your velocity. And man, you guys had thoughts. The number one pushback, the hill a lot of people were willing to die on, was testing.

And I get it. On the surface, it sounds reasonable. Create some interface or abstraction for testing purposes. But that assumption starts to fall apart once you look at what testing actually needs.

Testing needs a seam. It does not automatically need an interface.

YouTube

Check out my YouTube channel, where I post all kinds of content on Software Architecture & Design, including this video showing everything in this post.

The typical example

I want to walk through a real example. Not something contrived. Something I just ran into. And honestly, it’s so stereotypical that if you had a coding agent generate it, it would probably come up with code very close to this.

Let’s say I have a VIN decoder. You pass in a VIN, a vehicle identification number, and I want back the year, make, and model of the vehicle.

So what’s the first thing people do?

Obviously, we need an interface, right?

So now we have an IVinDecoderService. And yes, I’m joking a bit here, because every time I use an LLM it wants to create everything as a service. But I digress.

Then we have the actual VinDecoderService, which takes an HttpClient. It’s simple. There’s one method that makes an HTTP call to some API, then takes the result and deserializes it into a VIN decoder result that contains the year, make, and model.

Somewhere in the application, I need to use this. So let’s say I have a vehicle registration flow with a command to register a vehicle. From there, of course, I inject the interface we created, and dependency injection gives me the concrete implementation.

In the application code, I call the decoder, do the lookup, and then use the result. In my example, I’m just returning the year, make, and model to keep it simple and make the testing angle obvious.

And that’s where the justification usually comes in: tests.

That’s why we created the interface to begin with, right?

The interface tax

Once we get to the test, the next step is predictable. We create a mock. We tell it what the lookup should return. Maybe it returns a 2026 Ford F-150. Then we inject that mock into our application code, call the handler, and assert the result.

This is the classic example people use to justify interfaces for testing.

And sure, it looks clean. It feels testable.

But this is what I call the interface tax.

What’s really happening here?

You created an interface with one implementation and one usage. The only reason that interface exists is for testing. That’s it.

But the real need was never the interface. The real need was a seam.

That’s the part I think gets missed. A seam is what lets you vary behavior in a test. An interface is only one way to get that seam, and in a lot of cases, it’s not even the best way.

What the seam actually is

If you look at the VIN decoder and think about what it really does, the seam becomes obvious.

It uses HTTP.

That’s the seam.

So if I want to test the VIN decoder itself, I can focus on the actual dependency: HttpClient. More specifically, I can pass in a custom message handler that returns a specific JSON payload. That lets me test exactly what I care about. Did it make the request? Did it deserialize the response? Did it map the data correctly?

That is a real seam, and it lines up with the behavior I’m actually testing.

The important thing here is that I didn’t need an interface to create that seam. The seam was already there.

What about the application code?

Now when people hear this, the next objection is usually about application-level tests.

And I agree with the concern. If I’m testing application code, I should not have to know about HttpClient. I should not have to build a custom message handler in every test. That setup is too low-level for the thing I’m trying to test.

But that still doesn’t mean the seam has to be an interface.

It can just be a fixture.

In this case, I can create a VinDecoderFixture that builds a VinDecoder with a custom message handler based on whatever scenario I want. Maybe I have a CreateSuccessful helper where I pass in the year, make, and model I want returned. Maybe I also have a failure helper that lets me return specific status codes and error bodies.

Now in my application test, I use the fixture, configure the behavior I want, and pass the concrete VIN decoder into the registration handler.

Same end result.

The difference is I didn’t create an interface just to satisfy a testing pattern. I used the concrete thing and hid the setup behind a fixture.

And honestly, if you’re already using mocks heavily, there’s a good chance you’ve done this anyway. A lot of teams end up with utilities that configure mocks the same way over and over again. At that point, you’ve basically created fixtures already. You just did it with a mocking framework and an extra abstraction layer you may not have needed.

“But that feels like an integration test”

This is where some people get uncomfortable.

They’ll say, “Well, I’m still using the concrete implementation, and that feels like an integration test.”

My answer is: I don’t care.

In this example, the behavior is deterministic. There are no side effects. The dependency is controlled. I’m totally fine with the application code depending on the real VIN decoder in that kind of test.

We get way too hung up on labels sometimes. Unit test. Integration test. Whatever. The real question is whether the test is fast, reliable, and useful.

If it is, then I’m not worried about whether it crossed some imaginary purity line.

Sometimes you don’t even need a class

There’s one more part of this example that matters.

I often say that if you have a class with one method, you probably wanted a function.

That’s really what was going on here. The thing I needed was one operation: decode a VIN.

So instead of wrapping that in a class and then creating an interface for the class, there’s nothing stopping me from representing that dependency as a delegate. I can wire it up to the actual method in production, and in tests I can just pass in a delegate that returns whatever result I need.

That makes the test setup even simpler.

If all your application code needs is one operation, a delegate may be the better abstraction. Not because interfaces are bad, but because a delegate might more accurately reflect what the dependency actually is.

The real takeaway

The point here is not that interfaces are bad.

That’s not what I’m saying.

The point is that testing needs a seam, and that seam should line up with the thing you’re actually testing.

When I was testing the VIN decoder, HTTP was the seam.

When I was testing the application code, I used the real VIN decoder and hid the setup behind a fixture.

And when all I needed was a single operation, a delegate was another good option.

That’s the point.

Stop treating interfaces like they’re the only way to test and the default answer to every dependency. They’re one option. Sometimes they’re the right option. Sometimes they’re not.

Use a seam that matches the problem.

Testing Needs a Seam, Not an Interface

A lot of codebases are littered with interfaces that exist for no reason other than testing. One implementation. One usage. Extra indirection. More files. More ceremony. And people defend it because that’s just how they were taught to make code testable.

But the seam was the thing that mattered all along.

So the next time you reach for an interface, ask yourself why. Is it really modeling a meaningful abstraction in your system? Or are you just paying the interface tax because you assume that’s what testing requires?

Because it doesn’t.

Testing needs a seam, not an interface.

Join CodeOpinon!
Developer-level members of my Patreon or YouTube channel get access to a private Discord server to chat with other developers about Software Architecture and Design and access to source code for any working demo application I post on my blog or YouTube. Check out my Patreon or YouTube Membership for more info.