Back to Blog
Lesson 48 of the Software Testing & Debugging: Testing & Debugging Foundations (QA) course
TestingSeptember 4, 20264 min read

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.

refactoringtestabilitydependency-injectionunit-testingclean-codesoftware-design
Close-up of AI-assisted coding with menu options for debugging and problem-solving.

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

JAVASCRIPT
class 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

JAVASCRIPT
class 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:

  1. Extract the dependency: Define an interface (or expected behavior) for the emailer.
  2. Inject: Pass the service into the constructor.
  3. 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.

  1. Identify the hard-coded dependency (e.g., fs.readFile or an axios call).
  2. Refactor the class to accept that dependency in its constructor.
  3. 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

Similar Posts