Next.js: useRouterλ₯Ό μ‘°λ‘±ν•˜λŠ” 방법?

에 λ§Œλ“  2019λ…„ 06μ›” 01일  Β·  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));
});

ν•˜μ§€λ§Œ const router = useRouter(); 라인을 κ°€λ¦¬ν‚€λŠ” TypeError: Cannot read property 'query' of null 였λ₯˜κ°€ λ°œμƒν•©λ‹ˆλ‹€.

μΆ”μ‹ : λ‚˜λŠ” 동적 λΌμš°νŒ…μ΄ ν˜„μž¬ μΉ΄λ‚˜λ¦¬μ•„ λ²„μ „μ—μ„œ μ‚¬μš© κ°€λŠ₯ν•˜κ³  변경될 수 μžˆλ‹€λŠ” 것을 μ•Œκ³  μžˆμ§€λ§Œ 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 )λ₯Ό μ „λ‹¬ν•˜κ³  파일 경둜λ₯Ό 전달할 수 μžˆμŠ΅λ‹ˆκΉŒ? μ–΄λ–»κ²Œ μƒκ°ν•˜λ‚˜μš”?

APIλŠ” 내뢀에 있고 μ–Έμ œλ“ μ§€ 변경될 수 μžˆμœΌλ―€λ‘œ createRouter λ₯Ό ν˜ΈμΆœν•˜λŠ” λŒ€μ‹  λΌμš°ν„°λ₯Ό 직접 μ‘°λ‘±ν•˜λŠ” 것이 κ°€μž₯ μ’‹μŠ΅λ‹ˆλ‹€. λ‹€μŒμ€ μ˜ˆμž…λ‹ˆλ‹€.

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 μ˜λ―Έκ°€ μžˆμŠ΅λ‹ˆλ‹€. 정말 κ³ λ§ˆμ›Œ!

Enzyme+Jestλ₯Ό μ‚¬μš©ν•˜μ—¬ useRouterλ₯Ό μ‘°λ‘±ν•˜λŠ” 방법이 μžˆμŠ΅λ‹ˆκΉŒ? λ‚˜λŠ” 쑰금 λ™μ•ˆ 온라인 검색을 ν•΄ λ³΄μ•˜κ³  μœ μΌν•˜κ²Œ κ΄€λ ¨ κ²°κ³Όκ°€ λ‚˜νƒ€λ‚¬μŠ΅λ‹ˆλ‹€. 이 λ¬Έμ œμž…λ‹ˆλ‹€.

이런 μ‹μœΌλ‘œ μ‘°λ‘±ν•˜λŠ” 데 μ„±κ³΅ν–ˆμŠ΅λ‹ˆλ‹€.

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() μ£Όμ˜ν•˜μ‹­μ‹œμ˜€ ... ν…ŒμŠ€νŠΈ 쀑에 ꡬ성 μš”μ†Œκ°€ 두 번 이상 λ Œλ”λ§λ˜μ–΄μ•Ό ν•˜λŠ” 경우 두 번째 λ Œλ”λ§μ—μ„œ λͺ¨μ˜ β€‹β€‹λΌμš°ν„°λ₯Ό μ‚¬μš©ν•˜μ§€ μ•ŠλŠ”λ‹€λŠ” 것을 μ•Œκ²Œ 될 κ²ƒμž…λ‹ˆλ‹€. 그리고 λ‹Ήμ‹ μ˜ ν…ŒμŠ€νŠΈλŠ” μ‹€νŒ¨ν•  κ²ƒμž…λ‹ˆλ‹€. mockImplementationOnce() λ₯Ό μ‚¬μš©ν•΄μ•Ό ν•˜λŠ” νŠΉλ³„ν•œ μ΄μœ κ°€ μ—†λŠ” ν•œ 항상 mockImplementation() λ₯Ό μ‚¬μš©ν•˜λŠ” 것이 κ°€μž₯ μ’‹μŠ΅λ‹ˆλ‹€.

ν…ŒμŠ€νŠΈλ³„λ‘œ κ³ μœ ν•œ 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 ν•¨μˆ˜λ‘œ λΆ„ν• ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

λ˜ν•œ BIG :+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 
  }
}

λ˜ν•œ withRouter λ˜λŠ” useRouter λ₯Ό ν™œμš©ν•˜λŠ” ꡬ성 μš”μ†Œλ₯Ό mount ν•΄μ•Ό ν•˜κ³  이λ₯Ό μ‘°λ‘±ν•˜κ³  μ‹Άμ§€λŠ” μ•Šμ§€λ§Œ μ—¬μ „νžˆ 그에 λŒ€ν•œ/μ£Όλ³€ ν…ŒμŠ€νŠΈλ₯Ό μƒμ„±ν•˜λ €λŠ” 경우 그런 λ‹€μŒ ν…ŒμŠ€νŠΈλ₯Ό μœ„ν•΄ 이 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 
  }
}

λ˜ν•œ withRouter λ˜λŠ” useRouter λ₯Ό ν™œμš©ν•˜λŠ” ꡬ성 μš”μ†Œλ₯Ό mount ν•΄μ•Ό ν•˜κ³  이λ₯Ό μ‘°λ‘±ν•˜κ³  μ‹Άμ§€λŠ” μ•Šμ§€λ§Œ μ—¬μ „νžˆ 그에 λŒ€ν•œ/μ£Όλ³€ ν…ŒμŠ€νŠΈλ₯Ό μƒμ„±ν•˜λ €λŠ” 경우 그런 λ‹€μŒ ν…ŒμŠ€νŠΈλ₯Ό μœ„ν•΄ 이 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 λ“±κΈ‰