Writing better Unit Tests in Jest/React and other Javascript tools

Mr Super Shetty
A Web Developer
Published in
6 min readJan 22, 2024

In this post we will go through the best parctices in writing better unit tests. Though the examples here are with Jest/React most of these are tool/framework independent.

Before going through the tests look at the code we will be writing tests for. The final code in the below link we will be using. So read this article before proceeding.

Single Expect Per Test

Don’t test multiple things in a single IT block. If a function returns multiple properties after performing transformations individually then they have to be separate IT blocks else test the whole object as one.

Never test multiple things in one IT unless you are do it for readability sake or in a really rare case the function is doing something really expensive which to ideally can be handled in beforeAll.

Wrong ❌

const props = {
networkName: 'wifi',
ipAddress: '1.1.1.1',
};

it('it should return proper result', () => {
const result = getNetworkDetails(props);
expect(result.network).toBe (‘wifi’);
expect(result.ip).toBe (‘1.1.1.1’);
});

Right ✅

it('it should return proper ip when ipAddress is not empty', () => {
const result = getNetworkDetails(props);
expect(result.ip).toBe (‘1.1.1.1’);
});

it('it should return proper network when networkName is not empty', () => {
const result = getNetworkDetails(props);
expect(result.network).toBe (‘wifi’);
});

Separate Mocks into methods

It’s not uncommon to inline method responses or large object inputs when mocking data. This leads to duplication of code if you want to reuse that same object elsewhere. It also fills the test file with unnecessary details adding to your cognitive load when reading the code.

Another thing to note is when moving mocks to a function, add the ability to extend that returned object. This ensures that what you need to verify is explicitly passed and that mock doesn’t become a black-box. This again reduces your cognitive load when reading the code.

Wrong ❌

it('it should return proper network when networkName is not empty', () => {
const props = {
networkName: 'wifi',
ipAddress: '1.1.1.1',
};
const result = getNetworkDetails(props);
expect(result.network).toBe ('wifi');
});

Right ✅

function getNetworkDetailsProps() {
return {
networkName: 'wifi',
ipAddress: '1.1.1.1',
};
}

it('it should return proper network when networkName is not empty', () => {
const props = getNetworkDetailsProps();
const result = getNetworkDetails(props);
expect(result.network).toBe ('wifi');
});

Better ✅ ✅

function getNetworkDetailsProps(obj) {
return {
networkName: 'wifi',
ipAddress: '1.1.1.1',
...obj,
};
}

it('it should return proper network when networkName is not empty', () => {
const props = getNetworkDetailsProps({
networkName: 'wifi'
});
const result = getNetworkDetails(props);
expect(result.network).toBe ('wifi');
});

Have Mocks in a separate file

Always put your mocks in a separate file. Not only will this make the original test file smaller, it also makes the imports in other files cleaner. Plus you can exclude these .mock files from some of your webpack/other tool operations.

Wrong ❌

// .test file
function getNetworkDetailsProps() {
return {
networkName: 'wifi',
ipAddress: '1.1.1.1',
};
}

it('it should return proper network when networkName is not empty', () => {
const props = getNetworkDetailsProps();
const result = getNetworkDetails(props);
expect(result.network).toBe ('wifi');
});

Right ✅

// .mock file
export function getNetworkDetailsProps() {
return {
networkName: 'wifi',
ipAddress: '1.1.1.1',
};
}
// .test file
it('it should return proper network when networkName is not empty', () => {
const props = getNetworkDetailsProps();
const result = getNetworkDetails(props);
expect(result.network).toBe ('wifi');
});

Only Test the Current Function

Only test the working of the current file. All methods, global parameters, device/browser state, child components etc that the code being tested calls/depends upon need to be mocked. Never depend on the inner workings of one of these methods during testing.

Wrong ❌

it('should get proper snapshots when network details exists', () => {
const props = getNetworkDetailsProps();
const snap = shallow(<NetworkDetails {...props} />);
expect(snap).toMatchSnapshot();
});

Right ✅

function mockGetNetworkDetailsDefault() {
return {
network: 'wifi',
ip: '1.1.1.1',
};
}

it('should get proper snapshots when network details exists', () => {
getNetworkDetailsProps.mockReturnValue(mockGetNetworkDetailsDefault());
const props = getNetworkDetailsProps();
const snap = shallow(<NetworkDetails {...props} />);
expect(snap).toMatchSnapshot();
});

Clear Mocks or Use MockOnce

The common mistake that most newbies make when writing UTs is to assume that the mock in one IT block/test is restricted to that IT block/test. Unless you clear the mock or use the ONCE variant of the methods these mocks bleed onto subsequent tests.

Wrong ❌

function mockGetNetworkDetailsDefault() {
return {
network: 'wifi',
ip: '1.1.1.1',
};
}

it('should get proper snapshots when network details exists', () => {
getNetworkDetailsProps.mockReturnValue(mockGetNetworkDetailsDefault());
const props = getNetworkDetailsProps();
const snap = shallow(<NetworkDetails {...props} />);
expect(snap).toMatchSnapshot();
});

// first mock is still carried over into this
it('should get snapshots with network details as NA', () => {
const props = getNetworkDetailsProps({
networkName: '',
});
const snap = shallow(<NetworkDetails {...props} />);
expect(snap).toMatchSnapshot();
});

Right ✅ ✅

// now this mock is restricted to this test
it('should get proper snapshots when network details exists', () => {
getNetworkDetailsProps.mockReturnValueOnce(mockGetNetworkDetailsDefault());
const props = getNetworkDetailsProps();
const snap = shallow(<NetworkDetails {...props} />);
expect(snap).toMatchSnapshot();
});

Right ✅

// resets mocks after each test
afterEach(() => {
jest.clearAllMocks();
});

Don’t Just Do Snapshot Tests

Once i saw this test written by a newbie, it was full of snapshots for various inputs. Snapshots are needed for only the full positive case. For the rest, test the exact thing you need to test. Snapshots are there to catch any developer wrongly altering something, not to test your methods

Wrong ❌

describe("PercentageText", () => {
it("should match percentageText snapshot when value = null", () => {
const snap = shallow(<PercentageText val={null} />);
expect(snap).toMatchSnapshot();
});

it("should match percentageText snapshot when value = 10", () => {
const snap = shallow(<PercentageText val={10} />);
expect(snap).toMatchSnapshot();
});
});

Right ✅

describe("PercentageText", () => {
it("should return empty if percentageText is null", () => {
const snap = shallow(<PercentageText val={null} />);
expect(snap.text()).toBe("");
});

//snapshot only for full happy path
it("should match snapshot", () => {
const snap = shallow(<PercentageText val={10} />);
expect(snap).toMatchSnapshot();
});
});

No Full Render when matching snapshots

While testing a snapshot never do a full render. This goes back to the previous point of mocking everything. You don’t want your snaps to change whenever one of the deeply nested children changes. This will keep your snaps smaller. More importantly your snap now closely matches the component you are testing. This makes it very easy to catch unintended changes and mistakes

Wrong ❌

it('should match snapshot with data', () => {
const snap = TestRenderer.create(<PercentageText val={10} />).toJSON();
expect(snap).toMatchSnapshot();
});

Wrong ❌

it('should match snapshot with data', () => {
const snap = mount(<PercentageText val={10} />);
expect(snap).toMatchSnapshot();
});

Right ✅

it('should match snapshot with data', () => {
const snap = shallow(<PercentageText val={10} />);
expect(snap).toMatchSnapshot();
});

Write proper ITs & Describes

The least importance we pay while writing UTs is to focus on the IT/Describe statements we write aka what the test does aka Test Name. It may be fine for the moment but if a future developer (could even be you yourself) looks at this he has no clue what you intended to test. More importantly, when tests fail in CI/CD pipelines you may not know which one failed when many tests have same/similar Test Names.

Wrong ❌

it("should not return empty if percentageText is 10", () => {
const snap = shallow(<PercentageText val={10} />);
expect(snap).toMatchSnapshot();
});

Right ✅

// this should be the right IT statement for the above UT
it("should match percentageText snapshot when non null value", () => {
const snap = shallow(<PercentageText val={10} />);
expect(snap).toMatchSnapshot();
});

Right ✅

// or change the test to match IT statement
it("should not return empty if percentageText is non null value", () => {
const snap = shallow(<PercentageText val={10} />);
expect(snap.text()).not.toBe("");
});

Wrap an IT in minimum 2 Describes

This is again one more area we pay the least importance to. Always wrap your ITs in two Describe blocks. One for the class/file being tested and the second for the method being tested. When the tests fail in pipelines it will be a breeze to locate what exactly failed. Also this logically groups all your tests

Wrong ❌

// start of the file
import { shallow, ShallowWrapper } from 'enzyme';
import * as React from 'react';
import { PercentageText, getNetworkDetails} from './file.ts';

it('should match snapshot with data', () => {
const snap = shallow(<PercentageText val={10} />);
expect(snap).toMatchSnapshot();
});

it('it should return proper network', () => {
const result = getNetworkDetails();
expect(result.network).toBe ('wifi');
});

Wrong ❌

describe("File", () => {
it('should match snapshot with data', () => {
const snap = shallow(<PercentageText val={10} />);
expect(snap).toMatchSnapshot();
});

it('it should return proper network', () => {
const result = getNetworkDetails();
expect(result.network).toBe ('wifi');
});
});

Right ✅

describe("File", () => {
describe("PercentageText", () => {
it('should match snapshot with data', () => {
const snap = shallow(<PercentageText val={10} />);
expect(snap).toMatchSnapshot();
});
});

describe("getNetworkDetails", () => {
it('it should return proper network', () => {
const result = getNetworkDetails();
expect(result.network).toBe ('wifi');
});
});
});

Few more that dont really fit the right/wrong structure.

  1. Using inline snapshots is an indirect way to ensure your component is smaller.
  2. Write Test Names in one of the below formats
    “Expect the method to ……“
    “ Should return ……”

Want us to write more

Hit claps if you liked it. It will encourage us to write more. Follow, for more posts. Comment below if you have any other suggestions or inputs or if you too have any more best practices.

--

--