An approach to routing testing in React

In React applications, there are many ways for testing routing behavior in our components. You might find solutions on the internet, but they probably will depend exclusively on your current routing library.

After struggling to test routing behavior, I came up with a personal solution that should help in most cases. In this post, we’ll cover it using typescript and react-router-dom. For testing, we’ll use react-testing-library with jest.

Getting Started

Let’s imagine a common scenario for a component that uses routing functions. In this case, we are using the react-router-dom library directly.

import { useHistory, useParams } from 'react-router-dom';

interface RouteParams {
    productId: string;
}

export function Product() {
    const history = useHistory();
    const params = useParams<RouteParams>();

    const productId = params.productId;
    const goBack = history.goBack;
    const goToDetails = () => history.push(
        Routes.product.details(productId)
    );

    return (
        <div>
            <p>product id: {productId}</p>

            <button onClick={goBack}>go back</button>
            <button onClick={goToDetails}>go to details</button>
        </div>
    );
}

As you can see it has some simple features like using a param from the route, navigating to a specific page, and going back to the previous page. If we need to change the main routing library, we would have to do that manually, refactoring every component that uses routing features. Also, if the library changes some syntax or behavior, we would be forced to refactor it.

Strategy

The strategy is to create an abstraction between our application and the routing library. This way, inverting the dependencies and ensuring we won’t struggle when we need to refactor or change libraries for some reason.

We’ll use a single hook for handling both route parameters and navigation. The hook implementation depends on which routing library you’re using, but the return (public interface) must be the same for every case.

Example with react-router-dom:

import { useHistory, useParams } from 'react-router-dom';

export type Router<TParams> = {
    params: TParams;

    goBack: () => void;
    openPage: (page: string) => void;
};

export const useRouter = <TParams = undefined>(): Router<TParams> => {
    const params = useParams<TParams>();
    const history = useHistory();

    return {
        params,
        goBack: () => history.goBack(),
        openPage: (page) => history.push(page),
    };
};

Example with next/router:

import { useRouter as useNextRouter } from 'next/router';

export type Router<TParams> = {
    params: TParams;

    goBack: () => void;
    openPage: (page: string) => void;
};

export const useRouter = <TParams = undefined>(): Router<TParams> => {
    const router = useNextRouter();

    return {
        params: router.query as TParams,
        goBack: () => router.back(),
        openPage: (page) => router.push(page),
    };
};

As you can see, both libraries have different syntaxes but have the same behavior.

Refactoring the Component

As we define our routing hook, we have to use it in the whole project. We will start refactoring our example component so it can use our handmade hook.

interface RouteParams {
    productId: string;
}

export function Product() {
    const router = useRouter<RouteParams>();

    const productId = router.params.productId;
    const goBack = router.goBack;
    const goToDetails = () => router.openPage(
        Routes.product.details(productId)
    );

    return (
        <div>
            <p>product id: {productId}</p>

            <button onClick={goBack}>go back</button>
            <button onClick={goToDetails}>go to details</button>
        </div>
    );
}

Note that it doesn’t matter which library we’re using. The usage and the syntax are the same.

Testing Setup

We’ll use jest.mock to mock our custom hook and create a helper. This helper allows us to reuse it in every test suite that requires a routing test.

First, we’ll have to override the default return from useRouter so we can mock his return. It’s easy to do that if the function is already a mock. We only need to call mockReturnValue with the expected return.

const setJestMockFor = <T>(router: Router<T>) =>
    (useRouter as jest.Mock<Router<T>>).mockReturnValue(router);

To mock our router, we’ll override it completely. The params can be changed, but goBack and openPage will be only jest.fn() without any behavior.

const makeRouter = <T>(params: T): Router<T> => {
    return {
        params,
        goBack: jest.fn(),
        openPage: jest.fn(),
    };
};

Putting it all together we will have mockUseRouter:

import { useRouter, Router } from "@/hooks/useRouter";

export const mockUseRouter = <T>(params: T = {} as T): Router<T> => {
    const router = makeRouter(params);
    setJestMockFor(router);

    return router;
};

Now we can control the whole implementation. By having access to the returned value from the hook we can assert our expectations in test cases.

Testing the Component

Since everything is good to go, we can start writing our tests. Let’s create a setup function that will help us by creating all mocks and rendering the component.

import { Product } from "./Product";
import { mockUseRouter } from "@/mocks/useRouter";
import { waitFor, render } from "@testing-library/react";

jest.mock("@/hooks/useRouter");

describe(Product, () => {
    const renderProduct = () => render(<Product />);

    function setUp<T>(params?: T) {
        const router = mockUseRouter(params);
        const component = renderProduct();

        return {
            router,
            component,
        };
    }
});

Note that we need to use jest.mock before everything. This is required for jest to understand that we want to use a mock for useRouter instead of the real implementation.

We’ll start by testing if productId from route param is rendered. We can also create a helper function for getting specific DOM elements. We’ll do this for every element.

const getProductId = productId =>
    screen.getByText(`product id: ${productId}`);

In the test case, we call setUp with the routing parameters that we expect. Then we query for productId text with our mocked router params. Finally, check if it’s rendered (is on the document).

it('renders product id', () => {
    const { router } = setUp({ productId: "any-id" });

    const productIdText = getProductId(router.params.productId); 
    expect(productIdText).toBeInTheDocument();
});

Now it’s time to test if the go back button can successfully go back.

const getGoBackButton = () =>
    screen.getByRole("button", { name: "go back" });

Additionally, we’ll make a function for button clicking as well.

const clickOn = element =>
    userEvent.click(element);

For this test case, we don’t even need to mock the parameters. Simply click the button and then wait for the expectation (with waitFor from react-testing-library). Note that we are only able to properly use the toHaveBeenCalled matcher because router.goBack is a jest.fn.

it('goes back when button is clicked', async () => {
    const { router } = setUp();

    clickOn(getGoBackButton());

    await waitFor(() => {
        expect(router.goBack).toHaveBeenCalled();
    });
});

And the last test case is for checking the navigation to product details route. It is almost the same as the previous one, but in this case, we expect a specific param for router.openPage: the productId from the route.

const getGoToDetailsButton = () => 
    screen.getByRole("button", { name: "go to details" });
it('goes to details page when button is clicked', async () => {
    const { router } = setUp({ productId: "any-id" });

    clickOn(getGoToDetailsButton());

    await waitFor(() => {
        expect(router.openPage).toHaveBeenCalledWith(
            Routes.product.details(router.params.productId)
        );
    });
});

The final test suit:

import { Product } from "./Product";
import { mockUseRouter } from "@/mocks/useRouter";
import { waitFor, render, screen } from "@testing-library/react";

jest.mock("@/hooks/useRouter");

describe(Product, () => {
    const renderProduct = () => render();

    function setUp(params?: T) {
        const router = mockUseRouter(params);
        const component = renderProduct();

        return {
            router,
            component,
        };
    }

    const getProductId = productId =>
        screen.getByText(`product id: ${productId}`);

    const clickOn = element =>
        userEvent.click(element);

    const getGoBackButton = () =>
        screen.getByRole("button", { name: "go back" });

    const getGoToDetailsButton = () =>
        screen.getByRole("button", { name: "go to details" });

    it('renders product id', () => {
        const { router } = setUp({ productId: "any-id" });

        const productIdText = getProductId(router.params.productId);
        expect(productIdText).toBeInTheDocument();
    });

    it('goes back when button is clicked', async () => {
        const { router } = setUp();

        clickOn(getGoBackButton());

        await waitFor(() => {
            expect(router.goBack).toHaveBeenCalled();
        });
    });

    it('goes to details page when button is clicked', async () => {
        const { router } = setUp({ productId: "any-id" });

        clickOn(getGoToDetailsButton());

        await waitFor(() => {
            expect(router.openPage).toHaveBeenCalledWith(
                Routes.product.details(router.params.productId)
            );
        });
    });
});

Conclusion

Even though we haven’t covered every routing feature from the library, this implementation can be really useful.

This solution requires you to do some setup and probably some refactoring as well. Abstractions in general are good because you can wrap the implementation, then control the behavior the way you need.

We are hiring new talents. Do you want to work with us? become@codeminer42.com