프론트엔드 테스팅에 대해 다뤄보는 다섯 번째 시간이다.
이전부터 즐겨 시청하던 채널인 개발바닥의 호돌맨님께서 프론트엔드 테스팅에서 뷰와 로직 간의 테스팅을 분리하는 방법을 고심하고 있다는 얘기를 들은 적이 있었는데, 최근 테스팅을 공부하면서 나 또한 이와 같은 고민을 해 왔다.
그리고 최근에 react-hooks-testing-library
라는 흥미로운 라이브러리를 발견하게 되었고, 이를 사용하여 온전하게는 아닐지라도 공통적으로 사용되는 리액트 커스텀 훅들에 대한 테스팅을 진행할 수 있음을 알게 되어 공부하고 정리해보고자 한다.
리액트에서는 use-
라는 접두사를 활용한 함수 선언을 통해 사용자가 리액트 코드 내에서 재사용되는 로직을 캡슐화하여 유지보수성을 높일 수 있도록 커스텀 훅 기능을 제공하고 있다.
그럼 그냥 재사용되는 로직 함수로 따로 정의하는 거랑 뭐가 다른데요?
커스텀 훅 내부에서는 실제 리액트 컴포넌트와 같이 useState, useEffect 등의 리액트 훅을 사용할 수 있다. 본래 리액트 훅은 리액트 컴포넌트 밖에서 사용이 불가하다는 까다로운 제약 조건이 걸려 있는데, 커스텀 훅을 사용하면 리액트 훅 선언에 걸려 있는 제약을 상당 부분 풀어낼 수 있다.
예시를 간략하게 들어보면, 아래 코드와 같다.
import { useState } from 'react';
export default function useSample() {
const [isOpen, setIsOpen] = useState(false);
return {
isOpen,
setIsOpen,
};
}
위의 코드처럼 이름에 use- 접두사를 붙인 커스텀 훅을 선언하여 사용하면 따로 컴포넌트를 반환하지 않아도 내부적으로 useState를 활용하는 함수를 제작할 수 있다.
커스텀 훅은 재사용 되는 리액트 로직을 캡슐화할 수 있다는 장점 뿐 아니라, 일반적인 로직에서 리액트 훅을 쉽게 활용할 수 있다는 점 또한 존재한다. 이를 통해 우리는 일반적인 비즈니스 로직에도 다양한 상태를 사용하고, 이를 활용한 다채로운 프론트엔드 코드를 작성할 수 있게 되었다.
그렇다면 이러한 커스텀 훅은 테스트할 수 있을까?
기존에는 커스텀 훅을 테스팅하려면 해당 훅을 사용하는 임의의 컴포넌트를 선언하고, 그 컴포넌트를 렌더링하는 별도의 과정을 통해 테스팅을 진행했어야만 했다. 아래 예시를 살펴보자.
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
이다!!
요친구는 커스텀 훅을 테스트하기 위한 목적으로 만들어졌으며, 내장 함수인 renderHook
을 통해 커스텀 훅을 불러오도록 한다. 아래 코드를 살펴보자.
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 속성을 사용하여 값을 가져오는 이유는, 커스텀 훅인 만큼 컴포넌트 내에서 여러 번 호출될 수 있음을 감안하여 마지막으로 호출되었을 때의 값을 반환하기 위해서이다!!
물론 커스텀 훅 내부 상태를 업데이트하는 동적인 로직도 작성이 가능하다. 아래 코드를 살펴보자.
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
로 래핑하지 않아도 무방하지만, 동적인 로직은 에러의 주 원인이 될 수 있으므로 이왕이면 감싸주는 습관을 들이자.
기본적으로 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가 변경되는 상황을 연출할 수 있다!
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
를 조금 더 폭 넓은 용도로 사용할 수 있지 않을까 생각이 들었고, 이후에 아이디어가 떠오른다면 이를 적극적으로 활용해보고 싶다.
질문 및 피드백 댓글은 언제나 환영입니다!