'How to test components using new react router hooks?

Until now, in unit tests, react router match params were retrieved as props of component. So testing a component considering some specific match, with specific url parameters, was easy : we just had to precise router match's props as we want when rendering the component in test (I'm using enzyme library for this purpose).

I really enjoy new hooks for retrieving routing stuff, but I didn't find examples about how to simulate a react router match in unit testing, with new react router hooks ?



Solution 1:[1]

Edit: The proper way of doing this the way described in Catalina Astengo's answer as it uses the real router functionality with just the history/routing state mocked rather than mocking the entire hook.

The way I ended up solving it was by mocking the hooks in my tests using jest.mock:

// TeamPage.test.js
jest.mock('react-router-dom', () => ({
  ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
  useParams: () => ({
    companyId: 'company-id1',
    teamId: 'team-id1',
  }),
  useRouteMatch: () => ({ url: '/company/company-id1/team/team-id1' }),
}));

I use jest.requireActual to use the real parts of react-router-dom for everything except the hooks I'm interested in mocking.

Solution 2:[2]

I looked at the tests for hooks in the react-router repo and it looks like you have to wrap your component inside a MemoryRouter and Route. I ended up doing something like this to make my tests work:

import {Route, MemoryRouter} from 'react-router-dom';

...

const renderWithRouter = ({children}) => (
  render(
    <MemoryRouter initialEntries={['blogs/1']}>
      <Route path='blogs/:blogId'>
        {children}
      </Route>
    </MemoryRouter>
  )
)

Hope that helps!

Solution 3:[3]

In your component use hooks as below

import {useLocation} from 'react-router';

const location = useLocation()

In your test spy on reactRouter Object as below

import routeData from 'react-router';

const mockLocation = {
  pathname: '/welcome',
  hash: '',
  search: '',
  state: ''
}
beforeEach(() => {
  jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation)
});

Solution 4:[4]

If you're using react-testing-library for testing, you can get this mock to work like so.

jest.mock('react-router-dom', () => ({
    ...jest.requireActual('react-router-dom'),
    useLocation: () => ({ state: { email: '[email protected]' } }),
}));

export const withReduxNRouter = (
    ui,
    { store = createStore(rootReducer, {}) } = {},
    {
    route = '/',
    history = createMemoryHistory({ initialEntries: [ route ] }),
    } = {}
) => {
    return {
    ...render(
        <Provider store={store}>
        <Router history={history}>{ui}</Router>
        </Provider>
    ),
    history,
    store,
    };
};

You should have mocked react-router-dom before it has been used to render your component. I'm exploring ways to make this reusable

Solution 5:[5]

I am trying to get if the push function in useHistory is called by doing that but I can't get the mocked function calls...

const mockHistoryPush = jest.fn();

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

fireEvent.click(getByRole('button'));
expect(mockHistoryPush).toHaveBeenCalledWith('/help');

It says that mockHistoryPush is not called when the button has onClick={() => history.push('/help')}

Solution 6:[6]

My use case was unit testing a custom hook using using useLocation(). I had to override the inner properties of useLocation which was read-only.


\\ foo.ts

export const useFoo = () => {

   const {pathname} = useLocation();


\\ other logic

return ({
          \\ returns whatever thing here
       });
}

/*----------------------------------*/

\\ foo.test.ts

\\ other imports here

import * as ReactRouter from 'react-router';


Object.defineProperty(ReactRouter, 'useLocation', {
   value: jest.fn(),
   configurable: true,
   writable: true,
});

describe("useFoo", () => {


       it(' should do stgh that involves calling useLocation', () => {

           const mockLocation = {
               pathname: '/path',
               state: {},
               key: '',
               search: '',
               hash: ''
           };


         const useLocationSpy =  jest.spyOn(ReactRouter, 'useLocation').mockReturnValue(mockLocation)



          const {result} = renderHook(() => useFoo());
         
           expect(useLocationSpy).toHaveBeenCalled();


       });
 });

Solution 7:[7]

A slight variation of the above solutions which includes several params and query strings for a more complex scenario. This is easy to abstract into a utility function similar to a few above which can be reused by other tests.

short version

      <MemoryRouter
        initialEntries={[
          '/operations/integrations/trello?business=freelance&businessId=1&pageId=1&pageName=Trello',
        ]}
      >
        <Route path="/operations/:operation/:location">
          <OperationPage />
        </Route>
      </MemoryRouter>

Longer version:

The example snippets below include a full example of the test file, component and logs to help leave little room for interpretation.

includes:

  • react 16
  • redux 7
  • react-router-dom 5
  • typescript
  • thunk
  • sagas
  • @testing-library/react 11

operations.spec.tsx

import React from 'react'
import { MemoryRouter, Route } from 'react-router-dom'
import { render, screen } from '@testing-library/react'
import { Provider } from 'react-redux'
import { createStore, applyMiddleware, compose } from 'redux'
import createDebounce from 'redux-debounced'
import thunk from 'redux-thunk'
import createSagaMiddleware from 'redux-saga'
import rootReducer from 'redux/reducers/rootReducer'
import OperationPage from '../operation'
import { initialState } from '../mock'
import '@testing-library/jest-dom' // can be moved to a single setup file

const sagaMiddleware = createSagaMiddleware()
const middlewares = [thunk, sagaMiddleware, createDebounce()]
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose

const store = createStore(
  rootReducer,
  // any type only until all reducers are given a type
  initialState as any,
  composeEnhancers(applyMiddleware(...middlewares))
)

const Wrapper: React.FC = ({ children }) => <Provider store={store}>{children}</Provider>

describe('Operation Page - Route', () => {
  it('should load', async () => {

    const Element = () => (
      <MemoryRouter
        initialEntries={[
          '/operations/integrations/trello?business=freelance&businessId=1&pageId=1&pageName=Trello',
        ]}
      >
        <Route path="/operations/:operation/:location">
          <OperationPage />
        </Route>
      </MemoryRouter>
    )
    render(<Element />, { wrapper: Wrapper })
    // logs out the DOM for further testing
    screen.debug()
  })
})

logs and the component via operations.tsx. Got lazy including the all types (via typescript) for this component but outside of scope :)

import React from 'react'
import { useParams, useLocation } from 'react-router-dom'
import { connect } from 'react-redux'
import queryString from 'query-string'

const OperationPage = (): JSX.Element => {
  const { search } = useLocation()
  const queryStringsObject = queryString.parse(search)
  const { operation, location } = useParams<{ operation: string; location: string }>()

  console.log(
    '>>>>>queryStringsObject',
    queryStringsObject,
    '\n search:',
    search,
    '\n operation:',
    operation,
    '\n location:',
    location
  )
  return <div>component</div>
}

const mapStateToProps = (state) => {
  return {
    test: state.test,
  }
}

export default connect(mapStateToProps, {})(OperationPage)

terminal where the tests are running

>>>>>queryStringsObject [Object: null prototype] {
  business: 'freelance',
  businessId: '1',
  pageId: '1',
  pageName: 'Trello'
}
 search: ?business=freelance&businessId=1&pageId=1&pageName=Trello
 operation: integrations
 location: trello


 PASS  src/__tests__/operations.spec.tsx
  Operation Page - Route
    ? should load (48 ms)

Test Suites: 1 passed, 1 total
Tests:       0 skipped, 1 passed, 1 total
Snapshots:   0 total
Time:        2.365 s
Ran all test suites related to changed files.

Solution 8:[8]

If using the enzyme library, I found a much less verbose way to solve the problem (using this section from the react-router-dom docs):

import React from 'react'
import { shallow } from 'enzyme'
import { MemoryRouter } from 'react-router-dom'
import Navbar from './Navbar'

it('renders Navbar component', () => {
  expect(
    shallow(
      <MemoryRouter>
        <Navbar />
      </MemoryRouter>
    )
  ).toMatchSnapshot()
})

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Spatz
Solution 3 Mario Petrovic
Solution 4 autopoietic
Solution 5 Albert Alises
Solution 6 exaucae
Solution 7
Solution 8 George