Paul Cowan Contract software developer

Testing the react-router useHistory Hook with React testing library

3 min read 871

React logo against a fire background.

React-router version 5 introduced a new family of Hooks that have simplified the process of making components route-aware.

useHistory does a great job of showcasing React Hooks by separating access to the react-router history object into a cross-cutting concern and replaces the previous cumbersome access via higher-order components or render-props that are now a thing of the past.

javascript
import { useHistory } from "react-router-dom";

function HomeButton() {
  let history = useHistory();

  function handleClick() {
    history.push("/home");
  }

  return (
    <button type="button" onClick={handleClick}>
      Go home
    </button>
  );
}

The new Hook makes life much more comfortable, but how do I write tests for this Hook? My initial googling brought back stackoverflow posts like this that advocate for using jest.mock.

javascript
jest.mock('react-router-dom', () => ({
  ...jest.requireActual('react-router-dom'),
  useHistory: () => ({
    push: jest.fn()
  })
}));

I try and avoid this approach at all costs since I want my tests to simulate real usage. jest.mock will blitz an essential component from the simulated reality and I might miss some critical regressions with this approach.

Instead, I am going to lean heavily on react router’s MemoryHistory.

Sample application

I have created this CodeSandbox:

This includes a simple Hook called useStepper that allows the user to navigate forward and back through several application steps:

A screenshot of a sample app.

Each forward or backward navigation uses the history object returned from useHistory to navigate to a new component at a new url:

We made a custom demo for .
No really. Click here to check it out.

javascript
export const useStepper = () => {
  const history = useHistory();

  const nextStepAction = useCallback(() => {
    setCurrentStep(index + 1);
  }, [getCurrentStepIndex]);

  const previousStepAction = useCallback(() => {
    setCurrentStep(index - 1);
  }, [getCurrentStepIndex]);

  useEffect(() => {
    const { path } = currentSteps[currentStep];

    history.push({
      pathname: path,
      state: { previousPath: history.location.pathname }
    });
  }, [currentStep, history]);

  // rest
};

There now follows a few easy steps to take control of useHistory without jest.mock.

Step 1: Centralize the history object

I centralize all access to the history object into a single export from one file located at src/history/index.ts:

javascript
import { createBrowserHistory, createMemoryHistory } from "history";
import { Urls } from "../types/urls";

const isTest = process.env.NODE_ENV === "test";

export const history = isTest
  ? createMemoryHistory({ initialEntries: ['/'] })
  : createBrowserHistory();

With this approach, I guarantee that all test and application code is dealing with the same history object.

I usually keep conditionals such as process.env.NODE_ENV === "test"; out of the application code, but I am making an exception in this case.

Step 2: Create a higher-order component to wrap any component under test in a Router

javascript
import { history } from "../history";
import React from "react";
import { render } from "@testing-library/react";
import { Router } from "react-router-dom";

export const renderInRouter = (Comp: React.FC) =>
  render(
    <Router history={history}>
      <Comp />
    </Router>
  );

renderInRouter is a simple function that takes a component and wraps it in a router. The critical thing to note here is the import in line 1:

javascript
import { history } from "../history";

As mentioned previously, this import will resolve to the central export that both the application code and test code now reference.

Step 3: Test components with useHistory

Testing components that use useHistory will now be easy with the two previous steps in place.

The useStepper.test.tsx test references the same history object as the application code to set up the test scenarios:

javascript
import React from "react";
import { history } from "../../history";
import { useStepper } from "./useStepper";
import { Urls } from "../../types/urls";
import { renderInRouter } from "../../tests";
import { renderHook, act } from "@testing-library/react-hooks";
import { ApplicationNavigator } from "../../Containers/Application";
import { screen, fireEvent } from "@testing-library/react";
import { Router } from "react-router-dom";

const render = () => renderInRouter(ApplicationNavigator);

describe("useStepper", async () => {
  beforeEach(() => {
    history.push(Urls.Home);
  });

  it("should go forward and should go back", async () => {
    render();

    const back = screen.getByText("BACK");
    const next = screen.getByText("NEXT");

    fireEvent.click(next);

    expect(history.location.pathname).toBe(Urls.About);

    fireEvent.click(next);

    expect(history.location.pathname).toBe(Urls.Start);

    fireEvent.click(back);

    expect(history.location.pathname).toBe(Urls.About);
  });
});

The render function will call the renderInRouter higher-order component and supply a component with routing for testing. The single history object that all code references is imported in the same way as the application code:

javascript
import { history } from "../../history";

Testing Hooks

The react-hooks-testing-library allows us to test Hooks in isolation with the renderHook function:

javascript

  it("should provide forward and backwards navigation", async () => {
    const { result } = renderHook(() => useStepper(), {
      wrapper: ({ children }) => (
        <>
          <Router history={history}>
            <ApplicationNavigator />
            {children}
          </Router>
        </>
      )
    });

    expect(result.current.currentStep).toBe(0);
    expect(result.current.cantGoBack).toBe(true);
    expect(result.current.cantProceed).toBe(false);

    await act(async () => {
      result.current.nextStepAction();
    });

    expect(result.current.currentStep).toBe(1);
    expect(result.current.cantGoBack).toBe(false);
    expect(result.current.cantProceed).toBe(false);

    expect(history.location.pathname).toBe(Urls.About);

    await act(async () => {
      result.current.previousStepAction();
    });

    expect(history.location.pathname).toBe(Urls.Home);
  });

The useStepper Hook can now be tested without being invoked from a specific component.

The vital thing to note is that a wrapper option is supplied to the renderHook function. We need to wrap the Hook in a Router to give the useStepper Hook the correct context to allow the test to flow.

Conclusion

jest.mock is something to be avoided in my experience and gives a false illusion to tests. With the approach outlined in this article, you won’t need to do this.

Happy testing!

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Paul Cowan Contract software developer

Leave a Reply