리액트 커스텀 훅을 테스트하는 과정


최근 협업 도구인 Dooray! 개발을 담당했었다. 두레이에서는 현재 웹 리뉴얼 작업과 함께 타입스크립트와 리액트 프레임워크로의 전환을 진행하고 있다. 이 과정에서 사용하게 된 커스텀 훅과 이를 테스트 라이브러리인 testing-library를 사용해서 테스트하는 방법을 알아보자.

리액트 훅

리액트 훅은 2018년 말 React 16.8에 추가되었다. 리액트 훅을 사용해서 상태 값과 여러 리액트의 기능을 사용할 수 있다.

이전부터 리액트를 사용해 왔다면 render props고차 컴포넌트와 같은 패턴을 통해 문제를 해결할 수 있지만 이런 패턴의 사용은 컴포넌트의 재구성을 강요하며, 코드의 추적을 어렵게 만든다. 리액트 훅을 사용하면 컴포넌트로부터 상태 관련 로직을 추상화 할 수 있다. 이를 이용해 독립적인 테스트와 재사용이 가능하다.

또한 리액트 훅을 사용할 때는 몇 가지 규칙이 있다.

  1. 반복문, 조건문 혹은 중첩된 함수 내에서 훅을 호출하지 마라.
  2. 오직 리액트 함수 내에서만 훅을 호출해야 한다.

여기서 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를 적극적으로 사용하고 있다. 이 글에서는 이를 통해 위 커스텀 훅을 테스트하는 방법을 알아볼 것이다.

테스트 케이스 명세

먼저 이 커스텀 훅을 어떻게 사용할지 생각해보자.

  1. usePagepageId1이 전달되면 캐시에 존재하지 않기 때문에 isLoadingtruedataDEFAULT_PAGE로 초기화된다.
  2. 비동기 fetch 요청 지연 이후에 data를 새로운 페이지 데이터 page1으로 업데이트하고 isLoadingfalse가 된다.
  3. 커스텀 훅에 새로운 아이디인 pageId2가 전달되면 1~2번 과정을 통해 page2 페이지 데이터를 가져온다.
  4. 커스텀 훅에 캐시 된 아이디인 pageId1이 전달되면 기존에 캐시 되었던 page1을 즉시 가져온다.
  5. 비동기 fetch 요청 이후에 의해 data와 캐시를 새로운 데이터인 newPage1로 업데이트한다.

image.png

여기까지 이해했다면 이제 테스트를 작성하기만 하면 된다!

테스트 작성

리액트 커스텀 훅을 테스트하기 위해서 여러 가지 방법을 사용할 수 있다.

  1. 라이브러리 없이 훅 테스트하기
  2. 커스텀 훅을 사용하는 테스트 컴포넌트를 만들고 해당 컴포넌트를 테스트한다.
  3. @testing-library/react-hooksrenderHook을 사용한다.

아래의 간단한 테스트를 확인해보자.

describe('usePage', () => {
  it('usePage를 새로운 pageId와 호출하면 isLoading은 true다.', () => {
    const [pageData, isLoading] = usePage('pageId1');

    expect(isLoading).toBe(true);
  });
});

위 코드는 Invalid hook call 을 던지며 실패한다. 위에서 살펴보았듯이 리액트 훅의 두 번째 조건인 "오직 리액트 함수 내에서만 훅을 호출해야 한다." 의 조건을 지키지 않았기 때문이다.

스크린샷 2022-03-08 오후 4.25.59.png

다른 테스트 라이브러리인 Enzymeshallow, 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가 실패하거나 올바르지 않은 데이터 형식에 대한 케이스는 이 글에서 다루지 않는다.

이제 첫 번째 테스트를 통과한다! 위에서 작성했던 테스트 케이스를 기억해보자.

  1. usePagepageId1이 전달되면 캐시에 존재하지 않기 때문에 isLoadingtruedataDEFAULT_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);
  });
});

두 번째 테스트 케이스를 작성하기 전에 테스트 케이스를 생각해보자.

  1. 비동기 fetch 요청 지연 이후에 data를 새로운 페이지 데이터 page1으로 업데이트하고 isLoadingfalse가 된다.

비동기 작업 지연을 기다리기 위해 여러 방법을 사용할 수 있지만, react-hooks-testing-library에서는 이를 위한 다양한 API를 제공하고 있다.

이쯤에서 우리가 사용할 renderHook의 반환값에 대해서 소개한다.

  • result

    • current 값을 통해서 전달된 콜백에서 반환하는 최신 값을 반영한다. error는 가장 최근 던져진 에러를 저장한다. all은 콜백에서 반환된 모든 값을 포함한다.

      type RenderResult = {
      all: Array<any>;
      current: any;
      error: Error;
      }
  • waitForNextUpdate: 훅이 다시 렌더링 되는 순간에 resolve되는 Promise 객체를 반환한다. 보통 비동기 업데이트의 결과를 통해 state를 업데이트 할 때 업데이트된다.
  • rerender: 테스트 컴포넌트를 리렌더링해 훅이 다시 계산되도록 하는 함수이다. newProps가 통과되면, 리렌더를 위해 콜백 기능의 initialProps를 대체하게 된다.
  • 이외의 API 명세는 공식 문서를 참고하자.
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를 변경할 때까지 기다리고, dataisLoading 값을 검증한다. result.current는 콜백에서 반환하는 최신 값을 반환하므로 정상적으로 결과가 나온 것을 확인할 수 있다.

3번 테스트를 두 단계로 나누어 테스트를 작성해보자.

  1. 커스텀 훅에 새로운 아이디인 pageId2가 전달되면 1~2번 과정을 통해 page2 페이지 데이터를 가져온다.

3-1. usePage호출 이후에 캐시되지 않은 pageId2가 전달되면 isLoadingtrue로, dataDEFAULT_PAGE로 초기화한다.
3-2. usePage호출 이후에 캐시되지 않은 pageId2가 전달되고 비동기 fetch 요청 지연 이후에 isLoadingfalse로, datapage2로 변경한다.

기존에 작성했던 테스트와 유사하나 커스텀 훅을 렌더링한 이후에 새로운 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',
    });
  });
});

마지막으로 캐시 된 페이지를 가져오는 동작을 테스트로 작성하자.

  1. 커스텀 훅에 캐시 된 아이디인 pageId1이 전달되면 기존에 캐시 되었던 page1을 즉시 가져온다.
  2. 비동기 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 에서 확인 할 수 있다.

프로젝트에서 데이터를 가져오는 부분을 커스텀 훅으로 분리하고 테스트를 작성함으로써 다음의 장점을 가질 수 있다.

  1. 컨테이너 컴포넌트에서 커스텀 훅을 호출하는 것으로 데이터를 가져올 수 있어 코드가 간결해진다.
  2. 컨테이너 컴포넌트에서 커스텀 훅의 테스트를 할 필요가 없다.
  3. 데이터를 가져오는 중복 코드를 줄일 수 있다.

참고

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/

조종현2022.03.11
Back to list