최근 협업 도구인 Dooray! 개발을 담당했었다. 두레이에서는 현재 웹 리뉴얼 작업과 함께 타입스크립트와 리액트 프레임워크로의 전환을 진행하고 있다. 이 과정에서 사용하게 된 커스텀 훅과 이를 테스트 라이브러리인 testing-library를 사용해서 테스트하는 방법을 알아보자.
리액트 훅은 2018년 말 React 16.8에 추가되었다. 리액트 훅을 사용해서 상태 값과 여러 리액트의 기능을 사용할 수 있다.
이전부터 리액트를 사용해 왔다면 render props와 고차 컴포넌트와 같은 패턴을 통해 문제를 해결할 수 있지만 이런 패턴의 사용은 컴포넌트의 재구성을 강요하며, 코드의 추적을 어렵게 만든다. 리액트 훅을 사용하면 컴포넌트로부터 상태 관련 로직을 추상화 할 수 있다. 이를 이용해 독립적인 테스트와 재사용이 가능하다.
또한 리액트 훅을 사용할 때는 몇 가지 규칙이 있다.
여기서 2번 규칙을 지키기 위해 일반적인 자바스크립트 함수에서 리액트 훅을 호출하지 말고 리액트 함수 컴포넌트 또는 커스텀 훅을 통해서 훅을 호출할 수 있다.
이 글에서는 리액트 커스텀 훅을 작성하는 방법과 훅 테스트를 작성하는 방법에 대해서 설명한다.
리액트 훅에 대한 더 자세한 설명은 리액트 훅 공식문서를 참고하자.
필자는 여러 컴포넌트에서 중복해서 사용되는 로직을 공유하기 위해서 커스텀 훅을 사용했다. 다음의 예시를 살펴보자.
import { useEffect, useState } from 'react';
import Page from '@common/types/page';
type PageMap = { [id in string]?: Page };
const CachePageMap: PageMap = {};
export const DEFAULT_PAGE: Page = { /* ... */ };
const usePage = (pageId: string, defaultPage = DEFAULT_PAGE) => {
const [data, setData] = useState<Page>(defaultPage);
const [isLoading, setLoading] = useState(true);
useEffect(() => {
// cacheID는 캐시된 값을 찾아오기 위한 유일한 값이다.
const cacheID = pageId;
// 먼저 캐시가 존재하는지 확인하고 만약 존재한다면 값을 설정한다.
const cachePage = CachePageMap[cacheID];
if (cachePage !== undefined) {
setData(cachePage);
setLoading(false);
} else {
// 캐시가 존재하지 않는다면 로딩 상태를 true로 만들고 값을 초기화해야 한다.
setLoading(true);
setData(defaultPage);
}
// 새로운 데이터를 fetch한다.
const url = `/get-page/${pageId}`;
fetch(url)
.then((res) => res.json())
.then((newData) => {
CachePageMap[cacheID] = newData.data;
setData(newData.data);
setLoading(false);
});
}, [pageId, defaultPage]);
return [data, isLoading];
};
export default usePage;
usePage
는 캐시된 데이터가 존재하면 이를 반환하고 그동안 URL에서 데이터를 가져오는 커스텀 훅이다. 페이지 정보를 간단한 API를 사용해서 가져오는데, 이를 캐시 할 수 있는 공간에 저장한 뒤 페이지 리스트와 페이지 본문 등 다양한 컴포넌트에서 페이지 정보가 필요하다. 또한 프리젠테이셔널 컴포넌트-컨테이너 컴포넌트 구조를 통해 데이터를 가져오는 부분을 분리하였기 때문에, 여러 곳에서 같은 로직이 반복되어 사용되는 것을 쉽게 확인할 수 있었고 이를 해결하기 위해 커스텀 훅을 작성하게 되었다.
실제 프로젝트에서는 redux
를 사용한 상태관리 라이브러리를 사용하고 데이터가 없을 때만 fetch 하는 로직과 실제 업데이트를 위한 로직을 따로 두었으나 이 글에서는 이해를 돕기 위해 간단한 in-memory 저장소를 사용하고 호출 시마다 페이지를 업데이트한다.
또한 isLoading
값을 통해서 아직 사용할 수 있는 데이터나 캐시가 없는 경우 true를 반환한다. 이를 통해서 페이지를 불러오는 로딩 표시를 할 수 있다.
개발에 참여했던 Dooray 에서는 컴포넌트를 테스트하기 위해 testing-library를 적극적으로 사용하고 있다. 이 글에서는 이를 통해 위 커스텀 훅을 테스트하는 방법을 알아볼 것이다.
먼저 이 커스텀 훅을 어떻게 사용할지 생각해보자.
usePage
에 pageId1
이 전달되면 캐시에 존재하지 않기 때문에 isLoading
은 true
로 data
는 DEFAULT_PAGE
로 초기화된다.data
를 새로운 페이지 데이터 page1
으로 업데이트하고 isLoading
은 false
가 된다.pageId2
가 전달되면 1~2번 과정을 통해 page2
페이지 데이터를 가져온다.pageId1
이 전달되면 기존에 캐시 되었던 page1
을 즉시 가져온다.data
와 캐시를 새로운 데이터인 newPage1
로 업데이트한다.여기까지 이해했다면 이제 테스트를 작성하기만 하면 된다!
리액트 커스텀 훅을 테스트하기 위해서 여러 가지 방법을 사용할 수 있다.
renderHook
을 사용한다.아래의 간단한 테스트를 확인해보자.
describe('usePage', () => {
it('usePage를 새로운 pageId와 호출하면 isLoading은 true다.', () => {
const [pageData, isLoading] = usePage('pageId1');
expect(isLoading).toBe(true);
});
});
위 코드는 Invalid hook call 을 던지며 실패한다. 위에서 살펴보았듯이 리액트 훅의 두 번째 조건인 "오직 리액트 함수 내에서만 훅을 호출해야 한다." 의 조건을 지키지 않았기 때문이다.
다른 테스트 라이브러리인 Enzyme
의 shallow, mount 를 통해서 2번 방식인 테스트 컴포넌트를 만들고 해당 컴포넌트를 테스트할 수 있으나 이 글에서는 소개하지 않는다.
필자가 진행했던 Dooray 프로젝트에서는 testing-library를 적극적으로 사용 중이다. React Testing Library는 React 컴포넌트를 테스트하기 위해 설계된 라이브러리다. 혹시 이에 대해서 더 궁금하다면 TOAST UI 위클리 에서 번역된 React Testing Library를 이용한 선언적이고 확장 가능한 테스트를 먼저 보고 오자.
@testing-library/react-hooks
를 사용하면 리액트 커스텀 훅을 간단하게 테스트 할 수 있다. react-hooks-testing-library
를 사용할 때는 react
, react-test-renderer
, 그리고 react-dom
의 버전을 유의해야 한다.
이를 사용해서 커스텀 훅을 렌더링하는 코드를 작성해보자.
import { renderHook } from '@testing-library/react-hooks';
const setupRenderCustomHook = (pageId: string, defaultPage?: Page) => {
return renderHook(({ pageId }) => usePage(pageId, defaultPage), {
initialProps: {
pageId,
},
});
};
usePage를 렌더링했다! 이제 우리는 setupRenderCustomHook
를 통해서 커스텀 훅의 결과를 가져올 수 있게 되었다. 위에서 잘못 작성했던 커스텀 훅 테스트를 변경해보자.
describe('usePage', () => {
it('usePage를 새로운 pageId와 호출하면 isLoading은 true이다.', () => {
const { result } = setupRenderCustomHook('pageId1');
const [, isLoading] = result.current;
expect(isLoading).toBe(true);
});
});
하지만 테스트는 실패한다. 아직 fetch
모킹 함수가 필요하고 result.current
가 뭔지도 잘 모르겠다. isLoading 뿐만 아니라 데이터도 테스트 해야 한다. 차근차근 하나씩 작성해보자.
일단 실패하는 테스트를 통과시키기 위해서 fetch 함수를 모킹해보자.
// ...
const fetchMock = (url: string, suffix = '') => {
return new Promise((resolve) =>
setTimeout(() => {
resolve({
json: () =>
Promise.resolve({
data: {
name: url,
creator: suffix + url,
} as Page,
}),
});
}, 50 + Math.random() * 150),
);
};
// ...
describe('usePage', () => {
// 테스트 실행전에 모킹한다.
beforeAll(() => {
global.fetch = jest.fn(fetchMock) as jest.Mock;
});
it( /* ... */ );
});
실제 API 환경과 유사하게 작동하도록 유도하기 위해서 50~200ms 사이의 임의 지연 값을 추가한다. fetch가 실패하거나 올바르지 않은 데이터 형식에 대한 케이스는 이 글에서 다루지 않는다.
이제 첫 번째 테스트를 통과한다! 위에서 작성했던 테스트 케이스를 기억해보자.
usePage
에pageId1
이 전달되면 캐시에 존재하지 않기 때문에isLoading
은true
로data
는DEFAULT_PAGE
로 초기화된다.
isLoading
만 검사하기에는 테스트가 부실하다. 좀 더 보강하자.
describe('usePage', () => {
// ...
it('usePage에 pageId이 전달되면 캐시에 존재하지 않기 때문에 isLoading은 true로 data는 DEFAULT_PAGE로 초기화된다.', () => {
const { result } = setupRenderCustomHook('pageId1');
const [defaultPage, isLoading] = result.current;
expect(defaultPage).toBe(DEFAULT_PAGE);
expect(isLoading).toBe(true);
});
});
두 번째 테스트 케이스를 작성하기 전에 테스트 케이스를 생각해보자.
- 비동기 fetch 요청 지연 이후에
data
를 새로운 페이지 데이터page1
으로 업데이트하고isLoading
은false
가 된다.
비동기 작업 지연을 기다리기 위해 여러 방법을 사용할 수 있지만, react-hooks-testing-library
에서는 이를 위한 다양한 API를 제공하고 있다.
이쯤에서 우리가 사용할 renderHook
의 반환값에 대해서 소개한다.
result
current
값을 통해서 전달된 콜백에서 반환하는 최신 값을 반영한다. error
는 가장 최근 던져진 에러를 저장한다. all
은 콜백에서 반환된 모든 값을 포함한다.
type RenderResult = {
all: Array<any>;
current: any;
error: Error;
}
describe('usePage', () => {
// ...
it('비동기 fetch 요청 지연 이후에 data를 새로운 페이지 데이터 page1으로 업데이트하고 isLoading은 false가 된다.', async () => {
const { result, waitForNextUpdate } = setupRenderCustomHook('pageId1');
expect(global.fetch).toHaveBeenCalledWith('/get-page/pageId1');
await waitForNextUpdate();
const [page1, isLoading] = result.current;
expect(isLoading).toBe(false);
expect(page1).toEqual({
name: '/get-page/pageId1',
creator: '/get-page/pageId1',
});
});
});
가장 먼저 fetch 함수가 호출된 것을 확인 한뒤, waitForNextUpdate
함수를 통해 비동기 지연 작업의 결과가 data를 변경할 때까지 기다리고, data
와 isLoading
값을 검증한다. result.current
는 콜백에서 반환하는 최신 값을 반환하므로 정상적으로 결과가 나온 것을 확인할 수 있다.
3번 테스트를 두 단계로 나누어 테스트를 작성해보자.
- 커스텀 훅에 새로운 아이디인
pageId2
가 전달되면 1~2번 과정을 통해page2
페이지 데이터를 가져온다.
3-1. usePage
호출 이후에 캐시되지 않은 pageId2
가 전달되면 isLoading
은 true
로, data
는 DEFAULT_PAGE
로 초기화한다.
3-2. usePage
호출 이후에 캐시되지 않은 pageId2
가 전달되고 비동기 fetch 요청 지연 이후에 isLoading
은 false
로, data
는 page2
로 변경한다.
기존에 작성했던 테스트와 유사하나 커스텀 훅을 렌더링한 이후에 새로운 PageId를 사용하기 위해 rerender
를 사용한다.
describe('usePage', () => {
it('usePage호출 이후에 캐시되지 않은 pageId가 전달되면 isLoading은 true로, data는 DEFAULT_PAGE로 초기화된다.', async () => {
const { result, waitForNextUpdate, rerender } =
setupRenderCustomHook('pageId1');
await waitForNextUpdate();
rerender({ pageId: 'pageId2' });
const [defaultPage, isLoading] = result.current;
expect(isLoading).toBe(true);
expect(defaultPage).toBe(DEFAULT_PAGE);
});
it('usePage호출 이후에 캐시되지 않은 pageId가 전달되고 비동기 fetch 요청 지연 이후에 isLoading은 false로, data는 page2로 초기화된다.', async () => {
const { result, waitForNextUpdate, rerender } =
setupRenderCustomHook('pageId1');
await waitForNextUpdate();
rerender({ pageId: 'pageId2' });
await waitForNextUpdate();
const [page2, isLoading] = result.current;
expect(isLoading).toBe(false);
expect(page2).toEqual({
name: '/get-page/pageId2',
creator: '/get-page/pageId2',
});
});
});
마지막으로 캐시 된 페이지를 가져오는 동작을 테스트로 작성하자.
- 커스텀 훅에 캐시 된 아이디인
pageId1
이 전달되면 기존에 캐시 되었던page1
을 즉시 가져온다.- 비동기 fetch 요청 이후에 의해
data
와 캐시를 새로운 데이터인newPage1
로 업데이트한다.
describe('usePage', () => {
it('usePage에 캐시된 pageId가 전달되면 isLoading은 false로, data는 캐시된 page로 초기화된다.', async () => {
const { result, waitForNextUpdate, rerender } =
setupRenderCustomHook('pageId1');
await waitForNextUpdate();
rerender({ pageId: 'pageId2' });
await waitForNextUpdate();
rerender({ pageId: 'pageId1' });
const [page1, isLoading] = result.current;
expect(isLoading).toBe(false);
expect(page1).toEqual({
name: '/get-page/pageId1',
creator: '/get-page/pageId1',
});
});
it('캐시된 데이터도 비동기 fetch 요청 지연 이후에 data를 새로운 페이지 데이터로 업데이트한다.', async () => {
const { result, waitForNextUpdate, rerender } =
setupRenderCustomHook('pageId1');
await waitForNextUpdate();
rerender({ pageId: 'pageId2' });
await waitForNextUpdate();
rerender({ pageId: 'pageId1' });
const [page1] = result.current;
await waitForNextUpdate();
const [newPage1] = result.current;
expect(page1).not.toBe(newPage1);
});
});
이 외에 더욱 복잡한 컨테이너 설계에서 사용하게 되면, 훅만 직접 렌더링하는 것보다 훅을 감싸는 컴포넌트를 렌더링하는 것이 유리할 때가 있다. 이때는 renderHook
의 옵션 중 wrapper 설정을 사용하면 된다.
필자는 프로젝트에서 redux store를 모킹하는 컴포넌트가 필요해 아래와 같이 작성하였다.
const setupRenderCustomHookWithWrapper = (
state: any,
pageId: string,
defaultPage?: Page,
) => {
const store = {
getState: () => state,
dispatch: () => {},
subscribe: () => () => null,
} as unknown as Store;
return renderHook(({ pageId }) => usePage(pageId, defaultPage), {
initialProps: {
pageId,
},
wrapper: ({ children }) => (
<ReduxProvider store={store}>{children}</ReduxProvider>
),
});
};
이상으로 리액트 훅에 대한 간단한 개요와 리액트 커스텀 훅을 테스트하는 방법에 대해서 알아보았다. 테스트 코드는 Github 에서 확인 할 수 있다.
프로젝트에서 데이터를 가져오는 부분을 커스텀 훅으로 분리하고 테스트를 작성함으로써 다음의 장점을 가질 수 있다.
https://ui.toast.com/weekly-pick/ko_20210630/
https://reactjs.org/docs/hooks-intro.html
https://reactjs.org/docs/hooks-rules.html
https://reactjs.org/docs/hooks-custom.html
https://www.toptal.com/react/testing-react-hooks-tutorial
https://blog.logrocket.com/a-quick-guide-to-testing-react-hooks-fa584c415407/
https://blog.openreplay.com/an-easy-guide-to-testing-react-hooks
https://dev.to/harshkc/a-quick-guide-to-testing-custom-react-hooks-48ce
https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0
https://github.com/testing-library/react-hooks-testing-library
https://testing-library.com/docs/react-testing-library/intro/