Consider a method that calculates some ETA:

class Calendar {
  add3WorkingDays(): Date {
    const result = new Date();
    let daysAdded = 0;
    while (daysAdded < 3) {
      result.setDate(result.getDate() + 1);
      if (this.isWorkingDay(result)) {
        daysAdded++;
      }
    }
    return result;
  }

  isWorkingDay(date: Date): boolean {
    const day = date.getDay();
    return day !== 0 && day !== 6;
  }
}

Notice how add3WorkingDays depends on the time when it’s called (result is initially now). Because of that, you’d have trouble writing reliable unit tests for this.

My recommendation: change the implicit dependency of now to explicit one and inject it:

  add3WorkingDays(date: Date): Date {
    const result = new Date(date);
    // no changes from here

It takes only so much to implement dependency injection, the most overcomplicated technique in the history of programming. You don’t need frameworks, containers, service locators, interfaces, or decorators. You only need to know how to parameterize a method.

Oh, by the way, we changed the interface of the method too. We have to change all the callers: calendar.add3WorkingDays() -> calendar.add3WorkingDays(new Date())

Does this mean you don’t want to introduce dependency injection later in the project, to avoid risky refactors? No. You can still have DI without changing the interface using the default parameter:

  add3WorkingDays(date: Date = new Date())

This way all the callers of add3WorkingDays can stay unchanged and, at the same time, all your unit tests can use the parameter to test the method properly.

Other useful examples

1. Validation rules

When the list of rules is long and much easier to comprehend in isolation, you can inject a 1-item array in tests. Don’t forget to use types, so that your tests are reliable.

const DEFAULT_RULES: Rule[] = [
  {
    errorMessage: 'Password must be at least 10 characters long',
    predicate: (password: string) => password.length >= 10,
  },
  {
    errorMessage: 'Password must contain at least one number',
    predicate: (password: string) => /[0-9]/.test(password),
  },
  {
    errorMessage: 'Password must contain at least one special character',
    predicate: (password: string) => /[!@#$%^&*()_+{}\[\]:;<>,.?~\\]/.test(password),
  },
  {
    errorMessage: 'Password must contain at least one lowercase letter',
    predicate: (password: string) => /[a-z]/.test(password),
  },
  {
    errorMessage: 'Password must contain at least one uppercase letter',
    predicate: (password: string) => /[A-Z]/.test(password),
  }
]

class PasswordValidator {
  constructor(readonly rules: Rule[] = DEFAULT_RULES) { }

  validate(password: string): ValidationResult {
    const errors = this.rules.reduce((errors: string[], rule: Rule) => {
      if (!rule.predicate(password)) {
        errors.push(rule.errorMessage);
      }
      return errors;
    }, []);

    return {
      success: errors.length === 0,
      errorMessages: errors,
    };
  }
}

// in production
new PasswordValidator();

// when you test a single rule
new PasswordValidator([{
  errorMessage: 'Password must contain at least one special character',
  predicate: (password: string) => /[!@#$%^&*()_+{}\[\]:;<>,.?~\\]/.test(password),
}])

// when you test what happes when your predicate fails
new PasswordValidator([{
  errorMessage: 'Error message that always shows',
  predicate: () => false
}])

2. Random number generator

Make Math.random an explicit dependency.

class Random {
  constructor(readonly getNumber: () => number = Math.random) { }

  getRandomInt(min: number, max: number): number {
    return Math.floor(this.getNumber() * (max - min)) + 1;
  }
}

console.log(new Random(() => 1.0).getRandomInt(1, 10)); // prints "10"
console.log(new Random(() => 0.5).getRandomInt(1, 10)); // prints "5"
console.log(new Random(() => 0.0).getRandomInt(1, 10)); // prints "1"

3. Logger

Instead of using console.log you can hide the concrete implementation of it behind an interface. This way it’s easily replaceable eg. in tests when you don’t want to pollute your console. Note how the interface is a useful feature here.

interface Print {
  log(...data: any[]): void;
}

class Logger {
  constructor(readonly print: Print = console) { }

  log(message: string): void {
    this.print.log(message);
  }
}

class NoopPrint implements Print {
  log(): void {
    // noop
  }
}

new Logger().log('Hello'); // prints "Hello"
new Logger(new NoopPrint()).log('Hello'); // prints nothing

4. Config / process.env

Once again, make it an explicit dependency.

class Config {
  constructor(readonly env: NodeJS.Dict<string> = process.env) { }

  get(key: string): string | undefined {
    return this.env[key];
  }
}

const stubConfig = { 'NODE_ENV': 'not real env' }

console.log(new Config().get('NODE_ENV')); // prints your NODE_ENV
console.log(new Config(stubConfig).get('NODE_ENV')); // prints "not real env"

5. Web MVC-ish

Typically, in a web framework, you have layers of classes (like services or repositories). If you follow that structure without giving it too much thinking your integration tests require a real database connection.

Here is how to do this with simple dependency injection:

class User {
  id: string;
  name: string;
}

const nullUser = new User();

interface UserRepository {
  get: (id: string) => User;
}

class SQLUserRepository implements UserRepository {
  get(id: string) {
    // SQL queries and whatnot; for now, just return a new user
    return new User();
  }
}

class InMemoryUserRepository implements UserRepository {
  constructor(readonly records: User[] = []) {
  }

  get(id: string) {
    return this.records.find((record) => record.id === id) ?? nullUser;
  }
}

class UserService {
  constructor(private userRepository: UserRepository = new SQLUserRepository()) { }

  authUser(id: string): boolean {
    const user = this.userRepository.get(id);
    if (user === nullUser) {
      return false;
    }
    return true;
  }
}

// in production
new UserService()

// in tests - initialize with empty database
new UserService(new InMemoryUserRepository())

// in tests - initialize with non-empty database
new UserService(new InMemoryUserRepository([{ id: '1', name: 'John Doe' }]))

Note how there are two instances of dependency injection here. You can inject a UserRepositry (you don’t have to, but you want it in your tests). And you can inject initial values to InMemoryUserRepository (again, you don’t have to).

Dependency injection for testability

Think of your code as potentially called by different types of callers. These different callers may need to inject dependencies according to their needs. One of the callers is always tests – think of what to inject to make your code testable.

Takeaways

  • Dependency injection is not a pattern, should be written all lowercase. Don’t be fooled by people who say that you need DI containers/service locators/frameworks to implement dependency injection.
  • You can have dependency injection in a very simple way – add another parameter to your constructor, function, or method.
  • The hard part is: some dependencies are implicit; first you have to recognize that and make them explicit, after that dependency injection should be straightforward.
  • You can leverage the default parameter to introduce dependency injection without changing the callers’ code.
  • Always inject dependencies that are inconvenient for tests