Logo

React Testing Library - 비동기 테스트

이번 포스팅에서는 React Testing Library(RTL)로 비동기(asynchronous)로 작동하는 컴포넌트를 테스트하는 방법에 대해서 알아보겠습니다.

예제 컴포넌트

우선 테스트 대상이 될 간단한 React 컴포넌트 하나를 작성해보도록 하겠습니다.

아래 <Switch/> 컴포넌트 함수는 on 상태값에 따라 ON 버튼 또는 OFF 버튼을 리턴합니다. on 상태값은 최초에는 false이지만 버튼을 클릭할 때 마다 true, false, …로 번갈아 가면서 바뀝니다. 상태값이 바뀌는데는 0.5초(500ms)가 걸리며, 상태값이 바뀌는 동안에는 버튼은 클릭이 불가능하도록 비활성화 됩니다.

Switch.js
import React, { useState } from "react";

function Switch() {
  const [disabled, setDisabled] = useState(false);
  const [on, setOn] = useState(false);

  const handleClick = () => {
    setDisabled(true);
    // TODO: clean up
    setTimeout(() => {
      setOn(!on);
      setDisabled(false);
    }, 500);
  };

  return (
    <button disabled={disabled} onClick={handleClick}>
      {on ? "ON" : "OFF"}
    </button>
  );
}

export default Switch;

참고로 본 포스팅에서는 중요한 부분이 아니라서 setTimeout() 함수가 리턴하는 객체를 의도적으로 정리하지 않았습니다. 따라서 예제 코드는 메모리 누수가 발생할 수 있으며 상용 환경에서 사용하기는 부적하다는 점 주의 바랍니다.

초기 상태 테스트

<Switch/> 컴포넌트는 처음에는 OFF를 보여주고 클릭이 가능한 상태여야 합니다. 이 부분은 React Testing Library를 사용하는 전형적인 방법으로 테스트 코드를 작성할 수 있습니다.

Switch.test.js
test("OFF button is enabled initially", () => {
  render(<Switch />);

  const button = screen.getByRole("button");

  expect(button).toHaveTextContent("OFF");
  expect(button).toBeEnabled();
});

getByRole() 함수로 DOM 상의 버튼 엘리먼트를 선택하고, 해당 버튼이 OFF라는 텍스트를 보여주고 활성화 되어있는지를 검증하고 있습니다.

물론 반대로 ON 버튼이 DOM 상에 존재하지 않는다는 것도 어렵지 않게 검증할 수 있겠죠?

Switch.test.js
test("ON button does not appear initially", () => {
  render(<Switch />);

  expect(
    screen.queryByRole("button", {
      name: /on/i
    })
  ).not.toBeInTheDocument();
});

React Testing Library에 대한 기본적인 사용법은 관련 포스팅를 참고 바랍니다.

동기 상태 변경 테스트

<Switch/> 컴포넌트 상태(state)는 버튼에서 클릭 이벤트가 발생할 때 마다 바뀌는데요. disabled 상태는 버튼이 클릭되지 마자 true로 바뀌기 때문에 단순하게 버튼을 선택 후에 검증을 할 수 있습니다.

Switch.test.js
test("button is disabled once clicked", () => {
  render(<Switch />);

  const button = screen.getByRole("button");

  userEvent.click(button);

  expect(button).toBeDisabled();
});

비동기 상태 변경 테스트

반면에 컴포넌트의 on 상태는 바뀌는데 0.5초가 걸리기 때문에, 약간 다른 테스팅 전략을 고려해야합니다.

예를 들어, 지금까지와 동일한 방법으로 테스트 코드를 작성하면 테스트는 실패하게 됩니다.

Switch.test.js
test("ON button is enabled when clicked (fail)", () => {
  render(<Switch />);

  userEvent.click(screen.getByRole("button"));

  const button = screen.getByRole("button", {
    name: /on/i
  });

  expect(button).toBeInTheDocument();
  expect(button).toBeEnabled();
});

에러 메세지를 확인해보면 ON 버튼 자체를 찾지 못하는 것을 알 수 있습니다.

Unable to find an accessible element with the role "button" and name `/on/i`

Here are the accessible roles:

  button:

  Name "OFF":
  <button
    disabled=""
  />

그 이유는 버튼이 클릭된 이후에도 0.5초 동안은 on 상태가 false로 바뀌지 않고 있기 때문입니다. 이 문제를 해결하려면 on 상태가 true로 바뀔 때까지 테스트가 기다렸다가 실행되야 합니다.

findByXxx 함수

React Testing Library는 이러한 비동기 테스트를 수월하게 할 수 있도록 다양한 함수를 제공하고 있습니다. 가장 대표적인 것이 findByRole(), getByLabelText(), 처럼 findBy로 시작하는 함수입니다.

위에서 실패한 테스트 코드에서 getByRole() 대신에 findByRole()를 사용하면 테스트가 통과하는 것을 볼 수 있습니다.

Switch.test.js
test("ON button will be enabled when clicked", async () => {
  render(<Switch />);

  userEvent.click(screen.getByRole("button"));

  const button = await screen.findByRole("button", {
    name: /on/i
  });

  expect(button).toBeInTheDocument();
  expect(button).toBeEnabled();
});

여기서 주의할 점은 비동기 테스트를 작성할 때는 async 키워드와 await 키워드를 적절하게 사용해줘야 한다는 것입니다.

async/await 키워드에 대한 자세한 설명은 관련 포스팅을 참고 바랍니다.

waitFor 함수

React Testing Library는 waitFor()라는 함수도 제공하고 있는데 findByXxx() 함수 대신에 사용할 수 있습니다.

예를 들어, 위에서 작성한 테스트를 waitFor() 함수를 사용해서 다시 작성해보겠습니다.

Switch.test.js
test("ON button will be enabled when clicked (waitFor)", async () => {
  render(<Switch />);

  userEvent.click(screen.getByRole("button"));

  const button = await waitFor(() =>
    screen.getByRole("button", {
      name: /on/i
    })
  );

  expect(button).toBeInTheDocument();
  expect(button).toBeEnabled();
});

재작성된 코드에서 보이듯이 waitFor() 함수를 사용하면 테스트 코드가 읽기 좀 더 복잡해진다는 단점이 있습니다. 하지만 인자로 함수를 받기 때문에 좀 더 유연하게 활용할 수 있습니다.

waitForElementToBeRemoved 함수

DOM 상에서 특정 엘리먼트가 비동기로 사라지는지를 검증해야할 때는 RTL에서 제공하는 waitForElementToBeRemoved() 함수를 사용할 수 있습니다. 버튼을 클릭한지 0.5초 후에는 <Switch/> 컴포넌트에서 최종적으로 OFF 버튼은 사라져야 합니다. 이 부분을 검증하는 테스트 코드를 waitForElementToBeRemoved() 함수를 사용하여 작성해보겠습니다.

Switch.test.js
test("OFF button will be removed when clicked", async () => {
  render(<Switch />);

  userEvent.click(screen.getByRole("button"));

  await waitForElementToBeRemoved(() =>
    screen.queryByRole("button", {
      name: /off/i
    })
  );
});

범용적으로 사용이 가능한 waitFor() 함수를 이용해서 위 테스트 코드를 재작성해보았습니다.

Switch.test.js
test("OFF button will be removed when clicked (waitFor)", async () => {
  render(<Switch />);

  userEvent.click(screen.getByRole("button"));

  await waitFor(() =>
    expect(
      screen.queryByRole("button", {
        name: /off/i
      })
    ).not.toBeInTheDocument()
  );
});

전체 코드

본 포스팅에서 작성한 예제 코드와 테스트 코드는 아래에서 직접 확인하고 실행해보실 수 있습니다.

마치면서

사용자와 상호작용이 빈번한 현대 웹 애플리케이션에서 비동기 로직은 결코 피하기 어려운 부분입니다. React Testing Library의 비동기 테스팅을 위한 API를 잘 활용하셔서 비동기로 실행되는 까다로운 코드를 효과적으로 테스트하실 수 있으셨으면 좋겠습니다.