🐸 테스트 코드가 왜 어려울까
그것은 테스트 코드를 써본적 없는 사람에겐 환경 설정이 지옥이기 때문이다.
진짜 한참 헤맸던 건데 테스트 코드를 입문하시는 분들이라면 비슷하게 헤매실 것 같습니다.
처음 jest를 설치하고 대충 a + b 테스트코드 짤때는 개쉬운데
컴포넌트 렌더링 해보는거에서 한번 지옥맛보고
비동기 테스트 하는거에서 두번 지옥맛을 본 뒤 테스트 코드를 손절하는..
저도 그랬었는데요 심지어 리액트 쿼리 공식문서에서도
중요한 셋업을 전혀 가르쳐주질 않아서 한참 헤맸습니다.
테스트 진행 환경은 다음과 같습니다
next.js 13.5.3 / pages router
jest ^29.7.0
msw ^1.3.2
@testing-library/jest-dom ^6.1.4
@testing-library/react: ^14.0.0
@tanstack/react-query ^4.36.1
zustand ^4.4.3
이 글을 딱 쓰고있던 시점에 react-query는 5버전을 내놓고
msw는 불과 3일전에 엄청난 브레이킹 체인지를 갖고 2.0 메이저버전을 내놨는데요..
실제 서비스에 올라갈 코드 이기때문에 일부러 안정적인 버전을 선택하고 있습니다.
본 예제에서는 컴포넌트 렌더링 테스트까지는 다루지 않고
단일 훅 테스트까지만을 다룰 예정입니다.
다만 가장 작은 단위부터 테스트해나가다보면 결국 컴포넌트 렌더링 테스트도
어렵지 않게 해낼 수 있을 것입니다.
필요한 종속성을 파악하기
next.js를 사용하고 있다는 가정으로 글을 작성합니다.
nextjs는 testing 환경 설정에 대해 아주 편리한 설정을 제공해주고 있는데요
공식문서를 따라가주면 어렵지 않게 초기 jest 세팅을 할 수 있습니다.
https://nextjs.org/docs/pages/building-your-application/optimizing/testing
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom
다음과 같이 필요한 종속성을 설치해줍니다.
jest / jest-environment-jsdom , rtl 을 설치하는 명령어입니다.
아래 코드는 현 시점에서는 최신이지만
이 글을 미래에 읽고있으신 분들에게는 outdated 되었을 확률이 있으니
웬만하면 공식문서 참조를 추천드립니다.
import nextJest from 'next/jest.js'
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
})
// Add any custom config to be passed to Jest
/** @type {import('jest').Config} */
const config = {
// Add more setup options before each test is run
// setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config)
프로젝트의 root에 jest.config.mjs 파일을 생성하고 위 코드를 붙여넣습니다.
next.js는 nextJest라는 함수를 통해 환경설정에 도움을 주는데요
필요하신 분들은 저기서 설정해주시면 되겠습니다.
import "@testing-library/jest-dom";
마찬가지로 root 폴더에 jest.setup.js 를 설치하고
위 코드를 붙여넣어줍니다.
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "jest --watch"
}
}
package.json의 스크립트에 test 명령어를 추가해줍니다.
--watch 옵션은 변경을 감지하고 변경된 파일의 테스트를 실행시켜주는 옵션입니다.
매우 유용하니까 꼭 사용해보세요
typescript를 사용하시는 분들은 타입스크립트 사용을 원하실 것 같습니다.
이를 위해서 추가적으로 넣어줄 종속성이 존재합니다.
npm i -D ts-jest
"include": [
"next-env.d.ts",
"index.d.ts",
"**/*.ts",
"**/*.tsx",
"__mocks__/fileMock.js",
"__mocks__/styleMock.js",
"jest.config.mjs",
"jest.setup.js",
"__mocks__/msw.ts"
],
ts-jest를 install 해주고
tsconfig의 include 항목에 우리의 jest 설정 파일들이 잘 들어가있는지 확인해줍니다.
이제 jest.config.mjs를 수정해주겠습니다.
import nextJest from "next/jest.js";
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: "./",
});
// Add any custom config to be passed to Jest
/** @type {import('jest').Config} */
const config = {
// Add more setup options before each test is run
// setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
"<rootDir>/__mocks__/fileMock.js",
"\\.(css|less)$": "<rootDir>/__mocks__/styleMock.js",
"^@/(.*)$": "<rootDir>/$1",
},
testEnvironment: "jest-environment-jsdom",
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],
testMatch: ["**/__tests__/**/*.+(ts|tsx|js)", "**/?(*.)+(spec|test).+(ts|tsx|js)"],
transform: {
"^.+\\.(ts|tsx)$": "ts-jest",
},
preset: "ts-jest",
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config);
preset, transform, testMatch에 타입스크립트 관련 설정을 넣어주었습니다.
moduleNameMapper는 next.js 공식문서에서 안내해주는
이미지 파일 모킹 방법에 대한 설정입니다. 필요하신분들만 해주시면 되겠습니다.
여기까지 수행하고 나면 간단한 컴포넌트 테스트를 수행할 수 있습니다.
다만 본 포스트에서는 리액트 쿼리와 주스탠드의 테스팅을 다루기 때문에 이부분은 건너뛰겠습니다.
꼭 해보고 싶으신 분들은 next.js의 testing 문서 혹은
다른 많은 레퍼런스들을 참고해보시기 바랍니다.
리액트 쿼리 테스트를 위한 종속성 추가
npm i @tanstack/react-query
npm i -D jest-fetch-mock @testing-library/user-event
userevent 라이브러리는 본 포스트에서는 사용하지 않지만
이후 컴포넌트 테스트할 때 유용하게 쓰실 수 있으니 겸사겸사 설치하시는 걸 추천드립니다.
테스트는 msw를 이용하여 네트워크 요청을 모킹하는 형태로 할 것이며
jest-fetch-mock 라이브러리를 이용해 fetch를 모킹하겠습니다.
https://github.com/jefflau/jest-fetch-mock
jest-fetch-mock이 필요한 이유는
fetch라는 함수가 브라우저 환경에 의존성이 있는 함수이기 때문입니다.
실제로 우리가 클라이언트 사이드에서 사용하는 fetch는
window객체 내부에 있는 window.fetch 함수를 이용하는 것인데
테스트 코드가 구동되는 환경에서는 window 객체가 존재하지 않기 때문에
자연스럽게 fetch 함수도 존재하지 않는 것입니다.
따라서 fetch 함수를 적절히 mocking 해주지 않고
테스트 코드 환경에서 fetch를 call 하게 되면 정의되지 않은 함수를
호출하려는 시도로 보이게 되어 console.error에 장문의 에러가 찍히게 됩니다.
axios를 사용하시는 경우에는 axios-mock-adapter 등 걸출한 라이브러리들이 많은 것으로 보이니
그쪽 생태계를 확인해보시면 좋겠습니다.
저는 모든 비동기 코드를 fetch로 관리하고 있기 때문에 jest-fetch-mock을 이용해주겠습니다.
setup 방법은 간단한데요
jest-fetch-mock을 설치한뒤
root의 jest.setup.js 에 jest-fetch-mock을 사용하는 코드를 넣어주면 끝입니다.
import "@testing-library/jest-dom";
require("jest-fetch-mock").enableMocks();
이제 테스트코드 환경 어디에서든 모킹된 fetch를 이용할 수 있습니다.
다음은 msw 셋업을 진행해줄텐데요
현재 msw의 메이저버전이 2.0.0으로 올라간 상태이기 때문에
본 포스트의 예제를 따라가시려면 1버전으로 낮춰서 설치를 진행하셔야합니다.
npm install -D msw@1.3.2
msw에 대한 자세한 설명은 다른 블로그 게시물들을 참고해주시면 감사하겠습니다.
다만 중요한 특징은 서버환경에서 msw를 이용할 때인데요
이 경우에는 클라이언트 환경과는 달리 서비스워커 셋업이 필요하지 않기 때문에
테스트 코드 환경에서만 msw를 사용하고 클라이언트에서는 사용하지 않는다면
서비스 워커 설정은 넘어가셔도 좋습니다.
handler와 server를 셋업하는 코드는 분리하여 관리하는 것이 추천되곤 하는데
본 예제에서는 실습의 용이성을 위해 붙여서 작성한다는 점 미리 알립니다.
root폴더에 __mocks__ 폴더를 만들고 msw.ts를 생성해주겠습니다.
__mocks__/msw.ts
import { rest } from "msw";
import { setupServer } from "msw/node";
const handlers = [
rest.get("/api/data", (_, res, ctx) => {
return res(ctx.status(200), ctx.json({ data: "hello world" }));
}),
];
export const server = setupServer(...handlers);
2.0 버전 이상을 사용하고 계신 분들은 문법이 많이 달라져서
rest 함수자체가 존재하지않고 대신 http 함수를 이용해야합니다.
자세한 사항은 msw 문서를 참고하세요
코드는 아주 간단한데요
/api/data 경로로 온 get 요청에 대해서 200 status와 함께
{data: 'hello world'}를 반환하라 라는 의미의 코드입니다.
그리고 setupServer(...handlers)를 이용하여 서버를 내보내줍니다.
전역적으로 msw를 설정해주길 원하는 분들은
jest.setup.js에 해주시면 되겠습니다만
저같은 경우에는 비동기 요청이 이루어지지 않는 테스트 코드가 훨씬 많기 때문에
쿼리를 사용하는 곳에서만 제한적으로 msw를 실행시켜줄 예정입니다.
우선 jest-fetch-mock 라이브러리가 잘 동작하는지 확인해보기 위해
fetch를 하는 함수를 작성하고 테스팅해보겠습니다.
src/fetching.ts
export const hi = async () => {
const response = await fetch("/api/data");
const data = response.json();
return data;
};
아주 간단한 fetch 함수입니다.
/api/data 경로로 get을 보내고 응답을 파싱하여 반환하는 함수에요
하지만 fetch를 사전에 모킹하지 않았다면 이 코드의 테스트는 처참히 망가집니다.
같은 경로에 테스트 파일을 생성해주겠습니다.
src/fetch.test.ts
import { server } from "@/__mocks__/msw";
import { hi } from "./fetching";
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("페치를 테스트합니다.", () => {
it("페치테스트", async () => {
const result = await hi();
expect(result.data).toBe("hello world");
});
});
만약 jest-fetch-mock을 이용하여 페치를 모킹해주지 않았다면
이 테스트 코드는 정의되지않은 fetch 함수를 사용하려고한 결과 에러를 반환할 것입니다.
여기까지 잘 성공했다면 이제 쿼리를 테스트해봅시다.
import { Suspense } from "@suspensive/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
logger: {
log: console.log,
warn: () => {},
error: () => {},
},
});
export function renderWithClient(ui: React.ReactElement) {
const testQueryClient = createTestQueryClient();
const { rerender, ...result } = render(<QueryClientProvider client={testQueryClient}>{ui}</QueryClientProvider>);
return {
...result,
rerender: (rerenderUi: React.ReactElement) =>
rerender(<QueryClientProvider client={testQueryClient}>{rerenderUi}</QueryClientProvider>),
};
}
export function createWrapper() {
const testQueryClient = createTestQueryClient();
// eslint-disable-next-line react/display-name
return ({ children }: { children: React.ReactNode }) => (
<Suspense fallback={<div role="textbox">로딩중이염</div>}>
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
</Suspense>
);
}
tkdodo가 제공한 리액트 쿼리 테스트 코드의 예시입니다.
각각 이유가 존재하는데요
테스트 환경에서 불필요하게 retry 요청을 보내는 것으로 인하여
테스트 시간이 길어지는 문제가 있기 때문에 리트라이를 하지않도록 설정합니다.
기본 옵션은 3회 리트라이를 시도하는 것으로 알고있어요
그리고 warn , error를 찍히지않게 고쳐주어 테스트에서 불필요한 경고창을 보지 않게 합니다.
리액트쿼리는 프로바이더가 필요한 라이브러리 중 하나인데요
jest 테스트코드 환경에서는 이렇게 프로바이더와 같은 외부상태가 필요한 코드를 위해
wrapper 옵션을 제공합니다.
그 wrapper로 전달할 createWrapper 를 작성해주었고
저는 Suspensive reactquery를 이용하고있기때문에
테스트환경에서도 서스펜스에 대한 설정을 해주었습니다.
테스트의 무결성을 위해 쿼리클라이언트를 테스트마다 새로 생성해주는 것을 권장하고 있기 때문에
createWrapper 함수는 함수형컴포넌트를 반환하는 함수로 작성한 것입니다.
이제 첫 테스트를 작성해봅시다 먼저 useQuery를 하는 코드부터 작성하겠습니다.
import { useSuspenseQuery } from "@suspensive/react-query";
export const hi = async () => {
const response = await fetch("/api/data");
const data = response.json();
if (!response.ok) {
throw new Error("서버에러가 발생했어요");
}
return data;
};
export const useHiQuery = () => {
const hiQuery = useSuspenseQuery<{ data: string }>(["hi-query"], hi, {});
return hiQuery;
};
useQuery를 이용해도 똑같이 테스트는 돌아가는것을 확인했습니다.
useQuery를 이용하고자하시는 분들은 서스펜스쿼리부분만 고쳐주세요
import { overrideSearchResultWithErrorData, server } from "@/src/service/set-up-server";
import { hi, useHiQuery } from "./fetching";
import { renderHook, waitFor } from "@testing-library/react";
import { createWrapper } from "@/src/service/query-test-utils";
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("페치를 테스트합니다.", () => {
it("페치테스트", async () => {
const result = await hi();
expect(result.data).toBe("hello world");
});
});
describe("페치에 에러가 난다면 어떨까요? 테스트합니다.", () => {
it("페치테스트", async () => {
overrideSearchResultWithErrorData();
try {
await hi();
} catch (error) {
if (error instanceof Error) {
expect(error.message).toBe("서버에러가 발생했어요");
}
}
});
});
describe("하이쿼리 훅을 테스트합니다,", () => {
it("하이쿼리 훅 테스트", async () => {
const { result } = renderHook(() => useHiQuery(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true), {
timeout: 2000,
});
expect(result.current.data.data).toBe("hello world");
});
});
아까 페치를 테스트하던코드에서 쿼리부분만 추가하여 작성해주겠습니다.
이렇게 지금하는것과 같이 커스텀훅 자체를 테스트할 때에는
RTL에서 제공하는 renderHook 메서드를 이용하면 되는데요
renderHook에는 렌더링시킬 훅을 첫번째 인수로 넣고 두번째인수로는 옵션을 집어넣어줍니다.
여기에서 wrapper를 전달해주면 됩니다.
다만 주의할점은 renderHook은 다른 renderHook과 별개의 상태를 가지기 때문에
renderHook을 여러개 쓰는것은 각 훅을 별개로 테스트하겠다는 의도가 된다는 것입니다.
만약 훅이 같은 컨텍스트를 공유하게 하고싶다면 렌더훅이 반환하는 값을
객체의 형태로 해주면 됩니다.
리액트 18버전으로 오면서 concurrency로 인하여 테스트코드 작성방법이 조금 달라졌는데요
waitFor 안에서 바로 expect문을 이용하여 검증을 진행해주시면 되겠습니다.
제가 테스트코드를 공부할 때에 환경설정에 대해 자세하게 알려주는 글이 참 고팠어서
조금 자세하게 설명을 드렸는데요 어느정도 익숙하신분들이라면
코드만 슥슥 보셔도 이해가 쉬우실 것 같습니다.
다음은 zustand를 설정하는 방법을 다룹니다.
zustand 다루기
https://docs.pmnd.rs/zustand/guides/testing
리액트쿼리가 조금 까다로웠어서 주스탠드 설정은 상대적으로 아주 쉽게 느껴지실 것 같습니다.
위 공식문서를 참고해주시면 되겠는데요
역시 블로그글은 outdated의 위험이 있으니 항상 참고용으로만 사용해주시길 부탁드립니다.
// __mocks__/zustand.ts
import * as zustand from 'zustand'
import { act } from '@testing-library/react'
const { create: actualCreate, createStore: actualCreateStore } =
jest.requireActual<typeof zustand>('zustand')
// a variable to hold reset functions for all stores declared in the app
export const storeResetFns = new Set<() => void>()
const createUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreate(stateCreator)
const initialState = store.getState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
// when creating a store, we get its initial state, create a reset function and add it in the set
export const create = (<T>(stateCreator: zustand.StateCreator<T>) => {
console.log('zustand create mock')
// to support curried version of create
return typeof stateCreator === 'function'
? createUncurried(stateCreator)
: createUncurried
}) as typeof zustand.create
const createStoreUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreateStore(stateCreator)
const initialState = store.getState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
// when creating a store, we get its initial state, create a reset function and add it in the set
export const createStore = (<T>(stateCreator: zustand.StateCreator<T>) => {
console.log('zustand createStore mock')
// to support curried version of createStore
return typeof stateCreator === 'function'
? createStoreUncurried(stateCreator)
: createStoreUncurried
}) as typeof zustand.createStore
// reset all stores after each test run
afterEach(() => {
act(() => {
storeResetFns.forEach((resetFn) => {
resetFn()
})
})
})
스토어 모킹에 필요한 코드들을 문서에서 모두 제공해주고있기 때문에
이걸 그냥 복사해서 사용해주시면 되겠습니다.
const { result } = renderHook(() => ({
login: authStore.loginAction(),
state: authStore.getState(),
logout: authStore.logoutAction(),
}));
추가로 아까 위에서 잠깐 언급했던
렌더훅은 이런형태를 의미합니다.
이렇게 사용해주면 여러훅들이 같은 컨텍스트를 공유하기에
같이 테스트되어야하는 훅들은 이렇게 테스트해주면 되겠습니다.
마치며
테스트코드는 작성보다 환경설정이 / 테스트코드가 구동되는 특수한 환경에 대한 이해를 가지고
테스트코드가 잘 돌아가지 않는 이유를 파악하는 것이 더 까다롭게 느껴지는 것 같습니다.
어쩌면 프론트엔드에서 테스트코드를 작성한다는 것은 이런 사항들까지 다 포함한게 아닐까싶은..
게 제 생각이었습니다.
읽어주셔서 감사합니다. 좋은 하루 되세요
'testcode' 카테고리의 다른 글
cypress와 jest 를 함께 사용하려하면 conflict이 날 수 있다. (2) | 2023.11.24 |
---|---|
jest --coverage 옵션을 통해 커버리지 확인하기 (1) | 2023.10.29 |
[jest] jest에서 안쓰는 import가 있으면 테스트 에러를 내요.. (0) | 2023.08.02 |
Vite Typescript 환경에서 Jest 설치하기 (1) | 2023.07.30 |
ts환경의 jest에서 path alias 사용하는 방법 (0) | 2023.07.27 |