envelopegithubhomelinkedinsearchrss

Overriding Singletons from TSyringe within Jest Tests

18 Jul 2020 - JavaScript, TypeScript, TSyringe, Jest

You might encounter some issues while trying to override singletons from TSyringe in Jest tests. Here is a quick post to help you to fix them.

Quick Overview of TSyringe

Dependency Injection (often abbreviated as D.I.), when correctly used, is one of the tools used to create modular, maintainable & testable applications. Every language has its own D.I. libraries. For example Scala users may use the powerful macros from MacWire.

In TypeScript, we have TSyringe. Here is the associated Hello World (don’t forget to enable decorators & to polyfill the Reflect API):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { container, injectable } from "tsyringe";

class Foo {
  value = 123;
}

@injectable()
class Bar {
  constructor(public foo: Foo) {}

  bar() {
    console.log(this.foo.value);
  }
}

container.resolve(Bar).bar();
// prints "123"

Dependency Injection is a design pattern used to implement Inversion of Control. One of the advantage is to not have to manually wire classes together.

Overriding injectables & usage within Jest

The library also allows us to create children dependency injection containers. Then it becomes easy to override some injectables during our tests:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { container, injectable } from "tsyringe";

class Foo {
  value = 123;
}

class NewFoo {
  value = 456;
}

@injectable()
class Bar {
  constructor(public foo: Foo) {}

  bar() {
    console.log(this.foo.value);
  }
}

test("Test override", () => {
  const bar = container
    .createChildContainer()
    .register<Foo>(Foo, NewFoo)
    .resolve(Bar);

  expect(bar.foo.value).toStrictEqual(456);
});

Note: writing two times Foo in .register<Foo>(Foo, NewFoo) is not a mistake. The second one is the injection token while the first one is here to constraint NewFoo to have the same shape as Foo during compilation. The generic type parameter can be omitted if you don’t want this additional security layer (although recommended).

Issues with Singletons

TSyringe supports singletons, useful if you don’t want to create a new instance every time you call resolve.

Unfortunately if you want to override a singleton, it will screw up everything, and you will not receive an error/warning from TSyringe (as of 4.3.0). This is because when you register a class as a singleton it is done within the global container (i.e. if you try to override it in a child container, then at the end it is overridden in all containers).

We can easily reproduce it with the following test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { container, singleton } from "tsyringe";

class Foo {
  value = 123;
}

class NewFoo {
  value = 456;
}

class NextGenFoo {
  value = 789;
}

@singleton()
class Bar {
  constructor(public foo: Foo) {}

  bar() {
    console.log(this.foo.value);
  }
}

test("Test singleton override - ok", () => {
  const bar = container
    .createChildContainer()
    .register<Foo>(Foo, NewFoo)
    .resolve(Bar);

  expect(bar.foo.value).toStrictEqual(456);
});

test("Test single override - boom", () => {
  const bar = container
    .createChildContainer()
    .register<Foo>(Foo, NextGenFoo)
    .resolve(Bar);

  expect(bar.foo.value).toStrictEqual(789);
});

Here is the associated output from Jest, where we can see the error:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
 FAIL  test/wrong.test.ts
  ✓ Test singleton override - ok (2 ms)
  ✕ Test single override - boom (2 ms)

  ● Test single override - boom

    expect(received).toStrictEqual(expected) // deep equality

    Expected: 789
    Received: 456

      37 |     .resolve(Bar);
      38 |
    > 39 |   expect(bar.foo.value).toStrictEqual(789);
         |                         ^
      40 | });
      41 |

      at Object.<anonymous> (test/wrong.test.ts:39:25)

As mentioned in the documentation we can easily fix this issue by adding the following snippet at the top of our test file:

1
2
3
beforeEach(() => {
  container.clearInstances();
});

Bonus point: if you do not want to add it at the top of all your test files, then you can instead add it to a file referenced by the setupFilesAfterEnv option from Jest 🚀.

Thoughts about using Mocks from Jest

Mocks from Jest (Manual & ES6 classes) are really powerful, well-supported & documented. Depending on your needs you may choose between the two strategies or combine them. One nice advantage of TSyringe is the ability to automatically wire all the dependencies in complex applications.