1️⃣ 필요한 라이브러리 설치
가장 먼저, 테스트에 필요한 기본적인 라이브러리부터 설치해보겠습니다.
yarn add jest
yarn add --dev @types/jest @testing-library/jest-dom @testing-library/react jest-environment-jsdom identity-obj-proxyyarn add jest
yarn add --dev @types/jest @testing-library/jest-dom @testing-library/react jest-environment-jsdom identity-obj-proxyjest와 react testing library는 테스트를 위해 기본적으로 필요한 라이브러리입니다.
jest-environment-jsdom 라이브러리는 jest의 테스팅 환경을 변경시켜주기 위한 라이브러리입니다.
jest의 기본 테스트 환경은 NodeJS입니다. NodeJs 테스트 환경에서 테스트를 진행하게 되면, NextJS에서 window를 찾지 못하는 것과 같이 document 객체를 찾을 수 없다는 에러를 뱉습니다.
따라서, jest의 기본 테스트 환경을 브라우저로 변경시켜주기 위해, jest-enviroment-jsdom 라이브러리를 설치하고 테스트 환경을 변경시켜주는 작업이 필요합니다.
identity-obj-proxy 라이브러리의 경우에는 컴포넌트에서 css 파일을 import 할 때, 에러가 발생하는데 이를 해결하기 위해서 설치하게 됩니다.
2️⃣ config 설정
본격적인 테스트에 들어가기 전에, 테스트에 필요한 기본 설정을 해야 합니다.
먼저, setupTests.ts/js 파일을 루트 디렉토리에 만들고, 해당 파일에서 다음과 같이 jest-dom 라이브러리를 import 해주어야 합니다.
import "@testing-library/jest-dom";import "@testing-library/jest-dom";위와 같이 jest-dom 라이브러리를 import 하는 이유는 테스트 전에 jest-dom 라이브러리를 import를 해야 jest-dom에서 제공하는 기능을 사용 할 수 있기 때문입니다.
다음으로, babel.config.js 파일을 루트 디렉토리에 만들고, 다음과 같이 작성하면 됩니다.
module.exports = {
presets: ["next/babel"],
};module.exports = {
presets: ["next/babel"],
};jest는 commonJS 기반으로 작동하기 때문에, babel 관련 설정을 해야 ES6 이상의 문법이나 typescript를 사용 할 수 있습니다.
다음으로, jest.config.js 파일을 루트 디렉토리에 만들고, 다음과 같이 작성하면 됩니다.
module.exports = {
moduleNameMapper: { "\\.(css|less|scss|sass)$": "identity-obj-proxy" },
testEnvironment: "jsdom",
setupFilesAfterEnv: ["<rootDir>/setupTests.ts"],
};module.exports = {
moduleNameMapper: { "\\.(css|less|scss|sass)$": "identity-obj-proxy" },
testEnvironment: "jsdom",
setupFilesAfterEnv: ["<rootDir>/setupTests.ts"],
};moduleNameMapper는 테스트 과정에서 css 파일을 import하지 못하는 문제를 proxy를 통해 해결하기 위한 것이며,
testEnvironment 는 앞서 말씀드린 테스팅 환경을 NodeJS에서 브라우저 환경으로 변경시켜주기 위한 것입니다.
setUpFilesAfterEnv 는 앞서 작성한 setupTests.ts 파일이 테스트 시작 전에 먼저 실행 될 수 있도록 하기 위함입니다.
3️⃣ useRouter 모킹
NextJS를 통해 컴포넌트를 렌더링하게 되는 경우, React의 Context를 이용해서 만든 RouterContext가 해당 컴포넌트를 감싸는 형태(HOC)로 렌더링되고, 이에 따라 RouterContext에서 제공하는 기능을 useRouter 훅을 통해 사용 할 수 있습니다.
테스팅 환경에서 RouterContext로 감싸지 않을 경우, 테스트하는 컴포넌트에서 useRouter 훅을 사용할 수 없습니다.
따라서, 테스트하는 컴포넌트에서 useRouter를 사용하려면, 해당 컴포넌트를 NextJS의 RouterContext.Provider 로 감싸고, 해당 Context에 value 값을 넣어 커스텀 하는 형태를 만들어 주어야 합니다.
이를 위해서, RouterContext.Provider의 기본 value를 넣어주기 위한 함수를 만들고, 해당 함수에 필요한 key, value를 주입하는 방식으로 사용했습니다.
https://github.com/BY-juun/Blog/blob/master/client/utils/test/createMockRouter.ts
import { NextRouter } from "next/router";
export function createMockRouter(router?: Partial<NextRouter>): NextRouter {
return {
basePath: "",
pathname: "/",
route: "/",
query: {},
asPath: "/",
back: jest.fn(),
beforePopState: jest.fn(),
prefetch: jest.fn(),
push: jest.fn(),
reload: jest.fn(),
replace: jest.fn(),
events: {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
isFallback: false,
isLocaleDomain: false,
isReady: true,
defaultLocale: "en",
domainLocales: [],
isPreview: false,
...router,
};
}import { NextRouter } from "next/router";
export function createMockRouter(router?: Partial<NextRouter>): NextRouter {
return {
basePath: "",
pathname: "/",
route: "/",
query: {},
asPath: "/",
back: jest.fn(),
beforePopState: jest.fn(),
prefetch: jest.fn(),
push: jest.fn(),
reload: jest.fn(),
replace: jest.fn(),
events: {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
isFallback: false,
isLocaleDomain: false,
isReady: true,
defaultLocale: "en",
domainLocales: [],
isPreview: false,
...router,
};
}위와 같이 default value를 넣어주기 위한 createMockRouter 함수를 만들고, 이를 이용해서, RouterContext.Provider에 value props를 넣어주면 됩니다.
render(
<RouterContext.Provider value={createMockRouter()}>
<CategoryChip category={props.category} />
</RouterContext.Provider>
);render(
<RouterContext.Provider value={createMockRouter()}>
<CategoryChip category={props.category} />
</RouterContext.Provider>
);4️⃣ 테스트 코드 작성
이제 모든 설정은 끝났고, 테스트 코드만 작성하면 됩니다.
import { useRouter } from "next/router";
import React, { useCallback, useContext, useEffect, useRef } from "react";
import { ThemeContext } from "../../../utils/ThemeContext";
import styles from "./styles.module.scss";
import useChangeColor from "./useChangeColor";
interface Props {
category: string;
length?: number;
mode?: string;
}
const CategoryChip = ({ category, length, mode }: Props) => {
const { push } = useRouter();
const btnRef = useRef<HTMLButtonElement>(null);
const { theme } = useContext(ThemeContext);
const onClickBtn = useCallback(
(category: string) => {
push({
pathname: "/filter",
query: { category: category, page: 1 },
});
},
[push]
);
useChangeColor({ category, btnRef });
return (
<button
ref={btnRef}
className={`${styles.CategoryChip} ${styles[theme]}`}
onClick={() => onClickBtn(category)}
>
<span>{category}</span>
{mode !== "post" && (
<div className={`${styles.CategoryLength}`}>{length ? length : 0}</div>
)}
</button>
);
};
export default CategoryChip;import { useRouter } from "next/router";
import React, { useCallback, useContext, useEffect, useRef } from "react";
import { ThemeContext } from "../../../utils/ThemeContext";
import styles from "./styles.module.scss";
import useChangeColor from "./useChangeColor";
interface Props {
category: string;
length?: number;
mode?: string;
}
const CategoryChip = ({ category, length, mode }: Props) => {
const { push } = useRouter();
const btnRef = useRef<HTMLButtonElement>(null);
const { theme } = useContext(ThemeContext);
const onClickBtn = useCallback(
(category: string) => {
push({
pathname: "/filter",
query: { category: category, page: 1 },
});
},
[push]
);
useChangeColor({ category, btnRef });
return (
<button
ref={btnRef}
className={`${styles.CategoryChip} ${styles[theme]}`}
onClick={() => onClickBtn(category)}
>
<span>{category}</span>
{mode !== "post" && (
<div className={`${styles.CategoryLength}`}>{length ? length : 0}</div>
)}
</button>
);
};
export default CategoryChip;테스트를 진행할 컴포넌트는 CategoryChip 컴포넌트입니다. 해당 컴포넌트는 각 카테고리의 정보를 나타내고, 클릭 시 해당 카테고리 페이지로 이동하는 컴포넌트입니다.
진행해볼 테스트는, 컴포넌트 렌더링 테스트와 클릭 테스트입니다.
컴포넌트가 각각 다른 props를 가졌을 때 렌더링이 정확히 되는지를 먼저 테스트해보겠습니다.
describe("<CategoryChip />", () => {
it("length가 있는 CategoryChip 렌더링 테스트", () => {
const props = {
category: "테스트카테고리",
length: 11,
};
render(
<RouterContext.Provider value={createMockRouter()}>
<CategoryChip {...props} />
</RouterContext.Provider>
);
expect(screen.getByText(props.category)).toBeInTheDocument();
expect(screen.getByText(props.length)).toBeInTheDocument();
});
it("length가 없는 CategoryChip 렌더링 테스트", () => {
const props = {
category: "테스트카테고리",
};
render(
<RouterContext.Provider value={createMockRouter()}>
<CategoryChip {...props} />
</RouterContext.Provider>
);
expect(screen.getByText(props.category)).toBeInTheDocument();
expect(screen.getByText(0)).toBeInTheDocument();
});
it("length가 있고, mode가 post인 CategoryChip 렌더링 테스트", async () => {
const props = {
category: "테스트카테고리",
length: 10,
mode: "post",
};
render(
<RouterContext.Provider value={createMockRouter()}>
<CategoryChip {...props} />
</RouterContext.Provider>
);
expect(screen.getByText(props.category)).toBeInTheDocument();
expect(screen.queryByText(props.length)).not.toBeInTheDocument();
});
});describe("<CategoryChip />", () => {
it("length가 있는 CategoryChip 렌더링 테스트", () => {
const props = {
category: "테스트카테고리",
length: 11,
};
render(
<RouterContext.Provider value={createMockRouter()}>
<CategoryChip {...props} />
</RouterContext.Provider>
);
expect(screen.getByText(props.category)).toBeInTheDocument();
expect(screen.getByText(props.length)).toBeInTheDocument();
});
it("length가 없는 CategoryChip 렌더링 테스트", () => {
const props = {
category: "테스트카테고리",
};
render(
<RouterContext.Provider value={createMockRouter()}>
<CategoryChip {...props} />
</RouterContext.Provider>
);
expect(screen.getByText(props.category)).toBeInTheDocument();
expect(screen.getByText(0)).toBeInTheDocument();
});
it("length가 있고, mode가 post인 CategoryChip 렌더링 테스트", async () => {
const props = {
category: "테스트카테고리",
length: 10,
mode: "post",
};
render(
<RouterContext.Provider value={createMockRouter()}>
<CategoryChip {...props} />
</RouterContext.Provider>
);
expect(screen.getByText(props.category)).toBeInTheDocument();
expect(screen.queryByText(props.length)).not.toBeInTheDocument();
});
});해당 컴포넌트는 useRouter 훅을 사용하기 때문에, 앞서 작성한 createMockRouter 함수와 RouterContext를 이용해서 useRouter를 모킹해서 사용 할 수 있도록 했습니다.
다음으로는, 버튼 클릭시 이벤트가 발생하는지를 테스트 해보겠습니다.
it("CategoryChip 클릭 이벤트 테스트", async () => {
const router = createMockRouter();
const props = {
category: "testCategory",
length: 10,
};
render(
<RouterContext.Provider value={router}>
<CategoryChip {...props} />
</RouterContext.Provider>
);
const categroyChipBtn = screen.getByRole("button");
expect(categroyChipBtn).toBeInTheDocument();
fireEvent.click(categroyChipBtn);
expect(router.push).toHaveBeenCalledWith({
pathname: "/filter",
query: { category: props.category, page: 1 },
});
});it("CategoryChip 클릭 이벤트 테스트", async () => {
const router = createMockRouter();
const props = {
category: "testCategory",
length: 10,
};
render(
<RouterContext.Provider value={router}>
<CategoryChip {...props} />
</RouterContext.Provider>
);
const categroyChipBtn = screen.getByRole("button");
expect(categroyChipBtn).toBeInTheDocument();
fireEvent.click(categroyChipBtn);
expect(router.push).toHaveBeenCalledWith({
pathname: "/filter",
query: { category: props.category, page: 1 },
});
});해당 버튼을 누르면, useRouter의 push 메서드가 실행되게 됩니다.
해당 메서드가 실행 될 때, 어떤 매게변수를 통해 실행되는지 테스트함으로써, 정확한 동작을 테스트 할 수 있습니다.