Unit Testing with JestJS

Anh-Thi Dinh

References

Run a single test / file

1# From 2019
2npm test -- path/to/file.spec.js

Basic

Sample

1// We wanna mock this class
2export class ProductsClient {
3  async getById(id) {
4    const url = `http://localhost:3000/api/products/{id}`;
5    const response = await fetch(url);
6    return await response.json();
7  }
8}
9
10// We wanna test this class
11export class ProductManager {
12  async getProductToManage(id) {
13    const productsClient = new ProductsClient();
14    const productToManage = await productsClient.getById(id)
15      .catch(err => alert(err));
16    return productToManage;
17  }
18}
1// In the file of testing class ProductManager
2import { ProductsClient } from './ProductsClient';
3jest.mock('./ProductsClient');
4
5// A "mock" getById() which returns "undefined" will be created
6// But we want the mock function returns a value as we want
7
8// assign a mock function to the ProductsClient's 'getById' method
9const mockGetById = jest.fn();
10ProductsClient.prototype.getById = mockGetById;
11// We can make the mock function return what we want
12mockGetById.mockReturnValue(Promise.resolve(expectedProduct));
13
14// Restore the state of the original class
15mockFn.mockClear() // or something like that (check the doc)

Folder conventions

(SO source) The conventions for Jest, in order of best to worst in my opinion:
  1. src/file.test.js mentioned first in the Getting Started docs, and is great for keeping tests (especially unit) easy to find next to source files
  1. src/__tests__/file.js lets you have multiple __tests__ directories so tests are still near original files without cluttering the same directories
  1. __tests__/file.js more like older test frameworks that put all the tests in a separate directory; while Jest does support it, it's not as easy to keep tests organized and discoverable

Testing with Date()

Becareful that,
For example, "2011-10-10" (date-only form), "2011-10-10T14:48:00" (date-time form), or "2011-10-10T14:48:00.000+09:00" (date-time form with milliseconds and time zone) can be passed and will be parsed. When the time zone offset is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as local time.
→ Sử dụng UTC time sẽ an toàn hơn (và ko bị ảnh hưởng bởi local time hay timezone)

Differences

beforeAll vs beforeEach

👇 This SO.
If the tests do make changes to those conditions, then you would need to use beforeEach, which will run before every test, so it can reset the conditions for the next one.

resetAllMocks, resetModules, resetModuleRegistry, restoreAllMocks

jest.fn() vs jest.spyOn()

👉 Read more.
  • They are all used for mocking a method.
  • jest.fn() → return undefined if no implementation is provided. (use these methods to implement)
  • jest.spyOn() → default it calls the original implementation, it stores the original implementation in memory. ← restore by mockRestore()

Using .env variable with Jest

Put import 'dotenv/config'; at the beginning of the .spec file. It will use the current env file.
If we wanna mock some variables?
1describe('process.env', () => {
2    const env = process.env
3
4    beforeEach(() => {
5        jest.resetModules() // important!!!
6        process.env = { ...env }
7    })
8
9    afterEach(() => {
10        process.env = env
11    })
12})
13
14it('should mock process.env', () => {
15    process.env.NODE_ENV = 'development'
16    console.log(process.env.NODE_ENV) // Will be "development"
17})
18
19it('should not mock process.env', () => {
20    console.log(process.env.NODE_ENV) // Will be "test"
21})
1// Use in a single suit
2describe('Enabled Zerobounce validation', () => {
3  beforeAll(() => {
4    process.env.ZEROBOUNCE_USE = 'true';
5  });

Jest CLI

  • --runInBand (or -i): source — Run all tests serially in the current process, rather than creating a worker pool of child processes that run tests.

Reset and restore

Mocking

👇 Source.

Basic

Mocking a named import → official jest.mock()
1// Usage
2import { getTime } from './time';
3
4// test.js
5jest.mock('./time', () => ({
6    getTime: () => '1:11PM',
7}));
Mocking only the named import (and leaving other imports unmocked) → official jest.requireActual
1// Usage
2import { getTime, isMorning } from './time';
3
4// test.js
5jest.mock('./time', () => ({
6    ...jest.requireActual('./time'), 
7    getTime: () => '1:11PM',
8    // isMorning will return its true value
9}));
Mocking a default import
1// Usage
2import getDayOfWeek from './time';
3
4// test.js
5jest.mock('./time', () => () => 'Monday');
Mocking default and named imports
1// Usage
2import getDayOfWeek, { getTime } from './time';
3
4// test.js
5jest.mock('./time', () => ({
6    __esModule: true,
7    default: () => 'Thursday'
8    getTime: () => '1:11PM',
9}));
Changing what the mock returns per test
Be careful: Calling mockReturnValue inside a test still changes the mock for all other tests after it. ← use mockReturnValueOnce instead!
1import getDayOfWeek from './time';
2
3jest.mock('./time', () => jest.fn());
4
5test('App renders Monday', () => {
6    getDayOfWeek.mockReturnValue('Monday');
7    //...
8});
9
10test('App renders Tuesday', () => {
11    getDayOfWeek.mockReturnValue('Tuesday');
12    //...
13});
14
15test('App renders Monday, again', () => {
16    // Fails
17});
Clearing mocks between tests (make sure it’ll be called once) → clearAllMocks
1beforeEach(() => {
2    jest.clearAllMocks();
3});
Mocking multiple modules with chaining,
1jest.mock('./time', () => jest.fn())
2    .mock('./space', () => jest.fn());

Give mock a name

→ Use mockName() and getMockName()

Mock dependencies of a class

⚠️
This is just an example of my very specific case. It may not good (or accurate) enough!
1// df.service.ts
2export class DialogflowService {
3	constructor(private loggerService, botId){}
4	method() {
5		this.loggerService.log('abc');
6		// main codes
7	}
8}
1// df.service.spec.ts
2beforeAll(() => {
3	loggerService = {
4    log: jest.fn()
5  };
6	const botId = 'fake-bot-id';
7	service = new DialogflowService(loggerService, botId);
8})

Mock a class

1// my-class.ts
2class MyClass {
3  constructor(name) {
4    this.name = name;
5  }
6  methodOne() {
7    return 1;
8  }
9  methodTwo() {
10    return 2;
11  }
12}
13export default MyClass;
1// my-class.spec.ts
2import testSubject from './testSubject';
3jest.mock('./myClass', () => ({
4  name: 'Jody',
5  methodOne: () => 10,
6  methodTwo: () => 25,
7}));
If a class is not a default export from a module (official doc),
1import {SoundPlayer} from './sound-player';
2jest.mock('./sound-player', () => {
3  // Works and lets you check for constructor calls:
4  return {
5    SoundPlayer: jest.fn().mockImplementation(() => {
6      return {playSoundFile: () => {}};
7    }),
8  };
9});

Mock get, static in a class

Read this official doc.

Mock a specific method in the class

1// Original class
2export default class Person {
3    constructor(first, last) {
4        this.first = first;
5        this.last = last;
6    }
7    sayMyName() { // wanna mock
8        console.log(this.first + " " + this.last);
9    }
10    bla() { // wanna keep
11        return "bla";
12    }
13}
1// Test
2import Person from "./Person";
3
4test('Modify only instance', () => {
5    let person = new Person('Lorem', 'Ipsum');
6    let spy = jest.spyOn(person, 'sayMyName').mockImplementation(() => 'Hello');
7
8    expect(person.sayMyName()).toBe("Hello");
9    expect(person.bla()).toBe("bla");
10
11    // unnecessary in this case, putting it here just to illustrate how to "unmock" a method
12    spy.mockRestore();
13});

Mock a module for different tests

👇 ⭐ Source (read the exmplanation there)
If we wanna import a constant?
1// app.js
2import { CAPITALIZE } from './config';
3export const sayHello = (name) => {
4  let result = 'Hi, ';
5
6  if (CAPITALIZE) {
7    result += name[0].toUpperCase() + name.substring(1, name.length);
8  } else {
9    result += name;
10  }
11  return result;
12};
1// app.spec.ts
2import { sayHello } from './say-hello';
3import * as config from './config';
4// 👇 For typescipt: type casting
5const mockConfig = config as { CAPITALIZE: boolean };
6
7jest.mock('./config', () => ({
8  __esModule: true, // important for using import * from ...
9  CAPITALIZE: null // default: null if export default CAPITALIZE;
10}));
11
12describe('say-hello', () => {
13  test('Capitalizes name if config requires that', () => {
14    mockConfig.CAPITALIZE = true;
15
16    expect(sayHello('john')).toBe('Hi, John');
17  });
18
19  test('does not capitalize name if config does not require that', () => {
20    mockConfig.CAPITALIZE = false;
21
22    expect(sayHello('john')).toBe('Hi, john');
23  });
24});
How about a method?
(We don’t need import * from ... and also no need __esModule)
1import { sayHello } from './say-hello';
2import { shouldCapitalize } from './config';
3
4jest.mock('./config', () => ({
5  shouldCapitalize: jest.fn()
6}));
7
8describe('say-hello', () => {
9  test('Capitalizes name if config requires that', () => {
10    shouldCapitalize.mockReturnValue(true);
11
12    expect(sayHello('john')).toBe('Hi, John');
13  });
14
15  test('does not capitalize name if config does not require that', () => {
16    shouldCapitalize.mockReturnValue(false);
17
18    expect(sayHello('john')).toBe('Hi, john');
19  });
20});

(Optional) Mock a client of @google-cloud/dialogflow

Mock got package

got has itself got and other methods get, post,…

Reject / Test Exceptions

There is also a case where in the main funtion, we throw new ClassName() and we want to catch this error in the test file!
1// not working 
2expect(new TestObject()).toThrow();
3// Because new TestObject() is evaluated first
4
5// working
6expect(() => { new TestObject() }).toThrow();

Mock reject a function inside

Bên trong hàm cần được test có 1 hàm (updateEntityTypeSpy) và ta muốn mock hàm này throw an error để có thể test.
1it('If there are problems with the API?', async () => {
2  // Mock
3  const serviceIdObjects = fakeServiceIdObjects().entities;
4  const lexIds = serviceIdObjects.slice(0, 2).map(obj => obj.idetaId);
5  updateEntityTypeSpy = jest
6    .spyOn(service, 'updateEntityType')
7    .mockRejectedValueOnce('Error')
8    .mockResolvedValueOnce();
9
10  // Call the method
11  await service
12    .updateLexiconsInDialogflow(...)
13    .catch(() => {
14      expect(updateEntityTypeSpy).toBeCalledTimes(1);
15    });
16
17  // Expectations
18  expect(updateIntentSpy).not.toBeCalled();
19  expect(removeLexiconServiceIdSpy).not.toBeCalled();
20  expect(removeLexiconStatusSpy).not.toBeCalled();
21});

Testing Observable

1it('should return an object of given type', done => {
2  of(mockSnapshot()).pipe(service.methodToTest()).subscribe(res => {
3    expect(res).toStrictEqual(fakeReturnedObj);
4    done();
5  });
6});

Tips

Create a “util” function for all tests → create it in .stub.ts and then export it and import it in the .spec.ts file.

Troubleshooting

❇️  mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64'))
(On Mac M1) → make sure the terminal is open under arm architecture (run arch too see) → then reinstall everything,
1rm -rf node_modules && rm package-lock.json && npm i
❇️  Jest did not exit one second after the test run has completed.
(Not a perfect solution) ← just not to see but not solve the problem internally
1# Run with
2--detectOpenHandles --forceExit
✳️ Unexpected directive 'TinyBotSpinnerComponent' imported by the module 'DynamicTestModule'. Please add an @NgModule annotation.
Still not know!!!!
✳️ RequestError: getaddrinfo ENOTFOUND us-undefined
→ Forget to .mockResolvedValue()
1// Error
2const getAgentSpy = jest.spyOn(service, 'getAgent');
3// Without error
4const getAgentSpy = jest.spyOn(service, 'getAgent').mockResolvedValue(null);
It’s good to do something like this,
1describe('💎 freshExport()', () => {
2  let getAgentSpy: jest.SpyInstance;
3	
4	beforeEach(() => {
5    jest.clearAllMocks();
6    getAgentSpy = jest.spyOn(service, 'getAgent');
7	});
8	
9	it('should call all the necessary methods', async () => {
10    getAgentSpy.mockResolvedValue(null);
11	});
12});