Getting started testing with Jest

The methodology

This series will be based on the black box testing methodology. What does that even mean? It means that there is input data provided, then internal processing occurs (a.k.a. black box) and results are produced. The tester is not aware of how the internals work but only knows what the app should do.

input -> [ black box ] -> output

Take a look at a simple web page.

<input type="text">
<button>Add</button>

<table>
    <thead>
        <th>value</th>
    </thead>
    <tbody></tbody>
</table>

As a tester I know that:

  • when I fill the input field (input)

  • and click Add button (input)

  • it should block the input field and the button (output)

  • send HTTP request (output)

  • add new a new record to the table (output)

  • clear input field data (output)

  • unblock the input field and the button (output)

I don't care how the app is going to do all that stuff, but I do care about the user interface and sent requests. And that's what can and should be tested.

The tools

Requirements

  • node 16

  • npm 8

  • npx

Setup

The series will be using Jest as the default testing framework. Jest is a fast, widely used and well-documented testing framework. The documentation can be found here: jestjs.io. For testing typescript files ts-jest library is required.

Initialize npm with default settings.

npm init -y

Install required dependencies. For testing: jest and ts-jest. For compilation: typescript. For observables: RxJS

npm i jest@29 @types/jest@29 ts-jest@29 typescript@4.7 rxjs@7

Initialize the default ts-jest configuration.

npx ts-jest config:init

A simple test

Assume you want to test the following class:

class Formatter {
  whitespaceToUnderscore(input: string): string {
    return input.split('').join('_');
  }
}

So the test might look like this:

describe('Test Formatter', () => {
  let formatter: Formatter;

  beforeAll(() => {
    formatter = new Formatter();
  });

  it('should convert "This is and example input" to "This_is_and_example_input"', () => {
    expect(formatter.whitespaceToUnderscore('This is and example input')).toBe('This_is_and_example_input');
  });
});

Let's break it down.

  • The describe is a block that groups the related tests or other describe blocks,

  • The beforeAll is a block that is executed once in the describe block. Since you don't want to create Formatter instance each time it's more performant just to create it once.

  • The it (a.k.a. test) is a block that contains expectations (preferably one expectation per it block),

  • The expect is a function that takes input (i.e. the actual result of a method call). Next, it is chained with the matcher. The matcher is a function that takes a value, that you expect to receive and compares it with expect's argument. In this particular case, it's toBe matcher, which is used for comparing primitive types.

Jest basics

Primitives

To test primitives it's enough to use toBe matcher.

const inputString = 'my example';
const inputFloat = 512.1241;
const inputInteger = 999;
const inputBoolean = false;
const inputNull = null;
const inputUndefined = undefined;
const inputNaN = NaN;


describe('Test primitives', () => {

  it('should test string', () => {
    expect(inputString).toBe('my example');
  });

  it('should test float', () => {
    expect(inputFloat).toBe(512.1241);
  });

  it('should test integer', () => {
    expect(inputInteger).toBe(999);
  });

  it('should test boolean', () => {
    expect(inputBoolean).toBe(false);
  });

  it('should test null', () => {
    expect(inputNull).toBe(null);
  });

  it('should test undefined', () => {
    expect(inputUndefined).toBe(undefined);
  });

  it('should test NaN', () => {
    expect(inputNaN).toBe(NaN);
  });
});

When the value doesn't matter, but you need to check whether it's a false or true when compared to a boolean, use toBeFalsy() or toBeTruthy();

const inputString = 'my example';
const inputEmptyString = '';
const inputZeroInteger = 0;
const inputZeroFloat = 0.0;
const inputFloat = 512.1241;
const inputInteger = 999;
const inputBooleanFalse = false;
const inputBooleanTrue = true;
const inputNull = null;
const inputUndefined = undefined;
const inputNaN = NaN;


describe('Test primitives', () => {

  it('should test string', () => {
    expect(inputString).toBeTruthy();
    expect(inputEmptyString).toBeFalsy();
  });

  it('should test float', () => {
    expect(inputFloat).toBeTruthy();
    expect(inputZeroFloat).toBeFalsy();
  });

  it('should test integer', () => {
    expect(inputInteger).toBeTruthy();
    expect(inputZeroInteger).toBeFalsy();
  });

  it('should test boolean', () => {
    expect(inputBooleanFalse).toBeFalsy();
    expect(inputBooleanTrue).toBeTruthy();
  });

  it('should test null', () => {
    expect(inputNull).toBeFalsy();
  });

  it('should test undefined', () => {
    expect(inputUndefined).toBeFalsy();
  });

  it('should test NaN', () => {
    expect(inputNaN).toBeFalsy();
  });
});

For NaN, null and undefined there are corresponding matchers toBeNan(), toBeNull() and toBeUndefined(). It's the same as using toBe() matcher, but more verbose.

const inputNull = null;
const inputUndefined = undefined;
const inputNaN = NaN;


describe('Test primitives', () => {

  it('should test null', () => {
    expect(inputNull).toBeNull();
  });

  it('should test undefined', () => {
    expect(inputUndefined).toBeUndefined();
  });

  it('should test NaN', () => {
    expect(inputNaN).toBeNaN();
  });
});

Arrays

To deeply compare arrays use toEqual() matcher.

const inputEmptyArray = [];
const inputSimpleArray = [1, '2'];
const inputSparseArray = [undefined, 1, undefined, '2'];
const inputNestedArray = [1, '2', ['33', {color: '#fff'}]];


describe('Test array equality', () => {

  it('should test empty array', () => {
    expect(inputEmptyArray).toEqual([]);
  });

  it('should test simple array', () => {
    expect(inputSimpleArray).toEqual([1, '2']);
  });

  it('should test sparse array', () => {
    expect(inputSparseArray).toEqual([undefined, 1, undefined, '2']);
  });

  it('should test nested array', () => {
    expect(inputNestedArray).toEqual([1, '2', ['33', {color: '#fff'}]]);
  });
});

Objects and classes

To deeply compare objects use toEqual() matcher.

const inputEmptyObject = {};
const inputSimpleObject = {color: '#fff'};
const inputSparseObject = {color: '#fff', border: undefined};
const inputNestedObject = {color: '#fff', margin: {top: '25px', left: '5px'}};


describe('Test object equality', () => {

  it('should test empty object', () => {
    expect(inputEmptyObject).toEqual({});
  });

  it('should test simple object', () => {
    expect(inputSimpleObject).toEqual({color: '#fff'});
  });

  it('should test sparse object', () => {
    expect(inputSparseObject).toEqual({color: '#fff', border: undefined});
  });

  it('should test nested object', () => {
    expect(inputNestedObject).toEqual({color: '#fff', margin: {top: '25px', left: '5px'}});
  });
});

To check whether an object is an instance of a certain class use toBeInstanceOf() matcher.

class ExampleClass {}


describe('Test class instance', () => {
  it('should test empty object', () => {
    const myClass = new ExampleClass();
    expect(myClass).toBeInstanceOf(ExampleClass);
  });
});

Method calls

To track the method calls you need to use a spy. By default, the spied method will be executed normally as it's defined in the code. The first parameter for jest.spyOn() is a class instance. The second is the name of the tracked method.

class Logger {
  log(data: unknown): void {
    ...
  }
}

logger = new Logger();
jest.spyOn(logger, 'log');

When spies are set, you can use specific matchers to check how or if at all, all the methods were called.

To check only if a method was called, use toHaveBeenCalled() matcher.

expect(logger.log).toHaveBeenCalled();

To check if a method was called and returned a specific value, use toHaveReturnedWith() matcher.

expect(headphones.getVolume).toHaveReturnedWith(0.5);

If a method is called multiple times, you can verify all consecutive calls by using toHaveBeenNthCalledWith(). The first argument is the number of the consecutive call, the second is the expected result.

expect(logger.log).toHaveBeenNthCalledWith(1, {text: 'message-1'});
expect(logger.log).toHaveBeenNthCalledWith(2, {text: 'message-2'});
expect(logger.log).toHaveBeenNthCalledWith(3, {text: 'message-3'});

Below you can find a fully working example.

import { Subject } from 'rxjs';

class Headphones {
  private readonly maxVolume = 1;
  private readonly minVolume = 0;
  private volume = 0.5;
  private volumeSubject = new Subject<number>();
  readonly volumeChanges$ = this.volumeSubject.asObservable();

  volumeUp(): void {
    this.changeVolume(0.1);
  }

  volumeDown(): void {
    this.changeVolume(-0.1);
  }

  getVolume(): number {
    return this.volume;
  }

  private changeVolume(value: number): void {
    this.volume = this.calculateVolumeLevel(value);
    this.volumeSubject.next(this.volume);
  }

  private calculateVolumeLevel(value: number): number {
    let updatedVolume = this.volume + value;

    if (updatedVolume < this.minVolume) {
      return this.minVolume;
    }

    if (updatedVolume > this.maxVolume) {
      return this.maxVolume;
    }

    return updatedVolume;
  }
}

class Logger {
  log(data: unknown): void {
    // ...
  }
}


describe('Test class calls', () => {
  let headphones: Headphones;
  let logger: Logger;

  beforeEach(() => {
    headphones = new Headphones();
    logger = new Logger();
    jest.spyOn(headphones, 'getVolume');
    jest.spyOn(logger, 'log');
  });

  it('getVolume should be called and return 0.5', () => {
    headphones.getVolume();
    expect(headphones.getVolume).toHaveReturnedWith(0.5);
  });

  it('should call logger once upon a volume change', () => {
    headphones.volumeChanges$.subscribe(volume => {
      const data = {event: 'VolumeChange', value: volume};
      logger.log(data);
    });

    headphones.volumeUp();
    expect(logger.log).toHaveBeenCalled();
  });

  it('should call logger with payload once upon a volume change', () => {
    headphones.volumeChanges$.subscribe(volume => {
      const data = {event: 'VolumeChange', value: volume};
      logger.log(data);
    });

    headphones.volumeUp();
    const expected = {event: 'VolumeChange', value: 0.6};
    expect(logger.log).toHaveBeenCalledWith(expected)
  });

  it('should call logger 3 times upon a volume changes', () => {
    headphones.volumeChanges$.subscribe(volume => {
      const data = {event: 'VolumeChange', value: volume};
      logger.log(data);
    });

    headphones.volumeUp();
    headphones.volumeUp();
    headphones.volumeDown();
    const expected1 = {event: 'VolumeChange', value: 0.6};
    expect(logger.log).toHaveBeenNthCalledWith(1, expected1);
    const expected2 = {event: 'VolumeChange', value: 0.7};
    expect(logger.log).toHaveBeenNthCalledWith(2, expected2);
    const expected3 = {event: 'VolumeChange', value: 0.6};
    expect(logger.log).toHaveBeenNthCalledWith(3, expected3);
  });
});

Exceptions (errors)

There are a few basic ways to test whether an exception was thrown:

  • test if any exception was thrown

  • test if a certain exception class was thrown

  • test if an exception is an object with certain message property

  • test if a string was thrown

To test an exception, you have to wrap the code in a function, otherwise, the exception will not be caught and the assertion will fail. Instead of

expect(throwInvalidNumberException()).toThrow();

you need to use something like

expect(() => throwInvalidNumberException()).toThrow();

Let's assume that all tests contain the following declarations:

class AppException extends Error {

}

class InvalidNumberException extends AppException {

}

const throwInvalidNumberException = (): never => {
  throw new InvalidNumberException('Provided number is invalid');
}

const throwGenericError = (): never => {
  throw new Error('Unknown error');
}

const throwString = (): never => {
  throw 'Unexpected error';
}

To test if any exception was thrown it's enough to use toThrow() matcher without any argument.

describe('Test any exception', () => {
  it('should throw InvalidNumberException', () => {
    expect(() => throwInvalidNumberException()).toThrow();
  });

  it('should throw generic error', () => {
    expect(() => throwGenericError()).toThrow();
  });

  it('should throw string', () => {
    expect(() => throwString()).toThrow();
  });
});

To test if an exception is an instance of a certain class use toThrow() matcher passing a class reference as an argument.

describe('Test exception class', () => {
  it('should throw InvalidNumberException', () => {
    expect(() => throwInvalidNumberException()).toThrow(InvalidNumberException);
  });

  it('should throw AppException', () => {
    expect(() => throwInvalidNumberException()).toThrow(AppException);
  });

  it('should throw generic error', () => {
    expect(() => throwGenericError()).toThrow(Error);
  });
});

To test if an exception is a string or an object with message property also use toThrow() matcher passing a string containing the expected error message.

describe('Test exception message', () => {
  it('should throw "Provided number is invalid" message', () => {
    expect(() => throwInvalidNumberException()).toThrow('Provided number is invalid');
  });

  it('should throw "Unknown error', () => {
    expect(() => throwGenericError()).toThrow('Unknown error');
  });

  it('should throw "Unexpected error"', () => {
    expect(() => throwString()).toThrow('Unexpected error');
  });
});

Asynchronous code

The basic way of testing asynchronous code is quite straightforward.

To test a resolved promise:

  • define the it callback as async function

  • await for a promise resolution

  • test the results

const resolvePromise = async (): Promise<string> => {
  return 'resolved';
}

it('should await for a promise', async () => {
  const result = await resolvePromise();
  expect(result).toBe('resolved');
});

To test a rejected promise you need to take some extra steps:

  • define the it callback as async function

  • since there will be a try/catch block, add expect.assertions() call. It takes a number and verifies that this number of assertions will be called during the test

  • await for a promise in try block

  • catch the error in catch block

  • check the error instance

const rejectPromise = async (): Promise<never> => {
  throw new Error('rejected');
}

it('should await for a rejected promise', async () => {
  expect.assertions(1);

  try {
    await rejectPromise();
  } catch (error) {
    expect(error).toBeInstanceOf(Error);
  }
});

Datasets

Let's assume, that you have the following code. findMultipleEntries function takes two arguments: an array of entries and an array of keys and returns entries matching the keys.

interface HasId {
  id: string;
}

type Nullable<T> = T | null | undefined;

const findMultipleEntries = <T extends HasId>(entries: Nullable<T[]>, keys: Nullable<string[]>): T[] => {
  if (!entries || !keys) {
    return [];
  }

  return entries.filter(entry => keys.includes(entry.id));
}

Instead of writing multiple duplicated tests, you might use the datasets. The dataset should at least contain all values that will be used inside test calls and also an expected result.

To define the dataset use the interface Dataset with 3 self-explanatory properties.

interface Dataset {
  entries: Nullable<any[]>;
  keys: Nullable<any[]>;
  expected: any[]
};

const datasets: Dataset[] = [
  {
    entries: null,
    keys: null,
    expected: []
  },
  {
    entries: null,
    keys: undefined,
    expected: []
  },
  {
    entries: undefined,
    keys: null,
    expected: []
  },
  {
    entries: undefined,
    keys: undefined,
    expected: []
  },
  {
    entries: [],
    keys: [],
    expected: []
  },
  {
    entries: [
      {id: 'k1', value: 1},
      {id: 'k3', value: 3},
      {id: 'k5', value: 5},
    ],
    keys: [],
    expected: []
  },
  {
    entries: [],
    keys: ['k1', 'k5', 'k6'],
    expected: []
  },
  {
    entries: [
      {id: 'k22', value: 22},
      {id: 'k23', value: 23},
    ],
    keys: ['k91', 'k95'],
    expected: []
  },
  {
    entries: [
      {id: 'k1', value: 1},
      {id: 'k3', value: 3},
      {id: 'k5', value: 5},
    ],
    keys: ['k1', 'k5', 'k6'],
    expected: [{id: 'k1', value: 1},
      {id: 'k5', value: 5},]
  },
];

To utilize datasets use it.each (or describe.each if executing multiple tests on a dataset) call. The %j is the first (and the only) positional parameter, that will convert a dataset (first and the only argument) to JSON. There are many other formatting options, so should you need more details, please refer to the official Jest documentation.

describe('Test findMultipleEntries', () => {
  it.each(datasets)('dataset: %j', (dataset: Dataset) => {
    const actualResult = findMultipleEntries(dataset.entries, dataset.keys);
    expect(actualResult).toEqual(dataset.expected);
  });
});

The output should look similar to the following:

  Test findMultipleEntries
    ✓ dataset: {"entries":null,"keys":null,"expected":[]}
    ✓ dataset: {"entries":null,"expected":[]}
    ✓ dataset: {"keys":null,"expected":[]}
    ✓ dataset: {"expected":[]}
    ✓ dataset: {"entries":[],"keys":[],"expected":[]}
    ✓ dataset: {"entries":[{"id":"k1","value":1},{"id":"k3","value":3},{"id":"k5","value":5}],"keys":[],"expected":[]}
    ✓ dataset: {"entries":[],"keys":["k1","k5","k6"],"expected":[]}
    ✓ dataset: {"entries":[{"id":"k22","value":22},{"id":"k23","value":23}],"keys":["k91","k95"],"expected":[]}
    ✓ dataset: {"entries":[{"id":"k1","value":1},{"id":"k3","value":3},{"id":"k5","value":5}],"keys":["k1","k5","k6"],"expected":[{"id":"k1","value":1},{"id":"k5","value":5}]}

Test Suites: 1 passed, 1 total
Tests:       9 passed, 9 total

Naming

My preferred way of naming tests is a structure of very long sentences

Imagine a simple application, that displays a client list. What would the flow look like?

  • the user enters the page

    • the skeleton is displayed

    • header is displayed

    • API clients request is made

      • request error occurs

        • an error is displayed
      • request is successful

        • the client list is displayed
    • add client button is displayed

      • user clicks the button

        • user is redirected to new URL

The tests should look similar to this structure. For example:

describe('When user enters the page', () => {

  it('should display page header', () => {});

  it('should display skeleton', () => {});

  it('should not display client list', () => {});

  it('should display "add user" button', () => {});

  it('should not display error', () => {});

  it('should send clients request', () => {});

  describe('and user clicks "add user" button', () => {
    it('should redirect to "/create" url', () => {});
  });

  describe('and clients request fails', () => {
    it('should display error', () => {});

    it('should not display skeleton', () => {});

    it('should not display client list', () => {});
  });

  describe('and clients request is successful', () => {
    it('should not display error', () => {});

    it('should not display skeleton', () => {});

    it('should display client list', () => {});
  });
});

When you run this test suite, you'll get this output:

When user enters the page
  ✓ should display page header
  ✓ should display skeleton
  ✓ should not display client list
  ✓ should display "add user" button
  ✓ should not display error
  ✓ should send clients request
  and user clicks "add user" button
    ✓ should redirect to "/create" url
  and clients request fails
    ✓ should display error
    ✓ should not display skeleton
    ✓ should not display client list
  and clients request is successful
    ✓ should not display error
    ✓ should not display skeleton
    ✓ should display client list

These sentences are easy to read and are quite verbose. Just a quick look at the results and you already know what's going on.

When your application grows bigger, and so do the tests, sometimes it's not always possible to keep that structure. At some point, you're going to add some nested blocks like describe('Testing pagination') that logically separate parts of the application.

There are of course many other best practices and guidelines on how to name your tests. Remember that the tests are mainly for us, the developers.

Footnote

This article just scratched the surface of testing. Jest itself is much more extensive and can be supplemented by extra libraries and plugins. And of course, there are many other frameworks, tools and methodologies to explore. As always - you have to find what works for you.