커스텀 훅 테스팅을 통해 프론트엔드 테스트 분리하기

오형근·2023년 9월 8일
8

Testing

목록 보기
5/5
post-thumbnail

프론트엔드 테스팅에 대해 다뤄보는 다섯 번째 시간이다.

이전부터 즐겨 시청하던 채널인 개발바닥의 호돌맨님께서 프론트엔드 테스팅에서 뷰와 로직 간의 테스팅을 분리하는 방법을 고심하고 있다는 얘기를 들은 적이 있었는데, 최근 테스팅을 공부하면서 나 또한 이와 같은 고민을 해 왔다.

그리고 최근에 react-hooks-testing-library라는 흥미로운 라이브러리를 발견하게 되었고, 이를 사용하여 온전하게는 아닐지라도 공통적으로 사용되는 리액트 커스텀 훅들에 대한 테스팅을 진행할 수 있음을 알게 되어 공부하고 정리해보고자 한다.


React Custom Hook?

리액트에서는 use- 라는 접두사를 활용한 함수 선언을 통해 사용자가 리액트 코드 내에서 재사용되는 로직을 캡슐화하여 유지보수성을 높일 수 있도록 커스텀 훅 기능을 제공하고 있다.

그럼 그냥 재사용되는 로직 함수로 따로 정의하는 거랑 뭐가 다른데요?

커스텀 훅 내부에서는 실제 리액트 컴포넌트와 같이 useState, useEffect 등의 리액트 훅을 사용할 수 있다. 본래 리액트 훅은 리액트 컴포넌트 밖에서 사용이 불가하다는 까다로운 제약 조건이 걸려 있는데, 커스텀 훅을 사용하면 리액트 훅 선언에 걸려 있는 제약을 상당 부분 풀어낼 수 있다.

어떻게 사용하지?

예시를 간략하게 들어보면, 아래 코드와 같다.

import { useState } from 'react';

export default function useSample() {
  const [isOpen, setIsOpen] = useState(false);

  return {
    isOpen,
    setIsOpen,
  };
}

위의 코드처럼 이름에 use- 접두사를 붙인 커스텀 훅을 선언하여 사용하면 따로 컴포넌트를 반환하지 않아도 내부적으로 useState를 활용하는 함수를 제작할 수 있다.

커스텀 훅은 재사용 되는 리액트 로직을 캡슐화할 수 있다는 장점 뿐 아니라, 일반적인 로직에서 리액트 훅을 쉽게 활용할 수 있다는 점 또한 존재한다. 이를 통해 우리는 일반적인 비즈니스 로직에도 다양한 상태를 사용하고, 이를 활용한 다채로운 프론트엔드 코드를 작성할 수 있게 되었다.

Custom Hook Testing

그렇다면 이러한 커스텀 훅은 테스트할 수 있을까?

기존에는 커스텀 훅을 테스팅하려면 해당 훅을 사용하는 임의의 컴포넌트를 선언하고, 그 컴포넌트를 렌더링하는 별도의 과정을 통해 테스팅을 진행했어야만 했다. 아래 예시를 살펴보자.

import React from 'react';
import { render } from '@testing-library/react';
import useIsOpen from './useIsOpen';

test('isOpen의 초기값은 false다', () => {
  let result = {} as ReturnType<typeof useIsOpen>;

  const Wrapper = () => {
    result = useIsOpen();
    return null;
  };

  render(<Wrapper />);

  expect(result.isOpen).toBe(false);
});

확인해 보면 아무래도 추가적인 Wrapper 컴포넌트를 내부적으로 만들어 주어야 하는 불필요한 코드가 들어가 있어 좋은 코드라고 보기 어렵다...

그래서 이를 개선하기 위한 라이브러리가 이미 존재하는데, 바로 react-hooks-testing-library이다!!

React Hooks Testing Library

요친구는 커스텀 훅을 테스트하기 위한 목적으로 만들어졌으며, 내장 함수인 renderHook을 통해 커스텀 훅을 불러오도록 한다. 아래 코드를 살펴보자.

Custom hook testing

import useTest from "../hooks/useTest";
import { renderHook } from "@testing-library/react-hooks";

test("isOpen의 초깃값은 false다", () => {
  const { result } = renderHook(() => useTest());
  expect(result.current.isOpen).toBe(false);
});

위의 코드처럼 renderHook 함수를 사용해서 반환된 result 내부의 current 속성에 접근하면 실제 커스텀 훅이 반환하는 값들을 가져올 수 있고, 이를 테스트할 수 있다.

여기에서 current 속성을 사용하여 값을 가져오는 이유는, 커스텀 훅인 만큼 컴포넌트 내에서 여러 번 호출될 수 있음을 감안하여 마지막으로 호출되었을 때의 값을 반환하기 위해서이다!!

State update testing

물론 커스텀 훅 내부 상태를 업데이트하는 동적인 로직도 작성이 가능하다. 아래 코드를 살펴보자.

import useTest from "../hooks/useTest";
import { renderHook, act } from "@testing-library/react-hooks";

test("setIsOpen을 이용해 내부 상태를 변경해줄 수 있다.", () => {
  const { result } = renderHook(() => useTest());

  act(() => {
    result.current.setIsOpen((prev) => !prev);
  });

  expect(result.current.isOpen).toBe(true);
});

간단하다. 본래 @testing-library/test-utils에 존재하는 메서드인 act를 활용해서 동적인 로직을 담아내면 된다. 위의 코드에서는 react-hooks 라이브러리에서 가져오고 있지만, import하는 곳과 무관하게 동일하게 동작하는 것 같다. 그러나 역할과 상황에 맞는 라이브러리의 기능을 끌어와서 사용하는 것이 권장되기 때문에, @testing-library/test-utils에서 메서드를 가져오자.

실제로 act의 경우 컴포넌트를 렌더링하는 테스트에서 상태 업데이트를 안전하게 실행해주기 위해 사용하는 메서드이기 때문에 커스텀 훅 테스트에서는 굳이 act로 래핑하지 않아도 무방하지만, 동적인 로직은 에러의 주 원인이 될 수 있으므로 이왕이면 감싸주는 습관을 들이자.

useEffect 로직이 들어간 테스트

기본적으로 React 진영에서, useEffect라는 훅은 기본 훅이지만 side effect를 발생시키기 좋은 훅이기 때문에 남발되는 것을 권장하지 않고, 꼭 필요한 사항(애플리케이션 외부와의 연결이 주)에만 사용하기를 권장하고 있다.

그만큼 useEffect를 사용한 로직은 일어날 수 있는 side effect를 예측하기 어렵기 때문에, 우리는 더욱이 이를 테스팅할 필요성을 가진다.

그러면 어떻게 해야할까?

아래 코드를 살펴보자. useEffect를 통해 상태값을 변경해주는 간단한 컴포넌트이다.

import { useState, useEffect } from 'react';

const useTest = ({ initialValue }: { initialValue: boolean }) => {
  const [test, setTest] = useState(initialValue);

  useEffect(() => {
    if (initialValue) {
      setTest(initialValue);
    }
  }, [initialValue]);

  return {
    test,
    setTest,
  };
}

이와 같이 useEffect를 통해 컴포넌트 외부에서 props로 전달받은 뒤 내부 상태의 값을 변경하는 로직이 있다.

여기에서 테스트 시나리오를 생각해보면,

특정 value로 컴포넌트 초기화 => 새로운 value를 지정해 컴포넌트 내에 새로운 props가 전달되는 상황을 mock

이를 코드로 나타내면 아래와 같아진다.

import useTest from "../hooks/useTest";
import { renderHook } from '@testing-library/react-hooks';

test('initialValue 변경은 test 상태에 반영된다.', () => {
  const { result, rerender } = renderHook((props) => useTest(props), {
    initialProps: {
      initialValue: false,
    },
  });

  expect(result.current.test).toBe(false);

  rerender({
    initialPage: true,
  });

  expect(result.current.test).toBe(true);
});

위에서 볼 수 있듯이, renderHook 내부 객체에서 rerender라는 메서드를 꺼내와 rerender함으로써 props가 변경되는 상황을 연출할 수 있다!

Context(Recoil, ContextAPI...)를 사용하는 커스텀 훅의 테스트

Recoil, Redux, ContextAPI 등 Provider를 제공하는 상태관리 툴들에 대해서도 테스팅을 진행할 수 있다.

방법 자체는 어렵지 않다. 처음 render를 할 때 각각에 맞는 Provider(Root)로 감싸주고, 이후 테스트 코드에서 wrapper라는 속성에 명시를 해주면 된다.

아래 예시를 살펴보자.

import { RecoilRoot } from 'recoil';
import { useData } from './hooks'; // recoil을 사용하는 커스텀 훅

const Wrapper: React.FC = ({ children }) => {
  return (
    /** Recoil의 훅 사용을 위해 RecoilRoot로 컴포넌트를 래핑한다  */
    <RecoilRoot>{children}</RecoilRoot>
  );
};

test('some state', () => {
  const { result } = renderHook(() => useData(), {
    wrapper: Wrapper,
  });

  expect(result.current.data).toBe(null);
});

위의 코드처럼 실제 렌더링 시에 Provider로 감싸주고, 테스트 코드에서 이를 wrapper라는 속성에서 명시를 해주는 방식으로 구현하면 테스트할 수 있다.

비동기 로직이 포함된 커스텀 훅의 테스트

실무에서는 백엔드 API 호출을 통해 데이터를 주입하는 형태의 로직이 많기 때문에, 비동기 로직을 테스트하는 것은 불가피하다.

그렇다면 비동기 훅은 어떻게 테스트하지?

RTL 에서 제공하는 waitForNextUpdate 메서드를 활용하면 비동기 로직을 기다릴 수 있다. 이 코드는 일반적인 testing-library에서의 waitFor와는 조금 다르게 콜백 함수가 없는데, 단순하게 비동기 로직을 기다리는 형태로 사용된다.

아래 코드를 살펴보자.

import { renderHook } from '@testing-library/react-hooks';
import useAsyncCounter from './useAsyncCounter';

test('setTimeout을 사용하면 비동기적으로 상태가 업데이트된다', async () => {
  const { result, waitForNextUpdate } = renderHook(() => useAsyncCounter());

  expect(result.current.count).toEqual(3);

  await waitForNextUpdate(); // 호출하지 않으면 테스트가 실패한다

  expect(result.current.count).toEqual(1000);
});

위 예시에서 waitForNextUpdate 가 반환하고 있는 Promise의 경우 비동기 로직에 의해 컴포넌트가 다시 렌더링 된 직후에 resolve된다.

비동기 로직 완료 => 변화에 의한 컴포넌트 리렌더 => 리렌더를 감지한 waitForNextUpdate resolve

여기서 요점은, waitForNextUpdate비동기를 기다리는 것이 아니라 컴포넌트 리렌더링을 기다린다는 점이다. 이 점을 활용하면 waitForNextUpdate를 조금 더 폭 넓은 용도로 사용할 수 있지 않을까 생각이 들었고, 이후에 아이디어가 떠오른다면 이를 적극적으로 활용해보고 싶다.

Reference

React 커스텀 훅 함수의 테스트 코드 작성

질문 및 피드백 댓글은 언제나 환영입니다!

0개의 댓글