React Testing Library 비동기 테스트
웹에서는 UI가 비동기로 업데이트되는 경우가 참 많죠? UI가 변경될 때까지 기다려줘야 하기 때문에 많은 개발자들이 테스트하기 어려운하는 부분입니다.
이번 포스팅에서는 Testing Library로 비동기(asynchronous)로 작동하는 React 컴포넌트를 테스트하는 방법에 대해서 알아보겠습니다.
예제 컴포넌트
우선 테스트 대상이 될 간단한 React 컴포넌트 하나를 작성해보도록 하겠습니다.
아래 <Switch/>
컴포넌트 함수는 on
상태값에 따라 ON
버튼 또는 OFF
버튼을 리턴합니다.
on
상태값은 최초에는 false
이지만 버튼을 클릭할 때 마다 true
, false
, …로 번갈아 가면서 바뀝니다.
상태값이 바뀌는데는 0.5초(500ms)가 걸리며, 상태값이 바뀌는 동안에는 버튼은 클릭이 불가능하도록 비활성화 됩니다.
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()
함수가 리턴하는 객체를 의도적으로 정리하지 않았습니다.
따라서 예제 코드는 메모리 누수가 발생할 수 있으며 상용 환경에서 사용하기는 부적하다는 점 주의 바랍니다.
setTimeout()
함수에 대한 좀 더 자세한 내용은 관련 포스팅을 참고바랍니다.
초기 UI 테스트
<Switch/>
컴포넌트는 처음에는 OFF
를 보여주고 클릭이 가능한 상태여야 합니다.
이 부분은 Testing Library를 사용하는 전형적인 방법으로 테스트 코드를 작성할 수 있습니다.
import { render, screen } from '@testing-library/react';
import Switch from './Switch';
test("OFF button is enabled initially", () => {
render(<Switch />);
const button = screen.getByRole("button");
expect(button).toHaveAccessibleName("OFF");
expect(button).toBeEnabled();
});
getByRole()
함수로 버튼 요소를 선택하고, 해당 버튼이 OFF
라는 텍스트를 보여주고 활성화 되어있는지를 검증하고 있습니다.
물론 반대로 웹페이지에 OFF
버튼은 존재하지만 ON
버튼이 존재하지 않는다는 것도 어렵지 않게 검증할 수 있겠죠?
import { render, screen } from '@testing-library/react';
import Switch from './Switch';
test("ON button does not appear initially", () => {
render(<Switch />);
expect(screen.getByRole("button", { name: /off/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /on/i })).not.toBeInTheDocument();
});
여기서 한 가지 주의할 부분이 있는데요.
존재하지 않는 요소를 선택할 때는 getByXxx()
함수 대신에 queryByXxx()
함수를 사용하셔야 합니다.
요소가 존재하지 않을 때 getByXxx()
함수는 예외를 발생시키기 때문에
queryByXxx()
함수는 null
을 반환하기 때문에,
React Testing Library에 대한 기본적인 사용법은 관련 포스팅를 참고 바랍니다.
UI 변경 테스트
<Switch/>
컴포넌트 상태(state)는 버튼에서 클릭 이벤트가 발생할 때 마다 바뀌는데요.
disabled
상태는 버튼이 클릭되지 마자 true
로 바뀌기 때문에 단순하게 버튼을 선택 후에 검증을 할 수 있습니다.
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import Switch from './Switch';
test("button is disabled once clicked", async () => {
const user = userEvent.setup();
render(<Switch />);
const button = screen.getByRole("button");
await user.click(button);
expect(button).toBeDisabled();
});
User Event Testing Library에 대한 기본적인 사용법은 관련 포스팅를 참고 바랍니다.
비동기 상태 변경 테스트
반면에 on
상태는 바뀌는데 0.5초가 걸리기 때문에, 약간 다른 테스팅 전략을 고려해야합니다.
예를 들어, 지금까지와 동일한 방법으로 테스트 코드를 작성하면 테스트는 실패하게 됩니다.
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import Switch from './Switch';
test("ON button is enabled when clicked (fail)", async () => {
const user = userEvent.setup();
render(<Switch />);
await user.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 함수
Testing Library는 이러한 비동기 테스트를 수월하게 할 수 있도록 다양한 함수를 제공하고 있습니다.
가장 대표적인 것이 findByRole()
, findByLabelText()
, 처럼 findBy
로 시작하는 함수입니다.
위에서 실패한 테스트 코드에서 getByRole()
대신에 findByRole()
를 사용하면 테스트가 통과하는 것을 볼 수 있습니다.
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import Switch from './Switch';
test("ON button will be enabled when clicked", async () => {
const user = userEvent.setup();
render(<Switch />);
await user.click(screen.getByRole("button"));
const button = await screen.findByRole("button", { name: /on/i });
expect(button).toBeInTheDocument();
expect(button).toBeEnabled();
});
여기서 주의할 점은 비동기 테스트를 작성할 때는 async
키워드와 await
키워드를 적절하게 사용해줘야 한다는 것입니다.
async/await
키워드에 대한 자세한 설명은 관련 포스팅을 참고 바랍니다.
waitFor 함수
Testing Library는 waitFor()
라는 함수도 제공하고 있는데 findByXxx()
함수 대신에 사용할 수 있습니다.
예를 들어, 위에서 작성한 테스트를 waitFor()
함수를 사용해서 다시 작성해보겠습니다.
import {
render,
screen,
waitFor,
} from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import Switch from './Switch';
test("ON button will be enabled when clicked (waitFor)", async () => {
const user = userEvent.setup();
render(<Switch />);
await user.click(screen.getByRole("button"));
const button = await waitFor(() =>
screen.getByRole("button", { name: /on/i })
);
expect(button).toBeInTheDocument();
expect(button).toBeEnabled();
});
재작성된 코드에서 보이듯이 waitFor()
함수를 사용하면 테스트 코드가 읽기 좀 더 복잡해진다는 단점이 있습니다.
하지만 인자로 함수를 받기 때문에 좀 더 유연하게 활용할 수 있습니다.
예를 들어, waitFor()
함수의 인자로 다음과 같이 검증문을 바로 넘길 수도 있죠.
클릭하기 전에 먼저 선택을 해놓은 버튼 요소의 텍스트가 OFF
에서 ON
으로 바뀌었는지를 검증하고 있습니다.
import {
render,
screen,
waitFor,
} from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import Switch from './Switch';
test("ON button will be enabled when clicked (waitFor)", async () => {
const user = userEvent.setup();
render(<Switch />);
expect(button).toHaveAccessibleName(/off/i);
const button = screen.getByRole("button");
await user.click(button);
await waitFor(() => expect(button).toHaveAccessibleName(/on/i));
expect(button).toBeEnabled();
});
waitForElementToBeRemoved 함수
웹페이지 상에서 특정 요소가 비동기로 사라지는지를 검증해야 할 때는 Testing Library에서 제공하는 waitForElementToBeRemoved()
함수를 사용할 수 있습니다.
버튼을 클릭한지 0.5초 후에는 <Switch/>
컴포넌트에서 최종적으로 OFF
버튼은 사라져야 합니다.
이 부분을 검증하는 테스트 코드를 waitForElementToBeRemoved()
함수를 사용하여 작성해보겠습니다.
import {
render,
screen,
waitForElementToBeRemoved,
} from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import Switch from './Switch';
test("OFF button will be removed when clicked", async () => {
const user = userEvent.setup();
render(<Switch />);
expect(screen.getByRole("button", { name: /off/i })).toBeInTheDocument();
await user.click(screen.getByRole("button"));
await waitForElementToBeRemoved(() =>
screen.queryByRole("button", { name: /off/i })
);
});
범용적으로 사용이 가능한 waitFor()
함수를 이용해서 위 테스트 코드를 재작성해보았습니다.
import {
render,
screen,
waitFor,
} from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import Switch from './Switch';
test("OFF button will be removed when clicked (waitFor)", async () => {
const user = userEvent.setup();
render(<Switch />);
expect(screen.getByRole("button", { name: /off/i })).toBeInTheDocument();
await user.click(screen.getByRole("button"));
await waitFor(() =>
expect(
screen.queryByRole("button", { name: /off/i })
).not.toBeInTheDocument()
);
});
전체 코드
본 포스팅에서 Jest로 작성한 테스트 코드는 아래에서 확인하고 직접 실행해보실 수 있습니다.
Vitest를 사용하여 작성한 테스트 코드도 아래 올려두었으니 참고 바랍니다.
마치면서
사용자와 상호작용이 빈번한 현대 웹 애플리케이션에서 비동기 로직은 결코 피하기 어려운 부분입니다. Testing Library에서 제공하는 비동기 테스팅에 특화된 API를 잘 활용하셔서 비동기로 실행되는 까다로운 코드를 효과적으로 테스트하실 수 있으셨으면 좋겠습니다.