Article 5: Unit testing and depdency injection in TypeScript

2022-06-12

Goal

I recently joined a new company that uses TypeScript. In order to be able to configure the application properly and to apply inversion of control we were looking for a way to structure our TypeScript application. This is usually done by applying dependency injection The next few chapters describe the way from the very naive approach to something we were happy with. So let's dive right in!
Disclaimer: This article does not mean to be a complete overview nor does it claim to be the only way of doing things. It's an overview of possible solutions we found and subject to change at any time. Any feedback welcome. All code examples can be found on Github.

Approaches

  1. testing using no stub/mock at all
  2. stubbing modules
  3. using types
  4. using types and manual mocks
  5. using pick to dynamically generate the types
  6. using a class mocking library

1. testing using noch stub/mock at all

This is the most basic version of testing the consumer class. Please also note that the Dependency class is coupled tightly to the Consumer class.

dependency.ts

export class Dependency {
  someFunction() {
    return 'someValue'
  }
}

consumer.ts

import { Dependency } from "./dependency";

export class Consumer {
  dependency: Dependency;
  constructor() {
    this.dependency = new Dependency()
  }

  doThing() {
    return this.dependency.someFunction()
  }
}

consumer.integration.test.ts

import { Consumer } from './consumer'

describe('given a consumer', () => {
  const consumer = new Consumer()

  it('should properly do its thing', () => {
    expect(consumer.doThing()).toStrictEqual('someValue')
  })
})

pros & cons

+ easy
+ no extra dependencies needed

- classes are tightly coupled, without the ability to control instantiation
- hard to configure
- this is of course an integration test and not a proper unit test as more than one class is tested a the some time
- testing two classes at the same time

2. stubbing modules

In this step we will write a proper unit test by stubbing the dependency module.

dependency.ts

export class Dependency {
  someFunction() {
    return 'someValue'
  }
}

consumer.ts

import { Dependency } from "./dependency";

export class Consumer {
  dependency: Dependency;
  constructor() {
    this.dependency = new Dependency()
  }

  doThing() {
    return this.dependency.someFunction()
  }
}

consumer.module.test.ts

import { Consumer } from './consumer'

const mockSomeFunction = jest.fn()

jest.mock('./dependency', () => {
  return {
    Dependency: jest.fn().mockImplementation(() => {
      return {
        someFunction: mockSomeFunction
      }
    })
  }
})

beforeEach(() => {
  jest.clearAllMocks()
})

describe('when doing thing on consumer', () => {
  const consumer = new Consumer()
  
  it('should it should return value from some function', () => {
    mockSomeFunction.mockReturnValue('stub value')
    expect(consumer.doThing()).toStrictEqual('stub value')
  })

  it('should have called some function', () => {
    consumer.doThing()
    expect(mockSomeFunction).toHaveBeenCalledTimes(1)
  })
})

pros & cons

+ works for very naive cases
+ no extra dependencies needed
+ it is a proper unit test which tests only the behavior of the Consumer class

- still classes are tightly coupled, without the ability to control instantiation
- still hard to configure
- it's hard to spot the dependency and which parts of the dependency module have to be mocked
- setting up the stub is tedious

3. using types

In this version we will use a Type for the dependency and inject the dependency through the constructor.

consumer.ts

type HasSomeFunction = {
  someFunction: () => string
}

export class Consumer {
  dependency: HasSomeFunction;
  constructor(dependency: HasSomeFunction) {
    this.dependency = dependency
  }

  doThing() {
    return this.dependency.someFunction()
  }
}

consumer.typedependencyinjection.test.ts

import { Consumer } from './consumer'

describe('when doing thing on consumer', () => {
  const dependency = {
    someFunction: jest.fn()
  }
  const consumer = new Consumer(dependency)

  beforeEach(() => {
    jest.clearAllMocks()
  })
  
  it('should it should return value from some function', () => {
    dependency.someFunction.mockReturnValue('stub value')
    expect(consumer.doThing()).toStrictEqual('stub value')
  })

  it('should have called some function', () => {
    consumer.doThing()
    expect(dependency.someFunction).toHaveBeenCalledTimes(1)
  })
})
Instead of calling jest.clearAllMocks() before every test, one could also just move the whole code into the "it" function. I think this is just a matter of taste.

pros & cons

+ consumer does not know about the implementation of dependency at all
+ mock and stub are easier to create
+ test is way more readable
+ probably the least coupling that is still statically typed

- eventually hard to find the real implementation of dependency, depending on how the object graph is built

4. using types and manual mocks

The same version as before, but instead of using jest mocks we create the mock ourselves.

consumer.ts

type HasSomeFunction = {
  someFunction: () => string
}

export class Consumer {
  dependency: HasSomeFunction;
  constructor(dependency: HasSomeFunction) {
    this.dependency = dependency
  }

  doThing() {
    return this.dependency.someFunction()
  }
}

consumer.typedwithmanualmock.test.ts

import { Consumer } from './consumer'

describe('when doing thing on consumer', () => {
  it('should it should return value from some function', () => {
    const dependency = {
      someFunction: () => 'stub value'
    }
    const consumer = new Consumer(dependency)
    expect(consumer.doThing()).toStrictEqual('stub value')
  })

  it('should have called some function', () => {
    let wasCalled = false
    const dependency = {
      someFunction: () => {
        wasCalled = true;
        return 'stub value'
      }
    }
    const consumer = new Consumer(dependency)
    consumer.doThing()
    expect(wasCalled).toBeTruthy()
  })
})

pros & cons

generally the same as before and the following things:

+ no crazy mock code
+ most flexible

- lot of custom code

5. using pick to dynamically generate the types.

From a testing perspective this is the same as before, but we will use Pick from the TypeScript Utility Types to infer the type from the implementation during compile time.

dependency.ts

export class Dependency {
  someFunction() {
    return 'someValue'
  }
}

consumer.ts

import { Dependency } from "./dependency";

export class Consumer {
  dependency: Pick<Dependency, "someFunction">;
  constructor(dependency: Pick<Dependency, "someFunction">) {
    this.dependency = dependency
  }

  doThing() {
    return this.dependency.someFunction()
  }
}

consumer.typedependencyinjectionwithpick.test.ts

import { Consumer } from './consumer'

describe('when doing thing on consumer', () => {
  const dependency = {
    someFunction: jest.fn()
  }
  const consumer = new Consumer(dependency)

  beforeEach(() => {
    jest.clearAllMocks()
  })
  
  it('should it should return value from some function', () => {
    dependency.someFunction.mockReturnValue('stub value')
    expect(consumer.doThing()).toStrictEqual('stub value')
  })

  it('should have called some function', () => {
    consumer.doThing()
    expect(dependency.someFunction).toHaveBeenCalledTimes(1)
  })
})

pros & cons

+ easy to find the real implementation
+ test still doesn't know about the real implementation
+ no need for a separate type

- a little bit of coupling, but you're able to find the real implemntation easily
- syntax is very unusual for someone without a TypeScript background

6. using a class mocking library

The last version we will have a look at is to directly inject the class instance and mock it using jest-mock-extended. Instead of just being able to mock a type, this library can also mock classes.

dependency.ts

export class Dependency {
  someFunction() {
    return 'someValue'
  }
}

consumer.ts

import { Dependency } from "./dependency";

export class Consumer {
  dependency: Dependency;
  constructor(dependency: Dependency) {
    this.dependency = dependency
  }

  doThing() {
    return this.dependency.someFunction()
  }
}

consumer.classinjection.test.ts

import { mock, mockReset } from 'jest-mock-extended'
import { Consumer } from './consumer'
import { Dependency } from './dependency'

describe('when doing thing on consumer', () => {
  const dependency = mock<Dependency>()
  const consumer = new Consumer(dependency)

  beforeEach(() => {
    mockReset(dependency)
  })
  
  it('should it should return value from some function', () => {
    dependency.someFunction.mockReturnValue('stub value')
    expect(consumer.doThing()).toStrictEqual('stub value')
  })

  it('should have called some function', () => {
    consumer.doThing()
    expect(dependency.someFunction).toHaveBeenCalledTimes(1)
  })
})

pros & cons

+ probably most straight forward implementation
+ coming from other languages probably most easy to understand
+ no special TypeScript syntax needed
+ jest-mock-extended has nice support to assert mocked calls

- coupling of implementations
- extra dependency to download and maintain

Conclusion

There are quite a few ways to setup a TypeScript application and stub classes/types in unit tests. We will start with the last option as the one is the one that works best with NestJS out of the box. For the frontend we might chose one of the type options as the frontend application is built differently.

If you do have any questions, suggestions or feedback, feel free to connect with me on social media. You can find contact information here.