Dependency injection

First, let's see how you can use dependency injection as an alternative to module mocking. We've already touched on dependency injection when mocking functions, but now, let's how a proper introduction to it.

What is dependency injection?

Dependency injection (short, DI) is a software design technique that reverses the dependency relation, making the system a function of a dependency.
Consider this implementation of an UploadService class:
import { FileStorage } from './file-storage.js'

export class UploadService {
  private storage: FileStorage

  constructor() {
    this.storage = new FileStorage()
  }

  public async upload(file: File) {
    await this.storage.setItem(file.name, await file.arrayBuffer())
  }
}
The purpose of this class is to upload files via the .upload() method. Internally, the upload service relies on the FileStorage to provision actual file upload and retrieval. The file storage is available as this.storage because the UploadService creates an instance of that storage whenever it's constructed.
This means that the UploadService -> FileStorage dependency is implicit. Nobody using the upload service knows about it, and that includes our tests!
Because of this implicit dependency, the two classes become tightly coupled. If you would want to exclude the behaviors of the FileStorage class while testing the UploadService class, you would have to expose the implementation detail of that dependency to the test. That will make the test brittle, failing if you restructure your classes.
You can avoid this tight coupling by refactoring the UploadService class to accept an instance of a FileStorage as an argument (i.e. explicit dependency):
import { FileStorage } from './file-storage.js'

export class UploadService {
  constructor(private storage: FileStorage) {
    this.storage = new FileStorage()
  }

  public async upload(file: File) {
    await this.storage.setItem(file.name, await file.arrayBuffer())
  }
}
This seemingly insignificant change has huge implications on the relationship between these classes. Because their dependency has been "lifted up", this allows the consumer of the upload service to provide any instance of the file storage they want.
const storage = new FileStorage()
const uploadService = new UploadService(storage)
It almost seems like the file storage is being injected into the upload service πŸ˜‰. Hence the name, Dependency Injection.
Dependency injection transform the UploadService -> FileStorage dependency from being tight and internal to being loose and public. An upload service instance effectively becomes a function of file storage!
That kind of dependency is extremely handy when testing.
Since an upload service instance accepts any file storage instance, we can pass a fake file storage instance for testing purposes. This will exclude the file storage from the testing equation while giving us control over its behaviors.
const storage = new FakeFileStorage()
const uploadService = new UploadService(storage)
The only thing we have to do to make this setup work is to ensure that the FakeFileStorage is compatible with the actual FileStorage. To keep the established API contract.
But before we do that, let me give you a warning...

⚠️ When to use dependency injection

Dependency injection is a software design technique, not a testing technique. I find it extremely harmful to base your design decisions around how your system is going to be tested. You are creating APIs for your consumers, and as such, you must have the consumer's best interest in mind when crafting those APIs. Sometimes that will mean more complexity for you to tackle in tests, and that is perfectly fine.
In other words, be cautious not to rewrite your code for the sake of easier testing experience. This workshop alone gives you enough tools to tackle any complexity your system may introduce. Let it not sway you from the correct API decisions.
Design your software for the user, not the tests.

Your task

In this exercise, the NotificationService class happened to be written with the dependency injection pattern in mind, and you will certainly use that to your advantage when testing it!
πŸ‘¨β€πŸ’Ό Complete the tests for the by constructing a fake EmailService and using it as an argument to test different notification scenarios.
Once you are done, run npm test to make sure that all the tests are passing.