Improve Test Coverage (80%+) in a React / Next.js App using — Jest and React Testing Library. (TypeScript)

Deep Kotecha
Engineering at Bajaj Health
6 min readSep 4, 2023

As a popular unit testing framework, Jest and React Testing Library have all the necessary tools to get you to 80% or more coverage. Let’s discuss all the possible methods and strategies to effectively gain coverage for a React / Next application. To start, let’s take a hard look at our core approach.

The Approach

The reason a lot of people struggle to get effective percentage gains in coverage is because they try to take a top-down approach, i.e., from the root node of the DOM tree to the leaf nodes. (Rookie Mistake)

In my experience, the best approach is to go the other way around — Bottom Up (Leaf to Root ) Approach. This helps as it allows us to focus and test all the atomic components and partial components first. Also, if the reusability of components is something your application follows, then this approach pays heavy dividends later on.

TOP-DOWN VS BOTTOM-UP , for the same effort

It is easier to get high coverage out of smaller components. When the modular component is rendered in the test case, significant gains are observed without any attempt at coverage.

This approach also helps in cases where parent function is called inside the child component. From the test Id for the child element in jsx, fire the parent function to expect suitable results.

Mocking Globally

Apart from jest config , a setup file is helpful to mock React and React DOM env. It can also be used to attach a mock service worker that helps in mocking API calls.

jest.setup.js

import React from "react";
import ReactDOM from "react-dom";
import "jest-canvas-mock";
import Worker from "<route>/worker.js";

window.Worker = Worker;

global.React = React;
global.ReactDOM = ReactDOM;

jest.config.js

module.exports = {
....
setupFilesAfterEnv: ["./jest.setup.js"],
....
}

An empty file-mock can be used to ensure that all media and css files are mocked. (jest may not support them in testing)

module.exports = {
....
setupFilesAfterEnv: ["./jest.setup.js"],
moduleNameMapper: {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<route>/file-mock.js",
"\\.(css|less|scss|sass)$": "<route>/file-mock.js",
}
....
}

Global Render with supports

export const testRenderer = (ui: React.ReactElement, options: RenderOptions = {}) => {
return render(ui, { wrapper: TestProviders, ...options, hydrate: true });
};

This function is important as , it allows us to create a common dynamic for jest to compile the JSX and with all the necessary wrappers.

export const TestProviders = ({ children }): JSX.Element => {
const queryClient = new QueryClient();
const [initialGlobalState, dispatch] = useReducer(yourReducer, ContextInitialData);
const yourProviderValue = useMemo(() => ({ initialGlobalState, dispatch }), [initialGlobalState, dispatch]);
return (
<QueryClientProvider client={queryClient}> // for react-query
<RouterContext.Provider value={createMockRouter({})}> // for next/router
<YourContextWrapper.Provider value={yourProviderValue}> // for your context or redux
<Hydrate state={{}}>{children}</Hydrate>
</YourContextWrapper.Provider>
</RouterContext.Provider>
</QueryClientProvider>
);
};

Eg — Next Router.

To mock next/router , we must create a mock object that can allow us to simulate the code level impact of Router.

export function createMockRouter(router: Partial<NextRouter>): NextRouter {
return {
basePath: "",
pathname: "",
route: "",
query: {},
asPath: "/",
back: jest.fn(),
beforePopState: jest.fn(),
prefetch: jest.fn(),
push: jest.fn(),
reload: jest.fn(),
replace: jest.fn(),
events: {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
isFallback: false,
isLocaleDomain: false,
isReady: true,
defaultLocale: "en",
domainLocales: [],
isPreview: false,
...router,
};
}

When we encounter a component where we must be able to manipulate the router code to ensure that we can extract all possible conditions for coverage.

describe("basic suite", () => {
it("basic test without router mock", () => {
const { getByTestId } = testRenderer(
<Component prop1={prop1value} prop2={prop2value} prop3={prop3value} />
);

// ... testing
});

it("basic test with router mock query", () => {
const mockFn = jest.fn();
const mockRouter = {
back: mockFn,
query: {
test: "query-value",
},
};
const { getByTestId } = testRenderer(
<RouterContext.Provider value={createMockRouter(mockRouter)}>
<Component prop1={prop1value} prop2={prop2value} prop3={prop3value} />
</RouterContext.Provider>
);

// ... testing
});
});

We can now simulate flows from a network request level and ensure all code dependent on it is performing as expected. This same is applied to all your preset Wrappers, you can overwrite them in the function itself and simulate every corner of your code.(Context and Redux specially). It is a good way to determine if there is any dead code.

Testing Custom Hooks

I have seen a lot of people , mock their custom hooks and avoid testing them all together, even ignore them in coverage. This is a bad practice as all the real testing needs to be on the Custom Hooks.

Pre-requisites

To Effectively test hooks in the same env as our UI components , we use the same wrapper of TestProviders.

export const hooksWrapper = ({ children }) => {
return (
<TestProviders>
<randomWrapper.Provider value={mockedValue}>
{children}
</randomWrapper.Provider>
</TestProviders>
);
};

Like all providers , you can overwrite all providers and their values for router, react-query, redux, context etc. in this wrapper

Basic Example

import { useState } from "react";

export const useCustomCounter = () => {
const [counter, setCounter] = useState<number>(0);

const incrementCounter = () => {
setCounter(counter + 1);
};

const decrementCounter = () => {
setCounter(counter - 1);
};

return {
counter,
incrementCounter,
decrementCounter,
};
};

The Test Case would be like :-

import { act, renderHook } from "@testing-library/react";
import { hooksWrapper } from "<route to wrapper>";
import { useCustomCounter } from "<route to hook>";

describe("Counter Test", () => {
it("counter increments successfully", () => {
const { result } = renderHook(() => useCustomCounter(), {
wrapper: hooksWrapper,
});
act(() => {
result?.current?.incrementCounter();
});
expect(result?.current?.counter).toStrictEqual(1);
});

it("counter decrements successfully", () => {
const { result } = renderHook(() => useCustomCounter(), {
wrapper: hooksWrapper,
});
act(() => {
result?.current?.decrementCounter();
});
expect(result?.current?.counter).toStrictEqual(-1);
});
});

How well we can test custom hooks determines whether we cross that 80% threshold comfortably.

Mock vs SpyOn

If you are working on a smaller module made by yourself with a select few exports then it makes sense to mock as you can do so easily by adding:

jest.mock("<route-to-api-call>", () => ({
fetchData1: jest.fn(() =>
Promise.resolve({
data: {
sections: {
sections: [],
},
},
})
),
fetchData2: jest.fn(() =>
Promise.resolve({
data: {
name: "test",
},
})
),
}));

However , if you are trying to mock a core function or one from a package that has too many exports , it is recommended to spy on the function.

import * as reactQuery from "react-query";
import React, { ForwardRefExoticComponent } from "react";

jest.spyOn(React, "forwardRef").mockImplementation(() => {
return {} as ForwardRefExoticComponent<RefAttributes<unknown>>;
}); //diasables forwardRef

jest.spyOn(reactQuery, "useQuery").mockImplementation(() => {
return {
isError: true,
} as reactQuery.UseQueryResult;
}); // returns useQuery response as error

Note : spyOn can be used inside or outside a test-suite and mock must be only used outside. Clear/reset all mocks if you wish to write multiple suites/cases in a file.

FireEvent Functions

Easiest way to simulate user activity with code is the fireEvent object.

Common usage :

Click —

const button = getByTestId("test-button");
fireEvent.click(button);
// ... testing

Change —

const inputElement = getByTestId("test-input");
fireEvent.change(inputElement, { value: "123456" });
// ... testing

KeyDown —

fireEvent.keyDown(getByTestId("custom-toast"), {
key: "Escape",
code: "Escape",
keyCode: 27,
charCode: 27,
});

This can be for cases where a modal / dialog from a UI library like material-ui (mui) needs to be closed or we want to test functions fired on user typing before change is made to input.(input-validations etc)

Final Tips, Tricks and Hacks to Improve Coverage.

  1. Prioritise Reusable Functions and Components first.
    These Functions will provide 100% coverage easily, and they will give full coverage to their parent on render. Get the most coverage out of your utility functions, like calculations and string manipulations, etc.
  2. Line coverage and Condition coverage are Equally Important. Exhaust all conditions and identify dead code; removing it may help improve coverage.
  3. Manual Testing and Unit Test Coverage are totally different. Success at one does not guarantee success at the other. i.e., manual cases may not always account for things like error handling and code-level inaccuracies. Integrate manual test cases at the code level in separate suites for ideal user conditions, while maintaining suites for robust testing at the code level.
  4. SonarQube can tell you if a line is covered partially, fully, or not at all, which the jest response on coverage in the terminal cannot.
  5. If all is done well, the modular and root components will only require rendering and basic checks.

--

--