DEV Community

Cover image for How to mock useSearchParams() and useRouter() with Jest in Next 13 (next/navigation)
Peter Jacxsens
Peter Jacxsens

Posted on • Edited on

How to mock useSearchParams() and useRouter() with Jest in Next 13 (next/navigation)

Here is our custom hook again:

// hooks/useSort.ts

import { SortOrder } from '@/types';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import getSortOrderFromUseSearchParams from '../lib/getSortOrderFromUseSearchParams';

export default function useSort() {
  const params = useSearchParams();
  const pathname = usePathname();
  const router = useRouter();
  const sortOrder = getSortOrderFromUseSearchParams(params);

  const handleSort = (value: SortOrder) => {
    const newParams = new URLSearchParams(params.toString());
    newParams.set('sortOrder', value);
    router.push(`${pathname}?${newParams.toString()}`);
  };

  return { sortOrder, handleSort };
}
Enter fullscreen mode Exit fullscreen mode

This hook does 2 things. It extracts sortOrder from our the query string. We tested the function that is responsible for this: getSortOrderFromUseSearchParams in the previous part.

Secondly, it returns a handleSort function. This function will be used as an onClick event handler for our sort buttons. It will be called as handleSort('asc') or handleSort('desc').

handleSort creates a new URLSearchParams object from the old readonly URLSearchParams object that useSearchParams provides us. It then sets sortOrder to asc or desc on this new URLSearchParams object, creates a route and pushes that to router.

Good news, this is actually not that hard to test. Bad news: there is a complication and we start with that.

Testing custom hooks

You cannot test a custom hook function in the same way you would test another function. The reason, custom hooks only work inside functional components.

So, in order to test our custom hooks, we have to call it inside a functional component, mount this component in our test and then perform our tests.

So, we write a functional component:

function TestComponent() {
  const { sortOrder, handleSort } = useSort();
  return (
    <>
      <h1>{sortOrder}</h1>
      <button onClick={() => handleSort('bbbb')}>sort</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

We render sortOrder inside an <h1>, this makes it easy to query and we attach handleSort to a button. And we are ready to test.

sortOrder

We have already tested the function getSortOrderFromUseSearchParams so we can simply mock it and then return a value from this mock. If we then mount our <TestComponent />, this return value should appear inside our <h1>. We will perform this test in a bit. This is also all the testing we need to do for sortOrder.

handleSort()

handleSort makes use of 3 hooks: useSearchParams(), usePathname() and useRouter(). Since we mount <TestComponent /> in jest-dom, we don't actually have a router, searchParams or a pathname. So we have to mock all of them. This is easy with Jest automatic mocking:

import { usePathname, useSearchParams, useRouter } from 'next/navigation';

jest.mock('next/navigation');
Enter fullscreen mode Exit fullscreen mode

We add a setup function and write our first test:

function setup() {
  render(<TestComponent />);
  const heading = screen.getByRole('heading', { level: 1 });
  const button = screen.getByRole('button');
  return { heading, button };
}
Enter fullscreen mode Exit fullscreen mode

Our setup function renders <TestComponent />, queries the <h1> and <button> and returns these. Our first test is this:

test('TestComponent renders', () => {
  const { heading, button } = setup();
  expect(useSearchParams).toHaveBeenCalled();
  expect(usePathname).toHaveBeenCalled();
  expect(useRouter).toHaveBeenCalled();
  expect(heading).toHaveTextContent('aaaa');
  expect(button).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

And it passes successfully. All the hooks we just mocked, got called. On top of that, our <h1> correctly displays the aaaa value we returned from our mock.

jest.mock('../../lib/getSortOrderFromUseSearchParams');
getSortOrderFromUseSearchParams.mockReturnValue('aaaa');
Enter fullscreen mode Exit fullscreen mode

In other words, we tested sortOrder as we said we would. But, now we run into problems because we actually need return values from our mocked hooks.

useSearchParams()

useSearchParams returns a URLSearchParams object. We already know how to mock this because we did it to test the getSortOrderFromUseSearchParams function. This means we already know how to mock useSearchParams.

useSort only calls one method: toString(). So, from the useSearchParams() mock we will return an object with this method. What this method returns depends on the test, so we will have to use our setup function for this:

function setup(toString = '') {
  useSearchParams.mockReturnValue({
    toString: () => toString,
  });
  render(<TestComponent />);
  const heading = screen.getByRole('heading', { level: 1 });
  const button = screen.getByRole('button');
  return { heading, button };
}
Enter fullscreen mode Exit fullscreen mode

We added a toString argument to setup with a default empty string value. The toString argument becomes the return value of the .toString() method on the object we return from of our useSearchParams mock. We will use this setup in later tests.

Looking at our handleSort function inside useSort:

const handleSort = (value: SortOrder) => {
  const newParams = new URLSearchParams(params.toString());
  newParams.set('sortOrder', value);
  router.push(`${pathname}?${newParams.toString()}`);
};
Enter fullscreen mode Exit fullscreen mode

You may be wondering: don't we also use the set() method? The answer is no. We have 2 URLSearchParams objects. One, returned from useSearchParams hook that we will mock and a second new one that we save into the newParams variable.

We do not need to mock this second one. (I'm not even sure we can!) Why not mock? Because we are not testing the URLSearchParams constructor. We assume it is stable and we just use it in the same way we don't mock useState or useEffect.

Also note that we mocked params.toString() but this does not mock newParams.toString().

usePathname()

usePathname() simply return a string: f.e. localhost/sortList. We only use it to construct a route. So for testing purposes, we will return a value example.com from it. We will use this in a bit in a test.

usePathname.mockReturnValue('example.com');
Enter fullscreen mode Exit fullscreen mode

useRouter()

We already mocked useRouter and successfully ran a test on it. But handleSort calls the router push method and we want to test that. This means we have to:

  • Add a mock for this push function.
  • Test if the mocked push function was called.
  • Test if the mocked push function was called with the correct route.

Right now, mocked useRouter return nothing (undefined). We need it to return a push method:

useRouter.mockReturnValue({
  push: jest.fn(),
});
Enter fullscreen mode Exit fullscreen mode

This setup would work. <TestComponent /> will be successfully rendered. But, there is a flaw. We don't have access to push. This test:

expect(push).toHaveBeenCalled();
Enter fullscreen mode Exit fullscreen mode

Would lead to this error: ReferenceError: push is not defined. Why is this? Because we don't have a reference to jest.fn(). It's like an anonymous function. The solution is simple. We create a reference to jest.fn() and pass that reference.

const pushMock = jest.fn();
jest.mock('next/navigation');
useRouter.mockReturnValue({
  push: pushMock,
});
Enter fullscreen mode Exit fullscreen mode

Now, we are able to test push by using pushMock:

expect(pushMock).toHaveBeenCalled();
Enter fullscreen mode Exit fullscreen mode

And that's it, we've returned values from all our mocks. Here is an overview of where we are at:

import { screen, render } from '@testing-library/react';

import useSort from '../useSort';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import getSortOrderFromUseSearchParams from '../../lib/getSortOrderFromUseSearchParams';

jest.mock('next/navigation');
const pushMock = jest.fn();

usePathname.mockReturnValue('example.com');
useRouter.mockReturnValue({
  push: pushMock,
});

jest.mock('../../lib/getSortOrderFromUseSearchParams');
getSortOrderFromUseSearchParams.mockReturnValue('aaaa');

function setup(toString = '') {
  useSearchParams.mockReturnValue({
    toString: () => toString,
  });
  render(<TestComponent />);
  const heading = screen.getByRole('heading', { level: 1 });
  const button = screen.getByRole('button');
  return { heading, button };
}

function TestComponent() {
  const { sortOrder, handleSort } = useSort();
  return (
    <>
      <h1>{sortOrder}</h1>
      <button onClick={() => handleSort('bbbb')}>sort</button>
    </>
  );
}

describe('hooks/useSort in TestComponent', () => {
  test('TestComponent renders', () => {
    const { heading, button } = setup();
    expect(useSearchParams).toHaveBeenCalled();
    expect(usePathname).toHaveBeenCalled();
    expect(useRouter).toHaveBeenCalled();
    expect(heading).toHaveTextContent('aaaa');
    expect(button).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

The only thing there is left to do is actually write our tests for handleSort. Let's look at handleSort one more time:

const handleSort = (value: SortOrder) => {
  const newParams = new URLSearchParams(params.toString());
  newParams.set('sortOrder', value);
  router.push(`${pathname}?${newParams.toString()}`);
};
Enter fullscreen mode Exit fullscreen mode

And here is the test:

test('handleSort calls router.push mock with the correct string', async () => {
  const user = userEvent.setup();
  const { button } = setup();
  await user.click(button);
  expect(pushMock).toHaveBeenCalledWith(`example.com?sortOrder=bbbb`);
});
Enter fullscreen mode Exit fullscreen mode

This test compiles everything we talked about. We simulate a button click in <TestComponent />. This will call: handleSort('bbbb'). We called our setup function with no argument. This means that the toString method will return an empty string. (This is the equivalent of a route with no parameters, f.e. localhost/sortList).

sortOrder passes this empty string into a new URLSearchParams and then adds parameter sortOrder with the value passed from handleSort: 'bbbb' to this new object.

Finally, the push method is called with a route:

router.push(`${pathname}?${newParams.toString()}`);
Enter fullscreen mode Exit fullscreen mode

We mocked the return value of usePathname to be 'example.com'. And our newParams.toString() will return sortOrder=bbbb. And that is what we expected pushMock to have been called with (don't forget ?):

expect(pushMock).toHaveBeenCalledWith(`example.com?sortOrder=bbbb`);
Enter fullscreen mode Exit fullscreen mode

We wrote 2 more tests. In the next test we return a parameter from the useSearchParams mock. This simulates this url: localhost/sortList?foo=bar. We then test if the new sortOrder parameter was correctly added.

test('handleSort adds our sortOrder parameter to existing parameters', async () => {
  const user = userEvent.setup();
  const { button } = setup('foo=bar');
  await user.click(button);
  expect(pushMock).toHaveBeenCalledWith(`example.com?foo=bar&sortOrder=bbbb`);
});
Enter fullscreen mode Exit fullscreen mode

In our last test, we return the value sortOrder=cccc from the useSearchParams hook. We want to test if it gets correctly overwritten. The test succeeds:

test('handleSort adds overwrites an existing sortOrder parameter', async () => {
  const user = userEvent.setup();
  const { button } = setup('sortOrder=cccc');
  await user.click(button);
  expect(pushMock).toHaveBeenCalledWith(`example.com?sortOrder=bbbb`);
});
Enter fullscreen mode Exit fullscreen mode

Here is our final full test file:

// hooks/__test__/useSort.test.js
import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import useSort from '../useSort';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import getSortOrderFromUseSearchParams from '../../lib/getSortOrderFromUseSearchParams';

jest.mock('next/navigation');
const pushMock = jest.fn();

usePathname.mockReturnValue('example.com');
useRouter.mockReturnValue({
  push: pushMock,
});

jest.mock('../../lib/getSortOrderFromUseSearchParams');
getSortOrderFromUseSearchParams.mockReturnValue('aaaa');

function setup(toString = '') {
  useSearchParams.mockReturnValue({
    toString: () => toString,
  });
  render(<TestComponent />);
  const heading = screen.getByRole('heading', { level: 1 });
  const button = screen.getByRole('button');
  return { heading, button };
}

function TestComponent() {
  const { sortOrder, handleSort } = useSort();
  return (
    <>
      <h1>{sortOrder}</h1>
      <button onClick={() => handleSort('bbbb')}>sort</button>
    </>
  );
}

describe('hooks/useSort in TestComponent', () => {
  test('TestComponent renders', () => {
    const { heading, button } = setup();
    expect(useSearchParams).toHaveBeenCalled();
    expect(usePathname).toHaveBeenCalled();
    expect(useRouter).toHaveBeenCalled();

    expect(heading).toHaveTextContent('aaaa');
    expect(button).toBeInTheDocument();
  });

  describe('handleSort function, returned from useSort works correctly', () => {
    test('It calls router.push mock with the correct string', async () => {
      const user = userEvent.setup();
      const { button } = setup();
      await user.click(button);
      expect(pushMock).toHaveBeenCalledWith(`example.com?sortOrder=bbbb`);
    });

    test('It adds our sortOrder parameter to existing parameters', async () => {
      const user = userEvent.setup();
      const { button } = setup('foo=bar');
      await user.click(button);
      expect(pushMock).toHaveBeenCalledWith(
        `example.com?foo=bar&sortOrder=bbbb`
      );
    });

    test('It adds overwrites an existing sortOrder parameter', async () => {
      const user = userEvent.setup();
      const { button } = setup('sortOrder=cccc');
      await user.click(button);
      expect(pushMock).toHaveBeenCalledWith(`example.com?sortOrder=bbbb`);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

To test custom hooks, you need to call them inside a functional component.

Mocking useSearchParams equals mocking a URLSearchParams object. You do this by creating an object and adding all the methods you need on it.

Finally, mocking useRouter is not that hard. Return an object with all the methods you need from the useRouter mock. To test these methods individually, you will need to manually create a mocking function for each method.

Top comments (3)

Collapse
 
jessicax941 profile image
Jessica Phua Shu Xin • Edited

Thanks for this article, it helped! And in case anyone else needs this for Typescript, the following worked for me:

const pushMock = jest.fn();
jest.mock("next/navigation");
(useRouter as unknown as jest.Mock).mockReturnValue({
    push: pushMock,
});
Enter fullscreen mode Exit fullscreen mode
Collapse
 
ichater profile image
Izaak Chater

You are an absolute life saver! I have been working "around" testing components with useRouter etc for weeks now.

Collapse
 
peterlidee profile image
Peter Jacxsens

Glad to hear it helped.