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 otherdescribe
blocks,The
beforeAll
is a block that is executed once in thedescribe
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 perit
block),The
expect
is a function that takes input (i.e. the actual result of a method call). Next, it is chained with thematcher
. Thematcher
is a function that takes a value, that you expect to receive and compares it withexpect's
argument. In this particular case, it'stoBe
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
propertytest 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 asasync
functionawait
for a promise resolutiontest 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 asasync
functionsince there will be a
try/catch
block, addexpect.assertions()
call. It takes a number and verifies that this number of assertions will be called during the testawait for a promise in
try
blockcatch the error in
catch
blockcheck 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.