How to spy on `history` using `BrowserRouter` or `MemoryRouter`?

Viewed 951
react-router-dom@^5.1.0:
  version "5.1.2"

react@^16.12.0:
  version "16.12.0"

"@testing-library/react@^11.1.2":
  version "11.1.2"

history@^5.0.0:
  version "5.0.0"

Simplest example:

import React, {useEffect} from 'react';
import {render, act} from '@testing-library/react';
import {Router, useHistory, useLocation} from 'react-router-dom';
import {createMemoryHistory} from 'history';

test('Sample', async () => {
  const Component = () => {
    const location = useLocation();
    const history = useHistory();
    useEffect(() => {
      const timeoutId = setTimeout(() => {
        history.push('/abc');
      }, 100);
      return () => clearTimeout(timeoutId);
    }, []);
    console.log(history.location, location);
    return (
      <div>
        <div>{history.location.search}</div>
        <div>{location.search}</div>
      </div>
    );
  };

  const history = createMemoryHistory({
    initialEntries: [
      {
        pathname: '/',
        search: '',
      },
    ],
  });
  const pushFn = jest.spyOn(history, 'push');
  render(
    <Router history={history}>
      <Component />
    </Router>
  );
  expect(pushFn.mock.calls.length).toEqual(0);
  await act(() => new Promise(resolve => setTimeout(resolve, 125)));
  expect(pushFn.mock.calls.length).toEqual(1);
  expect(pushFn.mock.calls[0]).toEqual(['/abc']);
});

That console.log line, before history.push call, it prints out:

    { pathname: '/',
      search: '',
      hash: '',
      state: null,
      key: 'f2blf5di' } { pathname: '/',
      search: '',
      hash: '',
      state: null,
      key: 'f2blf5di' }

Ok, as expected.

Now, after history.push call, it becomes:

    { pathname: '/abc',
      search: '',
      hash: '',
      state: null,
      key: 'tz0v0qgf' } { action: 'PUSH',
      location:
       { pathname: '/abc',
         search: '',
         hash: '',
         state: null,
         key: 'tz0v0qgf' } }

So...that location object changes completely, but history.location still has the same object structure.

Using MemoryRouter or BrowserRouter fixes this, but I still need to spy on the history.push call as to make sure that there is only X number of pushes happened.

I need to do this check because some bugs might happen due to some useEffect in a component where it gets run multiple times and doing multiple history.push with the exact same parameters. Causing users having to hit browser back button (X + 1) times to get back to where they were.


Tried this as well, but history.goBack() doesn't change the location at all.

import React, {useEffect} from 'react';
import {render, act, screen, fireEvent} from '@testing-library/react';
import {BrowserRouter, useHistory, useLocation} from 'react-router-dom';
import window from 'global/window';

test('Sample', async () => {
  const Component = () => {
    const location = useLocation();
    const history = useHistory();
    useEffect(() => {
      const timeoutId = setTimeout(() => {
        history.push('/abc?foo=fooValue');
        history.push('/abc?foo=fooValue');
        history.push('/abc?foo=fooValue');
      }, 100);
      return () => clearTimeout(timeoutId);
    }, []);
    return (
      <div>
        <div>{history.location.search}</div>
        <div>{location.search}</div>
        {__DEV__ && <button onClick={() => history.goBack()}>back</button>}
      </div>
    );
  };

  render(
    <BrowserRouter>
      <Component />
    </BrowserRouter>
  );
  expect(window.location.pathname).toEqual('/');
  expect(window.location.search).toEqual('');
  await act(() => new Promise(resolve => setTimeout(resolve, 200)));
  fireEvent.click(screen.getByRole('button', {name: 'back'}));
  expect(window.location.pathname).toEqual('/abc');
  expect(window.location.search).toEqual('?foo=fooValue');
  fireEvent.click(screen.getByRole('button', {name: 'back'}));
  fireEvent.click(screen.getByRole('button', {name: 'back'}));
  fireEvent.click(screen.getByRole('button', {name: 'back'}));
  fireEvent.click(screen.getByRole('button', {name: 'back'}));
  fireEvent.click(screen.getByRole('button', {name: 'back'}));
  expect(window.location.pathname).toEqual('/');
  expect(window.location.search).toEqual('');
});

How do I use MemoryRouter or BrowserRouter and spy on the history.push call? Or maybe, how do I make Router to not change the location from useLocation object structure after history.push?


[UPDATE] 15 DEC 2020 13:00

After spending 2 days on this, I will just settle with this solution:

const memoryRouterRef: {current: any} = React.createRef<any>();
render(
  <MemoryRouter ref={memoryRouterRef}>
  ...
  </MemoryRouter>
);
...do some stuff...
expect(memoryRouterRef.current.history.location.pathname).toEqual(...);
expect(memoryRouterRef.current.history.location.search).toEqual(...);
expect(memoryRouterRef.current.history.length).toEqual(...);

memoryRouterRef.current.history.goBack(); // This works now!!

// We can now check the pathname / search to make sure user can use the browser back button properly
expect(memoryRouterRef.current.history.location.pathname).toEqual(...);
expect(memoryRouterRef.current.history.location.search).toEqual(...);

At the end of the day, I don't think this is a job for unit tests. It should just be e2e tests. For now though, this is enough for me.


Update Apr 11, 2022:

You can make it cleaner by doing this instead:

import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';

    const history = createMemoryHistory();
    render(
      <Router history={history}>
        ...
      </Router>
    );

Everything else is the same. You can still do expect(history.location.pathname).toEqual(...).

Some differences are instead of history.goBack(), you do history.back(). Just look at the history package documentation on what is available. The other one would be history.length. There is no length property. You should just check for the index instead, history.index.

0 Answers
Related