DEV Community

Dhana
Dhana

Posted on

Interface for Testability? A Reader's Pushback Made Me Reconsider

A previous article argued that registering a concrete class instead of an interface in Dependency Injection quietly determines whether a class can be unit tested. A reader, Ivan Rossouw, pushed back with a genuinely useful correction: framing an interface as the condition for testability oversimplifies things. This article works through that correction properly, because it's a distinction worth understanding precisely, not glossing over.

The Original Claim

The earlier argument was straightforward: if a Controller depends on a concrete class directly,

csharp
public WorkFlowController(clsWorkFlow workFlowDataAccess)

there's no way to substitute a fake implementation during testing, because there's no interface for a fake to implement. Register against an interface instead,

csharp
public WorkFlowController(IclsWorkFlow workFlowDataAccess)

and a fake IclsWorkFlow can stand in for the real, database-backed clsWorkFlow during a unit test.

This is true as far as it goes. What it leaves out is that "testable" doesn't only mean "mockable through an interface."

The Correction: A Concrete Class Can Be Tested Directly

A concrete class, with no interface at all, can still be tested — just differently:

csharp
var realService = new clsWorkFlow(testConnectionString);
var result = realService.GetAddWorkFlows(employeeSlno: 5, createdBy: 1);
Assert.Equal(expectedCount, result.Count);

This test genuinely runs clsWorkFlow's actual logic, including its real database calls. It's a legitimate test — it's simply a different kind of test than a unit test that isolates logic through a fake dependency. This is called an integration test: it verifies that the class works correctly together with a real (or real-like) database, rather than verifying the class's logic in isolation from everything it depends on.

Why an Integration Test Can Be More Valuable Here

For a class like clsWorkFlow, whose entire responsibility is talking to Oracle — building commands, passing parameters, mapping results — a mock-based unit test has a real limitation: a mock can't catch a genuine SQL or parameter-binding mistake, because a mock never touches a real database at all. If a stored procedure name is wrong, or a parameter type doesn't match what Oracle expects, a unit test built entirely on a fake IclsWorkFlow will pass without ever discovering the problem — because the fake never runs any real database logic.

An integration test, run against a disposable database — a temporary, throwaway database set up specifically for testing and torn down afterward, not the real production database — would catch exactly this kind of error, because it actually executes the real SQL against a real (if temporary) target. For a "thin adapter" class whose main job is translating between application code and database calls, this kind of test can surface more genuine, useful failures than a unit test that mocks the very thing it's supposed to be verifying.

A More Precise Rule of Thumb

Ivan's suggested approach, worth adopting as a working rule: introduce an interface around volatile or external behavior — code that reaches outside the application's own process, like a database call, an external API, or a file system operation — and keep genuinely simple, self-contained services concrete, without an interface just for the sake of having one.

The reasoning: an interface's real value isn't testability in the abstract — it's the ability to substitute behavior when that behavior is expensive, slow, unreliable, or external to the code being tested. A trivial service with no external dependencies doesn't need an interface to be tested; it can simply be instantiated directly, the same way clsWorkFlow can be tested directly against a disposable database.

A Useful Review Question

A practical check worth applying when reviewing whether an interface actually earns its place: does this interface express a genuine capability, or does it just duplicate every method on one class?

An interface like IclsWorkFlow, which exists specifically to allow swapping the real Oracle-backed implementation for a fake one during testing (or, in principle, for a different database implementation later), expresses a real capability — "something that can fetch and save workflow data" — independent of any one specific implementation. An interface that exists purely because "best practice says register interfaces," without any real intention of ever substituting a different implementation, is closer to unnecessary duplication than genuine abstraction.

What This Changes About the Original Argument

The core point — that concrete-class registration limits how a Controller can be tested through mocking — still holds. What needed correcting was the implication that mocking through an interface is the only legitimate path to testing, or automatically the best one. For a thin, database-facing class, a well-designed integration test against a disposable database can be more informative than a unit test built on a mock that can never fail the way real database code actually fails.

Takeaway

Testability isn't a single, uniform requirement satisfied only by interfaces and mocks. A concrete class can be genuinely well-tested through integration testing against a disposable resource, and for classes whose entire purpose is interacting with an external system, this can catch real problems that a mock-based unit test structurally cannot. The more precise habit isn't "always use an interface for testability" — it's introducing interfaces deliberately around volatile or external behavior, and asking, for each one, whether it expresses a real, substitutable capability or merely mirrors a single class's method list.

Top comments (0)