Question presented to candidate: "A class you are testing calls new EmailService() directly inside its constructor. Why does that make it hard to unit test, and what pattern fixes it?"
What a strong answer should cover:
- Dependency injection (DI) means a class receives its collaborators ("dependencies") from outside itself — typically via its constructor — rather than creating them itself internally. The class depends on an interface/shape, not a specific concrete implementation it constructs.
- 📌 The concrete, verifiable benefit: because the dependency is supplied externally, a test double (a fake/mock implementation) can be substituted with zero changes to the class under test — verified directly: the identical class, unmodified, worked correctly wired to a real service in one run and a fake, call-recording service in another.
- Node.js has no built-in, framework-level DI container the way some other ecosystems do (Angular, Spring, .NET) — DI in Node is most commonly just plain constructor parameters, sometimes formalized further with a small DI library (
awilix,inversify) or a framework's own convention (NestJS's decorator-based DI being the most prominent Node example). - The core problem DI solves is tight coupling: a class that instantiates its own dependencies is bound to that specific concrete implementation at every call site, making substitution (for tests, or for swapping a real implementation for a different one in a different environment) require modifying the class itself rather than just the wiring that constructs it.
- A precise answer distinguishes DI (a pattern — receiving dependencies from outside) from a DI container/framework (a tool that automates the wiring) — DI itself needs no special library at all, as demonstrated with plain constructor parameters; a container becomes more valuable specifically as the dependency graph grows large and manual wiring becomes tedious.
- The trade-off worth naming: DI adds a layer of indirection and an explicit "wiring" step (something has to actually construct and pass in the real dependencies at the application's entry point) — for a very small application, this can be more ceremony than the tight-coupling problem it solves actually costs.
Clarifying questions expected:
- "Is the goal specifically testability, or something broader like swapping implementations across environments?" — both are real motivations, but the emphasis differs.
- "Is a DI container/framework already in use (NestJS, awilix), or is plain constructor injection sufficient here?" — decides how much extra tooling the answer should reach for.
Code / implementation expected: Yes — the same class wired to a real dependency and a test double, unmodified, is the concrete, convincing demonstration of the actual benefit.