Next.js: Как издеваться над useRouter?

Созданный на 1 июн. 2019  ·  21Комментарии  ·  Источник: vercel/next.js

Вопрос о Next.js

Я хочу убедиться, что мой компонент правильно отображается с помощью хука useRouter (на самом деле я пытаюсь понять, как работает новая динамическая маршрутизация), поэтому у меня есть код:

import React from 'react';
import { NextPage } from 'next';
import { useRouter } from 'next/router';

const UserInfo : NextPage = () => {
  const router = useRouter();
  const { query } = router;

  return <div>Hello {query.user}</div>;
};

export default UserInfo;

И я пытаюсь:

// test
import { render, cleanup, waitForElement } from '@testing-library/react';

import UserInfo from './$user';

// somehow mock useRouter for $user component!!!

afterEach(cleanup);

it('Should render correctly on route: /users/nikita', async () => {
  const { getByText } = render(<UserInfo />);

  await waitForElement(() => getByText(/Hello nikita!/i));
});

Но я получаю сообщение об ошибке TypeError: Cannot read property 'query' of null которое указывает на строку const router = useRouter(); .

PS Я знаю, что динамическая маршрутизация доступна на канареечных версиях только сейчас и может измениться, но у меня проблема с маршрутизатором, а не с функцией WIP (я?).

Самый полезный комментарий

Я закончил тем, что издевался над этим, мне нужен только экспорт useRouter так что он работал достаточно хорошо для моих целей:

jest.mock("next/router", () => ({
    useRouter() {
        return {
            route: "/",
            pathname: "",
            query: "",
            asPath: "",
        };
    },
}));

Все 21 Комментарий

Привет, эта функция все еще экспериментальная, но useRouter использует React.useContext для использования контекста из next-server/dist/lib/router-context . Чтобы издеваться над ним, вам нужно будет обернуть его в провайдере контекста оттуда, как в этой строке

@ijjk Привет, спасибо!
Не знаю, правильно ли я делаю, но тест проходит 😂

import { render, cleanup, waitForElement } from '@testing-library/react';
import { createRouter } from 'next/router';
import { RouterContext } from 'next-server/dist/lib/router-context';

const router = createRouter('', { user: 'nikita' }, '', {
  initialProps: {},
  pageLoader: jest.fn(),
  App: jest.fn(),
  Component: jest.fn(),
});

import UserInfo from './$user';

afterEach(cleanup);

it('Should render correctly on route: /users/nikita', async () => {
  const { getByText } = render(
    <RouterContext.Provider value={router}>
      <UserInfo />
    </RouterContext.Provider>,
  );

  await waitForElement(() => getByText(/Hello nikita!/i));
});

Если есть более абстрактный способ имитировать параметры запроса, чтобы я мог передать фактический маршрут (например, /users/nikita ) и передать путь к файлу? Что вы думаете?

Лучше всего смоделировать маршрутизатор напрямую, а не вызывать createRouter поскольку этот API является внутренним и может измениться в любое время. Вот пример:

import React from 'react'
import { render } from '@testing-library/react'
import { RouterContext } from 'next-server/dist/lib/router-context'

describe('Basic test', () => {
  it('Renders current user value', async () => {
    const router = {
      pathname: '/users/$user',
      route: '/users/$user',
      query: { user: 'nikita' },
      asPath: '/users/nikita',
    }
    const User = require('../pages/users/$user').default
    const tree = render(
      <RouterContext.Provider value={router}>
         <User />
      </RouterContext.Provider>
    )
    expect(tree.getByText('User: nikita')).toBeTruthy()
  })
})

@ijjk, что имеет смысл. Большое спасибо!

Есть ли способ имитировать useRouter с помощью Enzyme + Jest? Я немного искал в Интернете, и единственные релевантные результаты, которые появляются, - это эта проблема.

Мне удалось так поиздеваться над этим.

import * as nextRouter from 'next/router';

nextRouter.useRouter = jest.fn();
nextRouter.useRouter.mockImplementation(() => ({ route: '/' }));

jest.spyOn у меня тоже работает -

import React from 'react'
import { render } from '@testing-library/react'
import ResultsProductPage from 'pages/results/[product]'

const useRouter = jest.spyOn(require('next/router'), 'useRouter')

describe('ResultsProductPage', () => {
  it('renders - display mode list', () => {
    useRouter.mockImplementationOnce(() => ({
      query: { product: 'coffee' },
    }))
    const { container } = render(
      <ResultsProductPage items={[{ name: 'mocha' }]} />
    )
    expect(container).toMatchSnapshot()
  })
})

Я закончил тем, что издевался над этим, мне нужен только экспорт useRouter так что он работал достаточно хорошо для моих целей:

jest.mock("next/router", () => ({
    useRouter() {
        return {
            route: "/",
            pathname: "",
            query: "",
            asPath: "",
        };
    },
}));

Если кто-то хочет издеваться над useRouter просто чтобы избежать вмешательства со стороны императивного prefetch , то этот мертвый простой макет будет работать.

jest.mock("next/router", () => ({
  useRouter() {
    return {
      prefetch: () => null
    };
  }
}));

Примером использования может быть компонент формы, который включает что-то вроде:

  const router = useRouter();
  useEffect(() => {
    router.prefetch("/success");
    if (confirmSuccess) {
      doStuff();
      router.push( {pathname: "/success" } )
    }
  }, [data]);

@ijjk Изменилось ли это поведение в последней версии? Пришлось импортировать из next/dist/next-server/lib/router-context . Он не распознал бы контекст, если бы я установил next-server отдельно.

У меня такая же проблема.
Мы под следующим 9. Ни одно из решений, использующих RouterContext.Provider самом деле не работает.
Единственный способ пройти тест - использовать решение @aeksco в качестве глобального объекта над тестом. В противном случае useRouter всегда не определено.
Это не идеально, поскольку я не могу устанавливать другие параметры для своего теста.
Есть идеи по этому поводу?

РЕДАКТИРОВАТЬ
Я сделал это работать с глобальным издеваться в next/router импорта и spyOn на макет, который позволяет мне назвать mockImplementation(() => ({// whatever you want}) в каждом тесте.
Это выглядит примерно так:

jest.mock("next/router", () => ({
  useRouter() {
    return {
      route: "",
      pathname: "",
      query: "",
      asPath: "",
    };
  },
}));

const useRouter = jest.spyOn(require("next/router"), "useRouter");

Тогда в тестах:

useRouter.mockImplementation(() => ({
      route: "/yourRoute",
      pathname: "/yourRoute",
      query: "",
      asPath: "",
    }));

Это не идеально, но, по крайней мере, для меня это работает

FWIW это то, на чем я остановился:

import { RouterContext } from 'next/dist/next-server/lib/router-context'
import { action } from '@storybook/addon-actions'
import PropTypes from 'prop-types'
import { useState } from 'react'
import Router from 'next/router'

function RouterMock({ children }) {
  const [pathname, setPathname] = useState('/')

  const mockRouter = {
    pathname,
    prefetch: () => {},
    push: async newPathname => {
      action('Clicked link')(newPathname)
      setPathname(newPathname)
    }
  }

  Router.router = mockRouter

  return (
    <RouterContext.Provider value={mockRouter}>
      {children}
    </RouterContext.Provider>
  )
}

RouterMock.propTypes = {
  children: PropTypes.node.isRequired
}

export default RouterMock

Мне нужно было что-то, что работало как в Storybook, так и в Jest. Кажется, это помогает, вы просто устанавливаете <Routermock> где-нибудь в дереве компонентов. Это не идеально, потому что я не люблю постоянно переопределять Router.router .

Я думаю, что официальное решение для насмешек было бы прекрасным :)

Метод @smasontst сработал для нас, но будьте осторожны с mockImplementationOnce() ... если вашему компоненту нужно отрендерить более одного раза во время вашего теста, вы обнаружите, что он не использует ваш макет роутера во втором рендере и ваш тест не удастся. Вероятно, лучше всегда использовать вместо этого mockImplementation() , если только у вас нет особой причины использовать mockImplementationOnce() .

Мне пришлось пересмотреть свою первоначальную реализацию, так как мне нужно было уникальное состояние useRouter для каждого теста. Взял страницу из примера, предоставленного @ nterol24s, и обновил ее, чтобы она действовала как служебная функция, которую я могу вызывать в своих тестах:

// Mocks useRouter
const useRouter = jest.spyOn(require("next/router"), "useRouter");

/**
 * mockNextUseRouter
 * Mocks the useRouter React hook from Next.js on a test-case by test-case basis
 */
export function mockNextUseRouter(props: {
    route: string;
    pathname: string;
    query: string;
    asPath: string;
}) {
    useRouter.mockImplementationOnce(() => ({
        route: props.route,
        pathname: props.pathname,
        query: props.query,
        asPath: props.asPath,
    }));
}

Теперь я могу делать такие вещи, как:

import { mockNextUseRouter } from "@src/test_util";

describe("Pricing Page", () => {

    // Mocks Next.js route
    mockNextUseRouter({
        route: "/pricing",
        pathname: "/pricing",
        query: "",
        asPath: `/pricing?error=${encodeURIComponent("Uh oh - something went wrong")}`,
    });

    test("render with error param", () => {
        const tree: ReactTestRendererJSON = Renderer.create(
            <ComponentThatDependsOnUseRouter />
        ).toJSON();
        expect(tree).toMatchSnapshot();
    });
});

Обратите внимание на комментарий @mbrowne - вы mockNextUseRouter и mockNextUseRouterOnce если вам нужно.

Также БОЛЬШОЙ: +1: за официальное издевательское решение @timneutkens

Для тех, кто хочет глобально издеваться над экземпляром Router , вы можете разместить в любом месте папку __mocks__ и настроить таргетинг на пакет next/router следующим образом:

__mocks__/next/router/index.js (должен соответствовать этому шаблону структуры папок!)

В этом примере ниже нацелены на Router.push и Router.replace :

jest.mock("next/router", () => ({
  // spread out all "Router" exports
  ...require.requireActual("next/router"),

  // shallow merge the "default" exports with...
  default: {
    // all actual "default" exports...
    ...require.requireActual("next/router").default,

    // and overwrite push and replace to be jest functions
    push: jest.fn(),
    replace: jest.fn(),
   },
}));

// export the mocked instance above
module.exports = require.requireMock("next/router");

Теперь везде, где есть import Router from "next/router"; это будет фиктивный экземпляр. Вы также сможете добавить к ним функции mockImplementation поскольку они будут глобально издеваться.
Если вы хотите, чтобы этот экземпляр сбрасывался для каждого теста, тогда в вашем jest.json добавьте свойство clearMocks .

Для справки, вот структура Router если вы хотите настроить таргетинг на конкретный экспорт:

{
  __esModule: true,
  useRouter: [Function: useRouter],
  makePublicRouterInstance: [Function: makePublicRouterInstance],
  default: { 
    router: null,
    readyCallbacks: [ 
      [Function],
      [Function],
      [Function],
      [Function],
      [Function],
      [Function] 
    ],
    ready: [Function: ready],
    push: [Function],
    replace: [Function],
    reload: [Function],
    back: [Function],
    prefetch: [Function],
    beforePopState: [Function] },
    withRouter: [Function: withRouter],
    createRouter: [Function: createRouter],
    Router: { 
      [Function: Router]
      events: { 
        on: [Function: on],
        off: [Function: off],
        emit: [Function: emit] 
       } 
    },
    NextRouter: undefined 
  }
}

Кроме того, если вам нужно mount компоненты, которые используют withRouter или useRouter и вы не хотите издеваться над ними, но все же хотите создать несколько тестов против / вокруг их, то вы можете использовать эту фабричную функцию HOC-оболочки для тестирования:

import { createElement } from "react";
import { mount } from "enzyme";
import { RouterContext } from "next/dist/next-server/lib/router-context";
// Important note: The RouterContext import will vary based upon the next version you're using;
// in some versions, it's a part of the next package, in others, it's a separate package

/**
 * Factory function to create a mounted RouterContext wrapper for a React component
 *
 * <strong i="33">@function</strong> withRouterContext
 * <strong i="34">@param</strong> {node} Component - Component to be mounted
 * <strong i="35">@param</strong> {object} initialProps - Component initial props for setup.
 * <strong i="36">@param</strong> {object} state - Component initial state for setup.
 * <strong i="37">@param</strong> {object} router - Initial route options for RouterContext.
 * <strong i="38">@param</strong> {object} options - Optional options for enzyme's mount function.
 * <strong i="39">@function</strong> createElement - Creates a wrapper around passed in component (now we can use wrapper.setProps on root)
 * <strong i="40">@returns</strong> {wrapper} - a mounted React component with Router context.
*/
export const withRouterContext = (
  Component,
  initialProps = {},
  state = null,
  router = {
    pathname: "/",
    route: "/",
    query: {},
    asPath: "/",
  },
  options = {},
) => {
  const wrapper = mount(
    createElement(
      props => ( 
        <RouterContext.Provider value={router}>
          <Component { ...props } /> 
        </RouterContext.Provider>
      ),
      initialProps,
    ),
    options,
  );
  if (state) wrapper.find(Component).setState(state);
  return wrapper;
};

Пример использования:

import React from "react";
import withRouterContext from "./path/to/reusable/test/utils"; // alternatively you can make this global
import ExampleComponent from "./index";

const initialProps = {
  id: "0123456789",
  firstName: "John",
  lastName: "Smith"
};

const router = {
  pathname: "/users/$user",
  route: "/users/$user",
  query: { user: "john" },
  asPath: "/users/john",
};

const wrapper = withRouterContext(ExampleComponent, initialProps, null, router);

...etc

Зачем это использовать? Потому что он позволяет вам иметь повторно используемый компонент React, обернутый в контекст маршрутизатора; и, что наиболее важно, он позволяет вызывать wrapper.setProps(..) в корневом компоненте!

import { useRouter } from 'next/router'

jest.mock('next/router', () => ({
  __esModule: true,
  useRouter: jest.fn()
}))

describe('XXX', () => {
  it('XXX', () => {
    const mockRouter = {
      push: jest.fn() // the component uses `router.push` only
    }
    ;(useRouter as jest.Mock).mockReturnValue(mockRouter)
    // ...
    expect(mockRouter.push).toHaveBeenCalledWith('/hello/world')
  })
})

Ни одно из этих решений не помогло мне. «Правильный» рабочий процесс также описан здесь, в документации Jest: https://jestjs.io/docs/en/es6-class-mocks#spying -on-methods-of-our-class.

Тем не менее, я вижу макет, но он не записывает звонки ...

Вот мой текущий test-utils.tsx . Мне это нравится намного больше, чем использование глобального макета.

import React from 'react';
import { render as defaultRender } from '@testing-library/react';
import { RouterContext } from 'next/dist/next-server/lib/router-context';
import { NextRouter } from 'next/router';

export * from '@testing-library/react';

// --------------------------------------------------
// Override the default test render with our own
//
// You can override the router mock like this:
//
// const { baseElement } = render(<MyComponent />, {
//   router: { pathname: '/my-custom-pathname' },
// });
// --------------------------------------------------
type DefaultParams = Parameters<typeof defaultRender>;
type RenderUI = DefaultParams[0];
type RenderOptions = DefaultParams[1] & { router?: Partial<NextRouter> };

export function render(
  ui: RenderUI,
  { wrapper, router, ...options }: RenderOptions = {},
) {
  if (!wrapper) {
    wrapper = ({ children }) => (
      <RouterContext.Provider value={{ ...mockRouter, ...router }}>
        {children}
      </RouterContext.Provider>
    );
  }

  return defaultRender(ui, { wrapper, ...options });
}

const mockRouter: NextRouter = {
  basePath: '',
  pathname: '/',
  route: '/',
  asPath: '/',
  query: {},
  push: jest.fn(),
  replace: jest.fn(),
  reload: jest.fn(),
  back: jest.fn(),
  prefetch: jest.fn(),
  beforePopState: jest.fn(),
  events: {
    on: jest.fn(),
    off: jest.fn(),
    emit: jest.fn(),
  },
  isFallback: false,
};

@flybayer спасибо! Работает отлично!

Решение @flybayer работает для меня, однако я должен указать тип возвращаемого значения для функции рендеринга

import { render as defaultRender, RenderResult } from '@testing-library/react'

...

export function render(
  ui: RenderUI,
  { wrapper, router, ...options }: RenderOptions = {}
): RenderResult { ... }

Для тех, кто хочет глобально издеваться над экземпляром Router , вы можете разместить в любом месте папку __mocks__ и настроить таргетинг на пакет next/router следующим образом:

__mocks__/next/router/index.js (должен соответствовать этому шаблону структуры папок!)

В этом примере ниже нацелены на Router.push и Router.replace :

jest.mock("next/router", () => ({
  // spread out all "Router" exports
  ...require.requireActual("next/router"),

  // shallow merge the "default" exports with...
  default: {
    // all actual "default" exports...
    ...require.requireActual("next/router").default,

    // and overwrite push and replace to be jest functions
    push: jest.fn(),
    replace: jest.fn(),
   },
}));

// export the mocked instance above
module.exports = require.requireMock("next/router");

Теперь везде, где есть import Router from "next/router"; это будет фиктивный экземпляр. Вы также сможете добавить к ним функции mockImplementation поскольку они будут глобально издеваться.
Если вы хотите, чтобы этот экземпляр сбрасывался для каждого теста, тогда в вашем jest.json добавьте свойство clearMocks .

Для справки, вот структура Router если вы хотите настроить таргетинг на конкретный экспорт:

{
  __esModule: true,
  useRouter: [Function: useRouter],
  makePublicRouterInstance: [Function: makePublicRouterInstance],
  default: { 
    router: null,
    readyCallbacks: [ 
      [Function],
      [Function],
      [Function],
      [Function],
      [Function],
      [Function] 
    ],
    ready: [Function: ready],
    push: [Function],
    replace: [Function],
    reload: [Function],
    back: [Function],
    prefetch: [Function],
    beforePopState: [Function] },
    withRouter: [Function: withRouter],
    createRouter: [Function: createRouter],
    Router: { 
      [Function: Router]
      events: { 
        on: [Function: on],
        off: [Function: off],
        emit: [Function: emit] 
       } 
    },
    NextRouter: undefined 
  }
}

Кроме того, если вам нужно mount компоненты, которые используют withRouter или useRouter и вы не хотите издеваться над ними, но все же хотите создать несколько тестов против / вокруг их, то вы можете использовать эту фабричную функцию HOC-оболочки для тестирования:

import { createElement } from "react";
import { mount } from "enzyme";
import { RouterContext } from "next/dist/next-server/lib/router-context";
// Important note: The RouterContext import will vary based upon the next version you're using;
// in some versions, it's a part of the next package, in others, it's a separate package

/**
 * Factory function to create a mounted RouterContext wrapper for a React component
 *
 * <strong i="33">@function</strong> withRouterContext
 * <strong i="34">@param</strong> {node} Component - Component to be mounted
 * <strong i="35">@param</strong> {object} initialProps - Component initial props for setup.
 * <strong i="36">@param</strong> {object} state - Component initial state for setup.
 * <strong i="37">@param</strong> {object} router - Initial route options for RouterContext.
 * <strong i="38">@param</strong> {object} options - Optional options for enzyme's mount function.
 * <strong i="39">@function</strong> createElement - Creates a wrapper around passed in component (now we can use wrapper.setProps on root)
 * <strong i="40">@returns</strong> {wrapper} - a mounted React component with Router context.
*/
export const withRouterContext = (
  Component,
  initialProps = {},
  state = null,
  router = {
    pathname: "/",
    route: "/",
    query: {},
    asPath: "/",
  },
  options = {},
) => {
  const wrapper = mount(
    createElement(
      props => ( 
        <RouterContext.Provider value={router}>
          <Component { ...props } /> 
        </RouterContext.Provider>
      ),
      initialProps,
    ),
    options,
  );
  if (state) wrapper.find(Component).setState(state);
  return wrapper;
};

Пример использования:

import React from "react";
import withRouterContext from "./path/to/reusable/test/utils"; // alternatively you can make this global
import ExampleComponent from "./index";

const initialProps = {
  id: "0123456789",
  firstName: "John",
  lastName: "Smith"
};

const router = {
  pathname: "/users/$user",
  route: "/users/$user",
  query: { user: "john" },
  asPath: "/users/john",
};

const wrapper = withRouterContext(ExampleComponent, initialProps, null, router);

...etc

Зачем это использовать? Потому что он позволяет вам иметь повторно используемый компонент React, обернутый в контекст маршрутизатора; и, что наиболее важно, он позволяет вызывать wrapper.setProps(..) в корневом компоненте!

привет, у меня такая ошибка:

TypeError: require.requireMock не является функцией

ИСПОЛЬЗОВАЛ ДАННОЕ РЕШЕНИЕ:

jest.mock("next/router", () => ({
  // spread out all "Router" exports
  ...jest.requireActual("next/router"),

  // shallow merge the "default" exports with...
  default: {
    // all actual "default" exports...
    ...jest.requireActual("next/router").default,

    // and overwrite push and replace to be jest functions
    push: jest.fn(),
    replace: jest.fn(),
  },
}));

// export the mocked instance above
module.exports = jest.requireMock("next/router");
Была ли эта страница полезной?
0 / 5 - 0 рейтинги