가수면

Next에서 jest를 사용할 때 내비게이션 문제 본문

일지

Next에서 jest를 사용할 때 내비게이션 문제

니비앙 2023. 8. 10. 02:58

넥스트에서 Link태그나 useRouter를 이용해 경로 이동하는 테스트를 시도하면 "Not implemented: navigation" 같은 오류가 발생한다.

  await userEvent.click(screen.getByText("Tech"));
  expect(window.location.pathname).toEqual("/tech");

 

이것은 React Testing Library환경에서 Next를 사용하는 데 오는 문제다.

 

기본적으로 RTL와 Jest는 리액트 환경에 초점이 맞춰져있으며, Next에서 제공하는 useRouter와 Link태그는 Next 자체에서 제공하는 유틸리티다. 즉, RTL에 없는 기능을 사용하려니 문제가 발생하는 것이다.

 

리액트에서처럼 react-router-dom에서 제공하는 BrowserRouter나 MemoryRouter를 import해서 테스트하는 것도 아니고, Next에서 그러면 어떻게 경로 이동을 테스트할 수 있을까?

Link 모킹

Jest는기본적으로 인터렉션에 따라 자동으로 경로를 이동해 해당 경로의 새로운 컴포넌트를 마운트하는 기능을 제공하지 않는다.

그런 실제 브라우저 동작을 테스트하려면  Cypress와 같은 E2E 테스트 도구를 사용해야 한다.

 

그러나 결국 테스트해야 할 항목은 두 가지면 충분하다.
1. Link 태그가 잘 눌러지는가?

2. href 속성이 잘 지정되어 있는가?

 

아래 Link의 동작을 테스트한다고 가정해보자.

먼저 모킹 함수를 만든다.

jest.mock("next/link", () => {});

"next/link"에 가보면 Link가 어떤 녀석인지 살펴볼 수 있다.

Link는 children으로 React.ReactNode를 받고 props로 InternalLinkProps라는 녀석을 받고 있는 형태라는 걸 확인할 수 있다.

 

그리고 InternalLinkProps에는 href가 있다.

이걸 바탕으로 모킹 함수를 구성해볼 수 있다.

 

필요한 건 두 가지였다.

'잘 눌리는지'와 'href이 의도대로 지정되어 있는지'

 

그리고 생각해보면 이건 하나의 동작으로 함수를 통해 처리할 수 있다.

 

바로 '눌렀을 때 href가 잘 지정되어 있는지 확인하는 함수'말이다.

 

뚝딱!

const mockFn = jest.fn();

jest.mock("next/link", () => {
  const MockedNextLink = ({
    children,
    href,
  }: {
    children: React.ReactNode;
    href: string;
  }) => {
    return <a onClick={() => mockFn(href)}>{children}</a>;
  };

  return MockedNextLink;
});

앞서 코드를 뜯어봤기에 우리는 Link의 props를 저렇게 지정해줘야한다는 것을 알 수 있다.

 

이렇게 하면 링크를 눌렀을 때 mock함수가 href 속성으로 지정된 "/test2"를 잘 호출하는지 확인하는 테스트 코드를 작성할 수 있게 된다.

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

import RootLayout from "@/app/layout";
import Testpage1 from "@/app/test1/page";

const mockFn = jest.fn();

jest.mock("next/link", () => {
  const MockedNextLink = ({
    children,
    href,
  }: {
    children: React.ReactNode;
    href: string;
  }) => {
    return <a onClick={() => mockFn(href)}>{children}</a>;
  };

  return MockedNextLink;
});

test("페이지 이동 테스트", async () => {
  render(<Testpage1 />, { wrapper: RootLayout });

  const linkButton = screen.getByText("Testpage1에서 Testpage2로 이동");

  expect(linkButton).toBeInTheDocument();
  await userEvent.click(linkButton);
  expect(mockFn).toHaveBeenCalledWith("/test2");
});

그리고 앞서 말한 것처럼 jest에선 실제로 언마운트, 마운트가 일어나진 않기 때문에 마운트를 수동으로 해줘야 한다.

  await userEvent.click(linkButton);
  expect(mockPush).toHaveBeenCalledWith("/test2");

  unmount();
  render(<Testpage2 />, { wrapper: RootLayout });

  expect(screen.getByText("Testpage2")).toBeInTheDocument();

최종코드

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

import RootLayout from "@/app/layout";
import Testpage1 from "@/app/test1/page";
import Testpage2 from "@/app/test2/page";

const mockFn = jest.fn();

jest.mock("next/link", () => {
  const MockedNextLink = ({
    children,
    href,
  }: {
    children: React.ReactNode;
    href: string;
  }) => {
    return <a onClick={() => mockFn(href)}>{children}</a>;
  };

  return MockedNextLink;
});

test("페이지 이동 테스트", async () => {
  const { unmount } = render(<Testpage1 />, { wrapper: RootLayout });

  const linkButton = screen.getByText("Testpage1에서 Testpage2로 이동");

  expect(linkButton).toBeInTheDocument();
  await userEvent.click(linkButton);
  expect(mockFn).toHaveBeenCalledWith("/test2");

  unmount();
  render(<Testpage2 />, { wrapper: RootLayout });

  expect(screen.getByText("Testpage2")).toBeInTheDocument();
});

useRouter 모킹

Next의 또 다른 내비게이션 useRouter 역시 마찬가지 방식으로 모킹해 테스트할 수 있다.

생성자를 통해 useRouter에 .을 찍으면 나왔던 기능들을 볼 수 있다.

 

필요한 것은 push를 사용했을 때기 때문에 push만 모킹하면 될 것 같다.

const mockPush = jest.fn();

jest.mock("next/router", () => ({
  useRouter: () => ({
    push: mockPush,
  }),
}));

보너스

경로를 확인하는 매 테스트마다 mock함수를 상단에 집어넣는 것은 매우 비효율적인 일이다.

이럴 땐 __mocks__를 통해 테스트 환경에서 실행되는 모듈을 자동 모킹하도록 할 수 있다.

※__mocks__에 모듈을 모킹해 놓으면 jest가 테스트 환경에서 모듈을 사용할 때 자동으로 __mocks__에 설정된 것을 사용하게 된다.

 

__mocks__에서 Link와 useRouter를 모킹한다면 아래와 같다.

// src\__mocks__\next\router.ts

export const mockPush = jest.fn();

const useRouter = () => ({
  push: mockPush,
});

export default useRouter;
// src\tests\__mocks__\next\link.tsx

import React from "react";

import { mockPush } from "./router";

interface NextLinkProps {
  children: React.ReactNode;
  href: string;
}

const NextLink = ({ children, href }: NextLinkProps) => {
  return <a onClick={() => mockPush(href)}>{children}</a>;
};

export default NextLink;

mockPush를 공유시킴으로써 사용이 좀 더 간결해지도록 만들었다.

반대로 mock함수를 link에 만들어 useRouter에 공유시켜 사용할 수도 있고, 둘 다 각각 만들어서 사용하는 방법도 있다.

나는 이 방식을 사용했지만 세 가지 중 어떤 방식이든 정상 작동된다.

 

그리고 사용할 땐 아래처럼 mockPush를 import해 사용하면 된다.

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

import RootLayout from "@/app/layout";
import Testpage1 from "@/app/test1/page";
import Testpage2 from "@/app/test2/page";

import { mockPush } from "../__mocks__/next/router";

test("페이지 이동 테스트", async () => {
  render(<Testpage1 />, { wrapper: RootLayout });

  const linkButton = screen.getByText("Testpage1에서 Testpage2로 이동");

  expect(linkButton).toBeInTheDocument();
  await userEvent.click(linkButton);
  expect(mockPush).toHaveBeenCalledWith("/test2");

  render(<Testpage2 />, { wrapper: RootLayout });

  expect(screen.getByText("Testpage2")).toBeInTheDocument();
});

Comments