~/posts/just-test-the-thing
Just test the thing
There’s a payment failure you need to debug. So you start at the route handler, then jump to a service interface, the concrete service, a repository interface, then finally land on the API call that actually matters. You peel back layer after layer of abstraction to actually get to the code that does the thing. The code is technically organized, but why so much indirection?
Usually it started with a test. Someone needed to isolate a class, so they extracted an interface to make it easier to mock. That tradeoff feels cheap the first time: Just stub a few methods, assert an interaction, and move on. The bill shows up later when everyone who reads the code has to navigate a maze of names and layers that don’t actually hide complexity, they end up creating it. I wouldn’t call that modularity. It’s testing convenience that’s now been promoted to architecture.
A closer look
Here’s an example I’ve seen in a few Node services.
interface OrderService { placeOrder(input: PlaceOrderInput): Promise<Order>}
class SpecialOrderService implements OrderService { constructor( private inventoryRepo: InventoryRepository, private paymentGateway: PaymentGateway, private orderRepo: OrderRepository ) {}
async placeOrder(input: PlaceOrderInput): Promise<Order> { const item = await this.inventoryRepo.getBySku(input.sku) if (!item || item.stock < input.quantity) { throw new Error('Out of stock') } await this.paymentGateway.charge(input.customerId, input.total) return this.orderRepo.insert(input) }}
app.post('/orders', async (req, res) => { const order = await gateway.resolve<OrderService>('OrderService').placeOrder(req.body) res.json(order)})But of course, the justification shows up in the test.
it('returns 201 when the order is placed', async () => { const service: OrderService = { placeOrder: vi.fn().mockResolvedValue({ id: 'o1' }) }
const app = makeApp({ orderService: service }) const res = await request(app).post('/orders').send({ sku: 'abc', quantity: 1 })
expect(res.status).toBe(200)})Take a look at what the interface is doing. It isn’t hiding a hard problem. It isn’t giving the rest of the system a simpler model. It only exists so this route test can swap in a fake object.
The usual defense is future flexibility, because one day there might be another implementation that needs it, and this will already be in place! It’s hard to accept that as a default, especially when that default becomes a pattern. Real alternative implementations rarely show up so neatly that they just drop into an interface you wrote months ago.
I think that’s why interface-heavy codebases feel worse to debug than they look on a whiteboard. Every extra layer turns one question into several smaller ones: Wait, which implementation did the container wire here? Is this method doing work or forwarding? Is this repository actually where the query lives, or is there another wrapper under it? None of those are domain questions, because you’re not navigating the domain anymore. You’re navigating the scaffolding.
The defensive reflex
The pushback is always the same: “If you remove these interfaces, how are you supposed to test anything?”
That question is part of the problem. It assumes the only solid test is one that isolates a class by mocking every dependency. If you accept that framing, then production code has to expose replacement points everywhere. Plenty of teams use TDD and still produce sane boundaries though. I think the issue is this strict belief that every unit test must be isolated with mocks, and any concrete dependency is treated as a failure in design.
You can also see it in what these tests end up asserting. Instead of checking that an order was rejected when stock was low, the test asserts that certain methods were called in a certain order. That couples the test to the internal shape of the workflow, so any harmless refactor breaks tests while behavior stays correct.
So the question isn’t “how do I test without mocks?” It’s “am I adding this interface for the code, or just so the test can swap in a fake?”
Test the thing you actually built
Instead, improve the test by adjusting its boundary. If a workflow contains real business rules, pull those rules into functions that take data in and return data out.
export function decideOrder({ item, quantity, customerStatus }) { if (!item || item.stock < quantity) { return { ok: false, reason: 'OUT_OF_STOCK' } }
if (customerStatus === 'blocked') { return { ok: false, reason: 'CUSTOMER_BLOCKED' } }
return { ok: true, reserve: quantity }}These functions are where a lot of the real value usually lives. You can test ten edge cases in a few lines each, with plain inputs and outputs. No interface. No mocking framework. Just pure functions. The database calls and payment hooks you were trying to avoid in tests still need to happen. They just don’t need an interface to hide behind.
import { decideOrder } from './decideOrder.js'
export async function placeOrder(input, deps) { const item = await deps.inventory.getBySku(input.sku) const decision = decideOrder({ item, quantity: input.quantity, customerStatus: input.customerStatus })
if (!decision.ok) return decision
await deps.payments.charge(input.customerId, input.total) await deps.inventory.reserve(input.sku, decision.reserve) const order = await deps.orders.insert(input)
return { ok: true, order }}Some people will look at deps and say this is still dependency injection! Yeah, technically it is. The important difference is that I didn’t create a public OrderService interface just so a controller test could mock it. I kept a concrete workflow and passed concrete collaborators through.
When an interface earns its keep
There are cases where an interface makes sense, of course.
A payment provider client that normalizes retries, handles idempotency, and wraps provider-specific error codes is doing real work. A storage boundary that lets the app crawl documents or a plugin point in production for external vendors is also doing real work.
The common smell is when the interface is a 1:1 mirror for something else. UserRepository and VipUserRepository where the interface methods are a subset of the same methods. NotificationService and DefaultNotificationService where the interface tells you nothing new. If removing the interface changes almost no production code and only makes mocks harder to write, that’s usually a tell.
You can usually read the abstraction and ask what complexity it lets the caller forget. If the answer is none, it’s likely wrapping. If the benefit mostly shows up in test setup rather than in production code, same thing. Good boundaries let you ignore the implementation most days. Mocks and interfaces just scatter it across more files.
| Earned boundary | Mock in disguise | |
|---|---|---|
| Why it exists | Hides provider quirks, normalizes concepts, supports real variants | Lets a unit test replace a consumer |
| Live implementations | Multiple active and under real pressure | One concrete class plus matching interface |
| What the caller forgets | Retries, idempotency, storage complexity, provider errors | Almost nothing; the same concepts leak through |
| Test style | Boundary tests or behavior tests | Interaction tests against method calls |
| Effect on debugging | Compresses the problem | Adds abstraction and indirection |
| Delete it tomorrow | It’s gonna be a long night | A handful of broken mocks and test setup |
Keep the boundary if it hides real complexity or supports genuinely different production behavior. If it only exists so a mock can fit through, cut it.