DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on

5. Mocking usePathName, useSearchParams and useRouter with Jest in Next 15

In previous parts we saw how to use and test the async searchParams prop in a route page component. Since we're working with searchParams I also wanted to demonstrate how to test a file that uses the useSearchParams, usePathname and useRouter hooks.

Note: this code is available in a github repo.

<ListControles />

This is the component we will be testing:

// src/components/ListControles.tsx

'use client';

import validateSortOrder from '@/lib/validateSortOrder';
import { SortOrderT } from '@/types/SortOrderT';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';

export default function ListControles() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const router = useRouter();

  // get validated sortOrder value
  const sortOrder = validateSortOrder(searchParams.get('sortOrder'));

  function handleSort(val: SortOrderT) {
    const newParams = new URLSearchParams(searchParams);
    // note: this is incorrect (explanation later), should be
    // const newParams = new URLSearchParams(searchParams.toString());
    newParams.set('sortOrder', val);
    router.push(`${pathname}?${newParams.toString()}`);
  }

  return (
    <div>
      <div className='mb-2'>current sort order: {sortOrder}</div>
      <div className='flex gap-1'>
        <button
          className='bg-blue-700 text-white py-1 px-4 rounded-sm'
          onClick={() => handleSort('asc')}
        >
          sort ascending
        </button>
        <button
          className='bg-blue-700 text-white py-1 px-4 rounded-sm'
          onClick={() => handleSort('desc')}
        >
          sort descending
        </button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

It's basically just 2 buttons. When you click them it takes the current url and changes or adds the searchParam sortOrder=asc or sortOrder=desc and then pushes the new url to router.

original url  -> localhost:3000/list?sortOrder=asc
new url       -> localHost:3000/list?sortOrder=desc
Enter fullscreen mode Exit fullscreen mode

There is also a text displaying the current sortOrder value.

<div className='mb-2'>current sort order: {sortOrder}</div>
Enter fullscreen mode Exit fullscreen mode

TLDR;

If you are just looking for code snippets, skip to the bottom. However, be warned, I ran into a lot of issues and walk you through them. Pure copy/paste might not suit your specific need.

Setting up our test

For now, we will setup a basic test without mocking any of the hooks. Since we also want to test button clicks we will need @testing-library/user-event so let's quickly install that:

npm i -D @testing-library/user-event
Enter fullscreen mode Exit fullscreen mode

We proceed by putting up an initial testing file:

// src/components/__tests__/ListControles.test.tsx
// Errors for now

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

import ListControles from '../ListControles';
import validateSortOrder from '@/lib/validateSortOrder';
jest.mock('@/lib/validateSortOrder');

function setup() {
  (validateSortOrder as jest.Mock).mockReturnValue('asc');
  render(<ListControles />);
  const buttonAsc = screen.getByRole('button', { name: /sort ascending/i });
  const buttonDesc = screen.getByRole('button', { name: /sort descending/i });
  return { buttonAsc, buttonDesc };
}

describe('<ListControles /> component', () => {
  // error
  test('It renders', () => {
    const { buttonAsc, buttonDesc } = setup();
    expect(screen.getByText(/current sort order: asc/i)).toBeInTheDocument();
    expect(buttonAsc).toBeInTheDocument();
    expect(buttonDesc).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

We wrote the basics of our test file here. Except for our hooks, we have all the imports, we already mocked validateSortOrder and we have a setup function so we don't have to write the queries every time.

We also wrote a simple first test it renders that checks if the text and buttons are in the document. Great, but if fails:

invariant expected app router to be mounted
Enter fullscreen mode Exit fullscreen mode

This is as expected, we are running a Jest test so there is no window or router available. So, we need to do some mocking.

jest.mock

Let's import all our hooks and mock them:

import { usePathname, useSearchParams, useRouter } from 'next/navigation';
jest.mock('next/navigation');
Enter fullscreen mode Exit fullscreen mode

Our test still errors but now we have a different error:

TypeError: Cannot read properties of undefined (reading 'get')

> 13 |   const sortOrder = validateSortOrder(searchParams.get('sortOrder'));
Enter fullscreen mode Exit fullscreen mode

Using jest.mock with the pathname/package is what's called a Jest automatic mock.

What jest.mock does is take the package and mock every export it has. Every function and hook that next/navigation returns is now mocked.

return values from next/navigation

This means that we can now use Jest matchers and helpers like .toHaveBeenCalled(). So for example this would now work:

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

But it also means that these hooks don't do anything anymore. They have no body and they don't return anything (undefined).

In our <ListControles /> component, this code runs:

const searchParams = useSearchParams();
// ...
const sortOrder = validateSortOrder(searchParams.get('sortOrder'));
Enter fullscreen mode Exit fullscreen mode

But since we mocked useSearchParams, searchParams doesn't have the get method anymore and that is our current error we're facing:

TypeError: Cannot read properties of undefined (reading 'get')
Enter fullscreen mode Exit fullscreen mode

mocking useSearchParams in Next 15

So, we need to fix this. We need to return something from our useSearchParams mock. But what? useSearchParams returns a URLSearchParams interface that gives us access to a whole bunch of properties like set, get, has, ... We only need the get method.

We could just create a new URLSearchParams interface, pass it mock searchParams (f.e. { sortOrder: 'asc' }) and return that from useSearchParams mock. That might work but there's a problem with it. I would not enable us to listen for the searchParams.get method to have been called. And that is something we do want to test.

We actually need the get method to be mocked or to be a mock. So let's return an object from useSearchParams. On this object we put a get property with a Jest mock as value:

(useSearchParams as jest.Mock).mockReturnValue({
  get: jest.fn(),
});
Enter fullscreen mode Exit fullscreen mode

Good news, all our error are gone. Our it renders test passes! Yay. But, let's add a new test.

test('It correctly sets sortOrder', () => {
  setup();
  expect(get).toHaveBeenCalled();
});
Enter fullscreen mode Exit fullscreen mode

Error:

ReferenceError: get is not defined
Enter fullscreen mode Exit fullscreen mode

This should make sense, we don't have direct access to the get mock because it only exists (is scoped) inside the return value from the useSearchParams mock.

The solution is quite simple, create a get mock first and then pass it:

const getMock: jest.Mock = jest.fn();
(useSearchParams as jest.Mock).mockReturnValue({
  get: getMock,
});
Enter fullscreen mode Exit fullscreen mode

And update the test:

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

And everything passes. Let's finish the rest of the current test:

// passes

test('It correctly sets sortOrder', () => {
  setup();
  expect(getMock).toHaveBeenCalled();
  expect(validateSortOrder).toHaveBeenCalled();
  expect(screen.getByText(/current sort order: asc/i)).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Note that we could also mock a return value from getMock but in our case that's useless.

toString() mock

Are we done with mocking useSearchParams? No. If we look into our component, we use it a second time in our handleSort function:

function handleSort(val: SortOrderT) {
  // note: incorrect, will be fixed later
  const newParams = new URLSearchParams(searchParams);
  newParams.set('sortOrder', val);
  router.push(`${pathname}?${newParams.toString()}`);
}
Enter fullscreen mode Exit fullscreen mode

So we pass searchParams into a new URLSearchParams interface:

const newParams = new URLSearchParams(searchParams);
Enter fullscreen mode Exit fullscreen mode

I explained this in part 1 of this series. The useSearchParams hook returns a readonly URLSearchParams interface. But we need to update it (with a new sortOrder) so readonly doesn't work for us.

!!! NOTE: following paragraph is incorrect, we 'discover' this in a bit !!!

So we manually create a new URLSearchParams and pass the old readonly URLSearchParams into it. The new one will then call the toString method on the old one to receive it's searchParam values.

This means, we need to also create a toString method on our useSearchParams hook. We already know how to do this because we already did the get method:

const getMock: jest.Mock = jest.fn();
const toStringMock: jest.Mock = jest.fn();
(useSearchParams as jest.Mock).mockReturnValue({
  get: getMock,
  toString: toStringMock,
});
Enter fullscreen mode Exit fullscreen mode

Note: we only need get and toString, if you need more methods, this is how you add those.

We now have a toStringMock but we will come back to that later because there is no way to test this for now.

testing handleSort

The only thing left to test now is the handleSort function that gets triggered by button clicks. We can simulate button clicks with user-event. Let's write a new test:

test('It calls router.push with "sortOrder=asc" when "sort ascending" button is clicked', async () => {
  const user = userEvent.setup();
  const { buttonAsc } = setup();
  await user.click(buttonAsc);
});
Enter fullscreen mode Exit fullscreen mode

This is just the setup needed to have our component fire the handleSort function. Right now, it errors:

TypeError: Cannot read properties of undefined (reading 'push')
Enter fullscreen mode Exit fullscreen mode

mocking useRouter in Next 15

Pretty much the same problem we had with the get method on useSearchParams. Remember, useRouter itself has already been mock by the Jest automatic mock:

import { usePathname, useSearchParams, useRouter } from 'next/navigation';
Enter fullscreen mode Exit fullscreen mode

But, this means that it now returns nothing. So, we need to create a return from the useRouter mock with a push method on it:

// mock useRouter
const routerPushMock: jest.Mock = jest.fn();
(useRouter as jest.Mock).mockReturnValue({
  push: routerPushMock,
});
Enter fullscreen mode Exit fullscreen mode

And the error is gone. But we haven't asserted anything in our latest test. We need to somehow test this:

function handleSort(val: SortOrderT) {
  // still incorrect
  const newParams = new URLSearchParams(searchParams);
  newParams.set('sortOrder', val);
  router.push(`${pathname}?${newParams.toString()}`);
}
Enter fullscreen mode Exit fullscreen mode

The only way to test this is to listen what our routerPushMock has been called with. There are 2 problems with that:

  1. newParams.toString() doesn't return anything (it's a mock with no return value)
  2. usePathname mock doesn't return anything either.

This makes it rather unpredictable what routerPushMock has been called with. Let's add an assertion:

// don't actually do this
expect(routerPushMock).toHaveBeenCalledWith('dunno');
Enter fullscreen mode Exit fullscreen mode

Obviously this will fail but Jest will actually tell us what it got called with:

Expected: 'dunno';
Received: 'undefined?get=function+%28%29+%7B%0A++++++++return+fn.apply%28this%2C+arguments%29%3B%0A++++++%7D&toString=function+%28%29+%7B%0A++++++++return+fn.apply%28this%2C+arguments%29%3B%0A++++++%7D&sortOrder=asc';
Enter fullscreen mode Exit fullscreen mode

This surprised me. It looks like our entire mocked object was converted to a string.

URLSearchParams

I had a long look into this and finally found the problem. Our test revealed an error in our original component code! Yay, that is what testing is for!

We have this line of code in our component:

const newParams = new URLSearchParams(searchParams);
Enter fullscreen mode Exit fullscreen mode

We create a new URLSearchParams interface by passing in the ReadonlyURLSearchParams that useSearchParams returns. But that seems to be incorrect.

The docs are unclear but URLSearchParams takes as possible arguments:

  • a valid query string, f.e. ?sortOrder=asc (not a full url!)
  • an object, f.e. { sortOrder: 'asc' }
  • URLSearchParams.toString(), notice the .toString() method

And that was causing the problem! So, we update our <ListPage /> component:

// incorrect
const newParams = new URLSearchParams(searchParams);
// correct
const newParams = new URLSearchParams(searchParams.toString());
Enter fullscreen mode Exit fullscreen mode

and rerun the test. Our test still fails but it makes sense now:

Expected: 'dunno';
Received: 'undefined?sortOrder=asc';
Enter fullscreen mode Exit fullscreen mode

Take a look at our handleSort function again:

function handleSort(val: SortOrderT) {
  // note the .toString()
  const newParams = new URLSearchParams(searchParams.toString());
  newParams.set('sortOrder', val);
  router.push(`${pathname}?${newParams.toString()}`);
}
Enter fullscreen mode Exit fullscreen mode

searchParams.toString() has been mocked with toStringMock and returns undefined for now. This means that newParams will have no params. On the next rule, we update newParam with sortOrder and the val argument asc, passed by the "sort ascending" button. We then push router with pathName (undefined) and our newParam.toString() which is sortOrder=asc.

If we temporarily update our test, everything passes:

// we're not done yet, don't do this
expect(routerPushMock).toHaveBeenCalledWith('undefined?sortOrder=asc');
Enter fullscreen mode Exit fullscreen mode

mocking usePathname in Next 15

We fill fix the pathname mock now. It's so simple. We already mocked usePathname itself. We only have to add a return value to the mock:

// add return value to usePathname mock
(usePathname as jest.Mock).mockReturnValue('example.com');
Enter fullscreen mode Exit fullscreen mode

And then update our test:

// passes
expect(routerPushMock).toHaveBeenCalledWith('example.com?sortOrder=asc');
Enter fullscreen mode Exit fullscreen mode

testing

The hard work is done. We mocked all our hooks and everything seems to work. Let's now fine tune our tests.

Our last test looks like this:

// passes
test('It calls router.push with "sortOrder=asc" when "sort ascending" button is clicked', async () => {
  const user = userEvent.setup();
  const { buttonAsc } = setup();
  await user.click(buttonAsc);
  expect(routerPushMock).toHaveBeenCalledWith('example.com?sortOrder=asc');
});
Enter fullscreen mode Exit fullscreen mode

We can do the same on the other button:

// passes
test('It calls router.push with "sortOrder=desc" when "sort descending" button is clicked', async () => {
  const user = userEvent.setup();
  const { buttonDesc } = setup();
  await user.click(buttonDesc);
  expect(routerPushMock).toHaveBeenCalledWith('example.com?sortOrder=desc');
});
Enter fullscreen mode Exit fullscreen mode

I also want to test if the current url searchParams are correctly overwritten with new values. There is no current url because we are running tests in Jest. So we have to fake current searchParams by mocking a return value from toStringMock.

// passes
test('It overwrites current searchParam "sortOrder=asc" when button "sort ascending" is clicked', async () => {
  const user = userEvent.setup();
  (toStringMock as jest.Mock).mockReturnValue('sortOrder=asc');
  const { buttonAsc } = setup();
  await user.click(buttonAsc);
  expect(routerPushMock).toHaveBeenCalledWith('example.com?sortOrder=asc');
});

// passes
test('It overwrites current searchParam "sortOrder=desc" when button "sort ascending" is clicked', async () => {
  const user = userEvent.setup();
  (toStringMock as jest.Mock).mockReturnValue('sortOrder=desc');
  const { buttonAsc } = setup();
  await user.click(buttonAsc);
  expect(routerPushMock).toHaveBeenCalledWith('example.com?sortOrder=asc');
});
Enter fullscreen mode Exit fullscreen mode

As a final test, we test if it correctly preserves existing searchParams. Again, we have to mock (fake) current url search params.

// passes

test('It preserves existing searchParams (except sortOrder) when button "sort ascending" is clicked', async () => {
  const user = userEvent.setup();
  (toStringMock as jest.Mock).mockReturnValue('foo=bar&foo=baz');
  const { buttonAsc } = setup();
  await user.click(buttonAsc);
  expect(routerPushMock).toHaveBeenCalledWith(
    'example.com?foo=bar&foo=baz&sortOrder=asc'
  );
});
Enter fullscreen mode Exit fullscreen mode

And that's a wrap. I consider properly tested. For brevity, here's our full test file

// src/components/__test__/ListControles.test.js
// all tests pass

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

import ListControles from '../ListControles';
import validateSortOrder from '@/lib/validateSortOrder';
jest.mock('@/lib/validateSortOrder');

import { usePathname, useSearchParams, useRouter } from 'next/navigation';
jest.mock('next/navigation');

// add a return value to useSearchParams mock
const getMock: jest.Mock = jest.fn();
const toStringMock: jest.Mock = jest.fn();
(useSearchParams as jest.Mock).mockReturnValue({
  get: getMock,
  toString: toStringMock,
});

// add a return value to useRouter mock
const routerPushMock: jest.Mock = jest.fn();
(useRouter as jest.Mock).mockReturnValue({
  push: routerPushMock,
});

// add return value to usePathname mock
(usePathname as jest.Mock).mockReturnValue('example.com');

function setup() {
  (validateSortOrder as jest.Mock).mockReturnValue('asc');
  render(<ListControles />);
  const buttonAsc = screen.getByRole('button', { name: /sort ascending/i });
  const buttonDesc = screen.getByRole('button', { name: /sort descending/i });
  return { buttonAsc, buttonDesc };
}

describe('<ListControles /> component', () => {
  test('It renders', () => {
    const { buttonAsc, buttonDesc } = setup();
    expect(screen.getByText(/current sort order: asc/i)).toBeInTheDocument();
    expect(buttonAsc).toBeInTheDocument();
    expect(buttonDesc).toBeInTheDocument();
  });

  test('It correctly sets sortOrder', () => {
    setup();
    expect(getMock).toHaveBeenCalled();
    expect(validateSortOrder).toHaveBeenCalled();
    expect(screen.getByText(/current sort order: asc/i)).toBeInTheDocument();
  });

  test('It calls router.push with "sortOrder=asc" when "sort ascending" button is clicked', async () => {
    const user = userEvent.setup();
    const { buttonAsc } = setup();
    await user.click(buttonAsc);
    expect(routerPushMock).toHaveBeenCalledWith('example.com?sortOrder=asc');
  });

  test('It calls router.push with "sortOrder=desc" when "sort descending" button is clicked', async () => {
    const user = userEvent.setup();
    const { buttonDesc } = setup();
    await user.click(buttonDesc);
    expect(routerPushMock).toHaveBeenCalledWith('example.com?sortOrder=desc');
  });

  test('It overwrites current searchParam "sortOrder=asc" when button "sort ascending" is clicked', async () => {
    const user = userEvent.setup();
    (toStringMock as jest.Mock).mockReturnValue('sortOrder=asc');
    const { buttonAsc } = setup();
    await user.click(buttonAsc);
    expect(routerPushMock).toHaveBeenCalledWith('example.com?sortOrder=asc');
  });

  test('It overwrites current searchParam "sortOrder=desc" when button "sort ascending" is clicked', async () => {
    const user = userEvent.setup();
    (toStringMock as jest.Mock).mockReturnValue('sortOrder=desc');
    const { buttonAsc } = setup();
    await user.click(buttonAsc);
    expect(routerPushMock).toHaveBeenCalledWith('example.com?sortOrder=asc');
  });

  test('It preserves existing searchParams (except sortOrder) when button "sort ascending" is clicked', async () => {
    const user = userEvent.setup();
    (toStringMock as jest.Mock).mockReturnValue('foo=bar&foo=baz');
    const { buttonAsc } = setup();
    await user.click(buttonAsc);
    expect(routerPushMock).toHaveBeenCalledWith(
      'example.com?foo=bar&foo=baz&sortOrder=asc'
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

Summary

I am aware this is quite a messy article. On the one hand you have to be aware of the component we are trying to test. On the other hand we're writing tests, learning to mock the next/navigation hooks and constantly running into problems and issues. Maybe my explanations aren't that clear too.

But I do think that running into problems and learning how to solve them gives a much better understanding on how to test and use mocks.

As a little bonus I also wrote an integration test for the entire /list route. You can see it on github.

If you want to support my writing, you can donate with paypal.

Top comments (0)