Refactoring for Testability: Mastering Dependency Injection
Learn how to refactor your code for better testability by applying dependency injection. Reduce tight coupling and modularize your logic for cleaner, testable systems.

Previously in this course, we explored handling flaky tests to ensure our existing test suites remain stable and trustworthy. Now, we shift our focus from stabilizing tests to improving the underlying architecture, specifically addressing how to restructure code to maximize its testability.
The Problem of Tight Coupling
In software engineering, testability is a direct reflection of how easily a unit of code can be isolated. If your function or class reaches out and touches the global state, a database, or a network API directly, it is "tightly coupled."
When code is tightly coupled, you cannot test it without also testing its dependencies. If you want to test a ProcessOrder function that saves to a production database, you are forced to set up a database just to verify a calculation. This leads to slow, fragile tests that break whenever the environment changes. As we discussed in Refactoring with Confidence, the goal is to isolate the logic from the infrastructure.
First Principles: Dependency Injection
The primary tool to fix tight coupling is Dependency Injection (DI). Instead of a class creating its own dependencies (e.g., this.db = new Database()), you "inject" them from the outside.
Think of it like a power outlet. A lamp doesn't contain its own power plant; it simply expects a source of electricity to be plugged into it. This allows you to plug the lamp into a wall socket (production) or a battery (a mock for testing).
Before: Tightly Coupled
JAVASCRIPTclass OrderService { process(orderId) { // Hard-coded dependency on a real Database instance const db = new Database(); const order = db.fetch(orderId); order.status = CE9178">'processed'; db.save(order); } }
After: Dependency Injection
JAVASCRIPTclass OrderService { constructor(database) { this.db = database; } process(orderId) { const order = this.db.fetch(orderId); order.status = CE9178">'processed'; this.db.save(order); } }
By passing database into the constructor, we can now inject a MockDatabase during testing that doesn't actually touch the disk.
Worked Example: Modularizing for Coverage
Let’s look at how this refactoring improves your ability to write unit tests. Suppose we have a NotificationManager that sends emails.
JAVASCRIPT// The "Bad" Way class NotificationManager { send(message) { const emailer = new EmailService(); // Hidden dependency emailer.connect(CE9178">'smtp.server.com'); emailer.send(message); } }
To test this, you’d need an SMTP server. Instead, let's modularize:
- Extract the dependency: Define an interface (or expected behavior) for the emailer.
- Inject: Pass the service into the constructor.
- Test: Use a mock object.
JAVASCRIPT// The "Testable" Way class NotificationManager { constructor(emailer) { this.emailer = emailer; } send(message) { this.emailer.send(message); } } // In your test suite test(CE9178">'sends notification', () => { const mockEmailer = { send: jest.fn() }; const manager = new NotificationManager(mockEmailer); manager.send(CE9178">'Hello!'); expect(mockEmailer.send).toHaveBeenCalledWith(CE9178">'Hello!'); });
Hands-on Exercise
Take a class in your current project that interacts with the filesystem or a network.
- Identify the hard-coded dependency (e.g.,
fs.readFileor anaxioscall). - Refactor the class to accept that dependency in its constructor.
- Write a test that uses a mock version of that dependency to verify that your class calls the expected method without actually performing the I/O.
Common Pitfalls
- Dependency Overload: Don't inject 20 different things. If a class needs too many dependencies, it’s a sign that the class is doing too much and should be broken into smaller modules.
- Constructor Bloat: If you find your constructor parameters growing uncontrollably, consider using a configuration object or a factory pattern.
- Mocking Everything: Only inject dependencies that are unstable or slow (databases, APIs, timers). Don't bother injecting simple data structures or pure utility functions.
FAQ
Q: Is dependency injection overkill for small projects? A: It might feel like it at first, but writing testable code is a habit. Even in small projects, DI prevents the "impossible to test" trap that forces massive refactors later.
Q: Does DI hurt performance? A: Negligible. The overhead of passing an object reference is practically zero compared to the benefits of a maintainable, testable codebase.
Recap
Refactoring for testability is about creating boundaries. By using dependency injection, we decouple our business logic from external systems, allowing for isolated unit tests that run in milliseconds. As you continue to build your project, keep looking for those new keywords inside methods—they are often signals that you should be injecting that dependency instead.
Up next: Advanced Assertions and Matchers
Work with me

Next.js Website & Landing Page Development
A blazing-fast, SEO-optimized website or landing page in Next.js — the kind that loads instantly and ranks. Design-to-code, done right.

Custom WordPress Theme Development
A custom WordPress theme built exactly to your design — fast, clean, and easy to manage. No bloated page builders, no compromises.


