출처: https://epicreact.dev/how-to-test-react-use-effect
TL;DR 역자 요약: useEffect를 모킹하거나 별도로 테스트하지말고 사용자의 입장에서 컴포넌트 단위의 테스트 케이스를 작성하자.
React.useEffect 코드를 사용하다 보면 도대체 이것들을 어떻게 테스트해야 할까 하는 궁금증이 생길 수 있다. 그리고 꽤 자주 묻곤 하는 질문이기도 하다. 이에 대한 대답은 다소 황당할 수 있겠지만 당연한 내용이다. 무언가 테스트를 할 때는 아래와 같은 의식의 흐름으로 진행한다.
사용자가 이 코드를 어떻게 실행할까? 테스트가 그것을 하도록 만든다.
이거다. 이것이 바로 비결이다. 비결은 바로 어떤 사용자가 있는지를 파악하는 것이다. 리액트 컴포넌트를 사용하는 사용자는 사실 두 명이라고 할 수 있다. 컴포넌트를 이용해 렌더링 하는 개발자와 그 컴포넌트와 상호작용하는 사용자(엔드 유저). 일반적으로 테스트 내용은 이 사용자들이 수행하는 작업보다 많을 필요도 없고 적어서도 안된다. 더 자세한 내용은 Avoid the Test User 포스트에서 볼 수 있다.
그럼 예제를 한번 살펴보자. EpicReact.dev/app 에서 연습 코드 하나를 가져왔다. 꽤 긴 예제지만 , 마음을 가다듬고 빠르게 스캔해보자.
import { jsx } from '@emotion/core';
import * as React from 'react';
import Tooltip from '@reach/tooltip';
import { FaSearch, FaTimes } from 'react-icons/fa';
import { Input, BookListUL, Spinner } from './components/lib';
import { BookRow } from './components/book-row';
import { client } from './utils/api-client';
import * as colors from './styles/colors';
function DiscoverBooksScreen() {
const [status, setStatus] = React.useState('idle');
const [data, setData] = React.useState();
const [error, setError] = React.useState();
const [query, setQuery] = React.useState();
const [queried, setQueried] = React.useState(false);
const isLoading = status === 'loading';
const isSuccess = status === 'success';
const isError = status === 'error';
React.useEffect(() => {
if (!queried) {
return;
}
setStatus('loading');
client(`books?query=${encodeURIComponent(query)}`).then(
(responseData) => {
setData(responseData);
setStatus('success');
},
(errorData) => {
setError(errorData);
setStatus('error');
}
);
}, [query, queried]);
function handleSearchSubmit(event) {
event.preventDefault();
setQueried(true);
setQuery(event.target.elements.search.value);
}
return (
<div css={{ maxWidth: 800, margin: 'auto', width: '90vw', padding: '40px 0' }}>
<form onSubmit={handleSearchSubmit}>
<Input placeholder="Search books..." id="search" css={{ width: '100%' }} />
<Tooltip label="Search Books">
<label htmlFor="search">
<button
type="submit"
css={{
border: '0',
position: 'relative',
marginLeft: '-35px',
background: 'transparent',
}}
>
{isLoading ? (
<Spinner />
) : isError ? (
<FaTimes aria-label="error" css={{ color: colors.danger }} />
) : (
<FaSearch aria-label="search" />
)}
</button>
</label>
</Tooltip>
</form>
{isError ? (
<div css={{ color: colors.danger }}>
<p>There was an error:</p>
<pre>{error.message}</pre>
</div>
) : null}
{isSuccess ? (
data?.books?.length ? (
<BookListUL css={{ marginTop: 20 }}>
{data.books.map((book) => (
<li key={book.id} aria-label={book.title}>
<BookRow key={book.id} book={book} />
</li>
))}
</BookListUL>
) : (
<p>No books found. Try another search.</p>
)
) : null}
</div>
);
}
export { DiscoverBooksScreen };
위 예제는
useReduce
를 사용했으면 확실히 더 좋았을 것이다. 나중에 워크샵 예제를 통해 이를 개선하게 된다.
조금 더 상세히 코드를 살펴보면
React.useEffect(() => {
if (!queried) {
return;
}
setStatus('loading');
client(`books?query=${encodeURIComponent(query)}`).then(
(responseData) => {
setData(responseData);
setStatus('success');
},
(errorData) => {
setError(errorData);
setStatus('error');
}
);
}, [query, queried]);
function handleSearchSubmit(event) {
event.preventDefault();
setQueried(true);
setQuery(event.target.elements.search.value);
}
테스트에서는 MSW 를 이용해 백엔드를 정상적으로 모킹했기 때문에 쉽게 요청을 만들고 결과를 받아올 수 있다. 그러므로 마치 엔드 유저처럼 컴포넌트와 상호작용 해보자.
이제부터가 테스트 코드다.
import * as React from 'react';
import { render, screen, waitForElementToBeRemoved, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DiscoverBooksScreen } from '../discover.extra-1';
// Learn more: https://kentcdodds.com/blog/stop-mocking-fetch
import { server } from 'test/server';
beforeAll(() => server.listen());
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
test('queries for books', async () => {
// 🤓 이건 개발자 유저의 행위이고
render(<DiscoverBooksScreen />);
// 🤠 이건 엔드 유저의 행위다.
userEvent.type(screen.getByRole('textbox', { name: /search/i }), 'Sanderson{enter}');
// 🤠 엔드 유저는 로딩 인디케이터를 볼 수 있겠지만 곧 사라질때까지 기다린다.
await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i));
// 🤠 엔드 유저는 책 제목을 확인할 수 있는 목록의 모든 항목들을 볼 수 있게 되고
// 보조 기술의 도움으로 ARIA role "listItem"을 통해 li 엘리먼트에 접근할 수 있다.
const results = screen.getAllByRole('listitem').map((listItem) => {
return within(listItem).getByRole('heading', { level: 2 }).textContent;
});
// 나는 스냅샷은 잘 사용하지 않지만 아래와 같은 어플리케이션은 꽤 좋아보인다.
// https://kcd.im/snapshots
expect(results).toMatchInlineSnapshot(`
Array [
"The Way of Kings (Book 1 of the Stormlight Archive)",
"Words of Radiance (Book 2 of the Stormlight Archive)",
"Oathbringer (Book 3 of the Stormlight Archive)",
]
`);
});
물론 이런 테스트를 수행하는 테스트케이스는 다른 방법도 있지만 (test/server
모듈에서 발생하는 중요한 것이 있지만 이 글에서는 시간문제로 다루지 않는다.) 기본적으로 원리는 같다. 바로.
사용자가 이 코드를 어떻게 실행할까? 테스트가 그것을 하도록 만든다.
다른 말로:
소프트웨어의 사용 방법과 테스트가 유사할수록 더 큰 신뢰감(confidence)을 얻을 수 있다. - me
그러니 useEffect
나 useState
를 도킹하려고 하지 말고, 무서운 테스트 유저인 세 번째 사용자를 멀리하자. 사용자에게 유일하게 좋은 것은 당신을 영광스러운 테스트 베이비시터로 만드는 것이다.
그리고 당신은 어떨지 모르겠지만 나는 "진짜" 멋진 것을 "진짜" 사람들에게 제공하고 싶다.