useEffect를 테스트 하는 방법


출처: 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

그러니 useEffectuseState를 도킹하려고 하지 말고, 무서운 테스트 유저인 세 번째 사용자를 멀리하자. 사용자에게 유일하게 좋은 것은 당신을 영광스러운 테스트 베이비시터로 만드는 것이다.

그리고 당신은 어떨지 모르겠지만 나는 "진짜" 멋진 것을 "진짜" 사람들에게 제공하고 싶다.

김성호2020.11.26
Back to list