Unit testing with jest image

Unit Testing with Typescript, NodeJs and Jest

Setting up unit tests in a TypeScript and Node.js project can significantly improve code quality and developer confidence. This guide, is going through configuring Jest for unit testing in a TypeScript + Node.js environment, covering essential best practices and how to apply test-driven development (TDD). Whether a backend service or a full-stack app, it should help with how to write clean, maintainable, and reliable tests that integrate smoothly into your development workflow.

Unit: method, class, module

Types of tests: unit, integration, E2E (end-to-end, UI)

Setup Project

Create NodeJs Project:

npm init -y

Install dependencies:

npm i –save-dev typescript jest ts-jest @types/jest ts-node

Generate tsconfig:

npx tsc –init

To avoid warnings set “esModuleInterop”: true

Create jest config file:

npx ts-jest config:init

Previous will create javascript jest config file (jest.config.js). Since we want all in typescript we can delete and create typescript jest config: jest.config.ts

//basic jest.config.ts

import type { Config } from '@jest/types';

const config: Config.InitialOptions = {
  preset: 'ts-jest', 
  testEnvironment: 'node',
  verbose: true,
}

export default config;

Writing tests

AAA principle

  • Arrange
  • Act
  • Assert

👉Good practice is to write only 1 assert per test to be able to identify what exactly has failed in the tested functionality!

// sample test

import { toUpperCase } from "../app/util";

describe('util test suite', () => {

  it('should return uppercase of valid string', () => {
    // arrange
    const expected = 'ABCD';

    // act
    const actual = toUpperCase('abcd');

    // assert
    expect(actual).toBe(expected);
  });

});

Some most used ‘expect’ methods

.toBe

compare primitive types (string, number, …)

.toEqual

compare objects

.toHaveLength

compare array length

.toContain

check if array contains an element

// To check more elements in array
const someArrayOfChars = ['a', 'b', 'c'];

expect(someArrayOfChars).toEqual(
    expect.arrayContaining(['b', 'c', 'a'])
);

.not

negate the matcher: expect(value).not.toBe(1)

Parametrized tests

In case we need to run the test for different parameters (inputs and results), but test itself will stay the same, it’s not necessary to repeat, but instead parametrized test can be written.

it.each([
   {input: 'abc', expected: 'ABC'}
   {input: 'my-string', expected: 'MY-STRING'}
   {input: 'xyz', expected: 'XYZ'}
])('$input toUpperCase should be $expected',
   ({input, expected}) => {
   
     const actual = toUpperCase(input);
     expect(actual).toBe(expected);
});

Testing for Errors

// three different ways how to test for errors

// 1. function
it('should throw error invalid argument', () => {
    function expectError() {
        const actual = toUpperCase('');
    }

    expect(expectError).toThrow();
    // or
    expect(expectError).toThrowError('Invalid arg...');
})

// 2. arrow function
it('should throw error invalid argument', () => {
    expect(() => {
        const actual = toUpperCase('');
    }).toThrowError('Invalid arg...');
})

// 3. try-catch block
it('should throw error invalid argument', (done) => {
    try {
        toUpperCase('');
  //a) need to fail the test otherwise it be OK even if the error is not thrown
        fail('should throw error');
  //b) jest has issue and throws 'fail is not defined' => workaround with 'done'
       done('should throw error');
    } catch (error) {
        expect(error).toBeInstanceOf(Error);
        expect(error).toHaveProperty('message', 'Invalid argument');
        done();
    }
})

Test doubles

  • stubs
  • fakes
  • mocks
  • spies

Stubs

Usual representation is an incomplete structure from the original and is filled only with minimum necessary data required for test.

Fakes

It’s e.g. empty function definition for a callback in case I don’t really need it to be called: () => {}

test('use fake callback', () => {
   const actual = someFunctionWithClbk('', () => {});
   expect(actual).toBe('');
})

Mocks

Used when Fakes are not enough and we need to know more information: how many times it’s called, call parameters, define return value, etc.

const clbkMock = jest.fn();

afterEach(() => {
    // mocks need to be reset
    jest.clearAllMocks();
})

test('', () => {
    const actual = someFceWithClbk('', clbMock);
    expect(clbMock).toBeCalledWith('<arguments>');
    expect(clbMock).toBeCalledTimes(1)
})

Spies

Spies

  • Spies, not like mocks, aren’t directly injected into our system-under-test,
  • original functionality is preserved,
  • usually track method calls
  • works on classes, modules
describe('spy example', () => {
  let sut: MyClass;

  beforeEach(() => {
    sut = new MyClass();
  })

  test('simple spy', () => {
    const methodSpy = jest.SpyOn(sut, 'method');
    sut.method('param');
    expect(methodSpy).toBeCalledWith('param');
  })

  test('spy w/ change implementation', () => {
    const methodSpy = jest.SpyOn(sut, 'method')
      .mockImplementation(() => {
        // own test implementation of 'method'
      });
    sut.method('param');
    expect(methodSpy).toBeCalledWith('param');
  })
})

Mocking modules

// 1. mock whole module
jest.mock('../app/.../someModule');

// 2. mock only what is necessary from module
jest.mock('../app/.../someModule', () => ({
  // include all what needs to be kept
  ...jest.requireActual('../app/.../someModule'),
  // mock only what is required
  someFce: () => { return 'value'},
  // or 
  someFce2: jest.fn()
}));

// 3. mock some libs (e.g.: uuid)
jest.mock('uuid', () => ({
  v4: () => '123'
}));

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *