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.
// 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));
}
}// 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.
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:
// 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. 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:
// 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 ↗- C# (TimeProvider)
dotnet/Tests.XUnit/practices/dependency-injection - TypeScript
typescript/src/practices/dependency-injection
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Writing Testable CodeThe properties that make code easy to test — pure cores, injected edges, no hidden state — and the specific smells that make it hard.
- Test DoublesDummy, stub, spy, mock and fake — Meszaros's five kinds of test double, what each is for, and why using the words precisely makes code reviews shorter.
- Unit Testing with MocksThe solitarist approach — isolate the unit behind test doubles. What it buys, the coupling it creates, and the three rules that keep a mock-heavy suite maintainable.
- Writing Tests in C#The .NET testing stack — xUnit, Moq or NSubstitute, WebApplicationFactory, Testcontainers — and the dependency injection story that makes it the most testable of the four.
- Test-Driven DevelopmentRed-green-refactor, what TDD actually changes about a codebase, where it fits badly, and the honest evidence for and against it.
- Writing Tests in Pythonpytest fixtures, the patching rules that catch everyone, async testing, and the toolchain for a Python project that has to hold up in CI.
- Flaky TestsWhy tests fail intermittently, the six root causes and how to fix each one, how to detect flakiness deliberately, and what to do with a test you cannot fix today.
- Running Tests in ParallelHow parallelism turns hidden coupling into failures, the shared resources that clash, and how to make a suite genuinely safe to run concurrently.