Skip to content
End To End Tester

Dependency Injection for Testability

Why injected dependencies are the one technique that makes isolated testing possible, the three forms of injection, and how to do it without a container.

2 min read · updated 19 September 2026

Almost every "this code is untestable" complaint resolves to the same sentence: the code constructs or reaches for its own dependencies, so there is no way to put a test double in the way.

csharp
// Untestable. Nothing can be substituted, so the test needs a real
// database, a real SMTP server, and the patience to wait for midnight.
public class OrderService
{
    public void Place(Order order)
    {
        var repository = new SqlOrderRepository(ConfigurationManager.AppSettings["Db"]);
        repository.Save(order);

        if (DateTime.Now.Hour < 17)
            new SmtpClient("mail.example.com").Send(ConfirmationFor(order));
    }
}
csharp
// Testable. Same behaviour; the dependencies now arrive from outside.
public class OrderService(IOrderRepository repository, IEmailSender email, TimeProvider clock)
{
    public void Place(Order order)
    {
        repository.Save(order);

        if (clock.GetLocalNow().Hour < 17)
            email.Send(ConfirmationFor(order));
    }
}

The second version can be tested in microseconds, with any clock value, against a failing repository, without a network. Nothing else changed.

#The three seams

Constructor injection — the default, and the one to reach for. The object cannot exist in an invalid state, and the constructor signature is an honest declaration of what the class depends on. That last property is valuable precisely because it becomes uncomfortable: a constructor with nine parameters is a class doing nine things.

Method injection — pass it at the call site. Right when the dependency varies per call rather than per object.

typescript
export function priceOrder(order: Order, rates: ExchangeRates): Money { /* ... */ }

Property injection — settable after construction. Last resort; it allows a half-constructed object and the test has to remember to set it.

#Doing it without a container

A container is an automation of the wiring, not the technique. Hand-wiring is entirely legitimate and keeps the object graph visible:

typescript
// composition root — one file, the only place that knows how things fit
export function buildApp(config: Config) {
  const db = new Database(config.databaseUrl);
  const orders = new SqlOrderRepository(db);
  const email = config.smtp ? new SmtpSender(config.smtp) : new ConsoleSender();
  const clock = systemClock;

  return new OrderService(orders, email, clock);
}

Tests never call buildApp. They construct the one object they are testing with exactly the doubles they need — which is the property that makes unit tests fast.

#The three dependencies people forget

The clock. DateTime.Now, new Date(), time.time(). Untested edge cases around midnight, month ends and daylight-saving transitions are a recurring source of production bugs, and they are trivial to test once the clock is injected.

typescript
// TypeScript. A one-line abstraction that makes expiry logic testable.
export type Clock = () => number;
export const systemClock: Clock = () => Date.now();

export function isExpired(token: Token, now: Clock = systemClock): boolean {
  return token.expiresAt <= now();
}

// in the test
expect(isExpired(token, () => Date.parse('2026-01-01T13:00:01Z'))).toBe(true);

Randomness. ID generation, shuffling, sampling, jitter. Inject the source and tests become deterministic.

The environment. process.env, ConfigurationManager, reading a file at module load. Configuration read at construction is injectable; configuration read at the point of use is not.

#Containers in tests

If the application uses a container, tests can too — but selectively. The component test pattern is to build the real container and override only what crosses the process boundary:

csharp
// The whole real graph; one edge replaced.
builder.ConfigureTestServices(services =>
{
    services.RemoveAll<IPaymentGateway>();
    services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
});

For unit tests, do not involve the container at all. new OrderService(fakeRepo, fakeEmail, fixedClock) is clearer, faster, and does not couple the test to registration details.

#When not to inject

Injecting everything is its own pathology. It produces constructors with twelve parameters, interfaces with one implementation apiece, and a codebase where following a call means grepping for registrations.

A practical rule: inject what crosses a process boundary or is non-deterministic. Construct everything else. Your own pure value objects, domain entities and small helpers should simply be newed — and a test that uses the real ones is easier to read and harder to fool. See unit testing without mocks.

The broader question — why some code resists testing at all — is writing testable code.

Common questions

Do I need a DI container to do dependency injection?
No. Dependency injection is passing a dependency in rather than constructing it inside; a container is one way to automate the wiring. Plenty of well-tested codebases inject by hand and are better for it, because the wiring stays visible.
How do I test code that calls DateTime.Now?
Inject a clock. Every ecosystem has an abstraction for this now — TimeProvider in .NET 8+, Clock in java.time, a fake timer in Jest, freezegun in Python. Reading the wall clock directly is the single most common cause of a test that fails at midnight.
Is it worth injecting something with only one implementation?
If the single implementation does I/O, yes — the second implementation is the test double. If it is pure and fast, no; injecting a pure function you own adds indirection for nothing.

Runnable samples for this page

last test results ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?