Please enable JavaScript to view the comments powered by Disqus.

[번역] 초보자를 위한 React 어플리케이션 테스트 심층 가이드 (2)

이 글은 ”An in-depth beginner’s guide to testing React applications“를 번역한 글입니다


  1. React 앱 테스팅 개요
  2. 테스트에 사용할 어플리케이션
  3. 무엇을 테스트해야 하는가?
  4. 테스트 작성
  5. 어둠 속에서 찔러보지 말라
  6. 렌더링된 DOM 트리에 접근하는 방법
  7. DOM 요소와 상호작용 하기
  8. 올바른 페이지가 렌더링 되었는지 확인하기
  9. 폼 테스팅
  10. 셋업 함수에서 중복 방지하기
  11. 폼을 변경하고 제출하기
  12. ARIA role 없이 요소에 접근하기
  13. 데이터 기다리기
  14. API 요청 위조하기(mocking)
  15. Mock 함수 테스팅

폼 테스팅

훌륭하다, 우리는 헤더에 있는 링크를 위한 첫번째 테스트를 작성했다. 이제 조금 더 복잡한 것을 해 볼 차례다. 우리는 폼을 테스트할 것이다.

[2020-10-14] 1,6-react-testing-intro-2

앞서 얘기했듯이, 우리의 테스트 시나리오는 다음 단계를 거쳐야 한다.

  1. 사용자는 폼 입력에 값을 입력하고 제출 버튼을 누른다.
  2. 데이터를 기다리는 동안 로딩 메시지가 표시된다.
  3. API 응답이 도착하면 데이터가 렌더링된다.

테스트는 헤더에서 했던 것과 같은 방법으로 시작할 수 있다.

describe('Subreddit form', () => {
  test('loads posts that are rendered on the page', () => {
    render(
      <MemoryRouter>
        <App />
      </MemoryRouter>
    );
  });
});

셋업 함수에서 중복 방지하기

헤더 테스트에서 했던 것과 중복된 부분을 발견할 수 있다. 중복 방지를 위한 일반적인 방법은 셋업 함수를 만드는 것이다.

function setup() {
  return render(
    <MemoryRouter>
      <App />
    </MemoryRouter>
  );
}

...

describe('Subreddit form', () => {
  test('loads posts and renders them on the page', () => {
    setup();
  });
});

이제 우리가 할 일은 setup 함수를 호출하고 테스트를 시작하는 것만 남았다.

폼을 변경하고 제출하기

위에서 설명했던 세 단계 중에서 첫 번째는 “사용자는 폼 입력에 값을 입력하고 제출 버튼을 누른다.”다.

폼의 입력에 접근하기 전에 screen.debug를 사용해서 렌더링된 앱이 어떻게 생겼는지 다시 확인할 수 있다.

[2020-10-14] 7-debug-form

서브레딧 검색을 위한 r / 라벨이 붙어있는 input 요소를 확인할 수 있다. 쿼리 목록의 우선순위의 다른 부분을 살펴보면, 폼 인풋을 찾기 위한 쿼리로는 getByLabelText가 바람직한 선택임을 알 수 있다.

입력 값을 변경하기 위해서는 @testing-library/user-eventtype 함수를 사용하면 된다.

setup();

const subredditInput = screen.getByLabelText('r /');
userEvent.type(subredditInput, 'reactjs');

다음으로, 우리는 폼을 제출해야 한다. screen.debug()의 출력에서 폼 안에 버튼 요소가 있는 것을 확인할 수 있다. 그것은 getByRole의 좋은 사용 사례다.

const subredditInput = screen.getByLabelText('r /');
userEvent.type(subredditInput, 'reactjs');

const submitButton = screen.getByRole('button', { name: /search/i });
userEvent.click(submitButton);

screen.debug();

우리는 앱의 현재 상태가 어떤지 확인하기 위해 아래쪽에 또 다른 debug 구문을 추가했다. 결과는 다음과 같다.

[2020-10-14] 4-debug-loading

아래 부분에서 우리는 앱이 “Is loading” 텍스트를 보여주고 있는 걸 확인할 수 있다. 그것은 우리가 제출 버튼을 클릭했을 때 기대했던 모습과 정확히 일치한다.

ARIA role 없이 요소에 접근하기

두 번째 단계는 “데이터를 기다리는 동안 로딩 메시지가 표시된다.”이다.

로딩 메시지는 div 요소 안에 있어서 접근에 사용할 ARIA role이 없다. Testing Library 문서에 의하면 이 경우에는 getByRole 대신 getByText를 사용할 수 있다.

userEvent.click(submitButton);

expect(screen.getByText(/is loading/i)).toBeInTheDocument();

여기까지 작성한 테스트도 역시 통과할 것이다.

이제 마지막 단계 - “API 응답이 도착하면 데이터가 렌더링된다에 파고 들 단계다.

데이터 기다리기

현재 우리는 제출 버튼을 클릭했고, 로딩 메시지가 표시되고 있는 중이다. 이는 API 요청을 보냈지만 아직 응답을 받지 못했다는 의미다. 데이터가 제대로 렌더링되는 것을 테스트하기 위해서 우리는 응답을 기다릴 필요가 있다.

지금까지 우리는 getBy* 쿼리만 사용해 왔다. 하지만 처음에 언급했듯이 getBy로 시작하는 함수는 동기적이다. 그것들은 어플리케이션의 현재 상태만 확인한다. 만약 함수가 검색 타겟을 바로 찾을 수 없다면 테스트가 실패할 것이다.

그러므로 이제는 다른 타입의 쿼리를 사용할 때가 되었다. 비동기적인 findBy* 함수는 타겟 요소가 나타날 때까지 최대 5초를 기다린다.

새로운 쿼리를 사용하기 전에 비동기적으로 표시되는 요소의 식별자를 찾아야 한다. 우리는 앱이 API 요청의 응답을 받는데 성공하면 검색된 인기 포스트의 개수를 폼 아래쪽에 표시한다는 사실을 알고 있다. 표시되는 텍스트의 형식은 “Number of top posts: …” 와 같다. 그러니 여기서는 findByText 쿼리를 사용하자.

렌더링되는 결과에 들어가는 숫자는 모르기 때문에 정규표현식을 사용하면 편리하다. 기억하는가? 정규표현식은 문자열의 일부만으로 요소를 찾을 수 있게 해준다.

test('loads posts and renders them on the page', async () => {
  setup();

  const subredditInput = screen.getByLabelText('r /');
  userEvent.type(subredditInput, 'reactjs');

  const submitButton = screen.getByRole('button', { name: /search/i });
  userEvent.click(submitButton);

  const loadingMessage = screen.getByText(/is loading/i);
  expect(loadingMessage).toBeInTheDocument();

  const numberOfTopPosts = await screen.findByText(/number of top posts:/i);
  screen.debug(numberOfTopPosts);
});

findByText 가 비동기적이기 때문에 await를 사용해야 한다. 그리고 await 때문에 테스트 함수 앞에 async 키워드도 추가해야 한다.

debug 함수의 결과는 아래와 같다.

[2020-10-14] 8-debug-number-of-top-posts

훌륭하다! 응답 데이터가 렌더링되었다. 우리가 앞서 정의했던 모든 단계를 다뤘다.

  1. 사용자는 폼 입력에 값을 입력하고 제출 버튼을 누른다.
  2. 데이터를 기다리는 동안 로딩 메시지가 표시된다.
  3. API 응답이 도착하면 데이터가 렌더링된다.

여기서 끝났다고 생각할 수도 있다. 하지만 안타깝게도, 마지막 하나가 더 남았다.

API 요청 위조(mocking)하기

당신이 직접 테스트를 돌려 봤다면 아마도 폼 테스트에 걸리는 시간이 다른 테스트에 비해 상대적으로 길다는 사실을 깨달았을 것이다. 내 컴퓨터에서는 거의 1초가 걸린다. 그 이유는 우리가 진짜 Reddit API에 요청을 보내고 있기 때문이다.

그것은 이상적이지 않다. 통합 테스트에는 서버 요청을 실제로 보내서는 안된다. 거기에는 몇 가지 이유가 있다.

  1. API 요청은 시간이 많이 걸린다. 통합 테스트는 보통 코드를 원격 저장소(ex. Github)에 푸시하기 전에 로컬 환경에서 자주 실행한다. 그리고 코드가 푸시되었을 때 CI 파이프라인을 통해 실행되는 케이스도 많다. 만약 테스트의 수가 아주 많고 테스트들이 보내는 요청도 많다면, 테스트는 끝도 없이 돌아갈 것이다. 그렇게 되면 개발 환경과 퍼포먼스에 영향을 미치게 된다.
  2. 우리는 API 요청을 컨트롤할 수 없다. 우리는 통합 테스트에서 앱이 가지는 다양한 상태를 테스트하고 싶어한다. 예를 들어 API 서버가 죽은 상황을 테스트하고 싶을 수도 있다. 특정 테스트가 실행되는 동안만 서버에 오류가 생기도록 만들 수는 없다. 하지만 우리가 요청을 위조한다면 어떤 타입의 응답도 쉽게 시뮬레이션할 수 있다.
  3. 우리가 작성한 테스트에 아무 문제가 없어도 API 서버가 기대한 기대로 동작하지 않는다면 테스트가 실패할 수 있다. 예를 들어 API 서버가 죽은 상태라면 그런 상황이 발생할 수 있을 것이다. 테스트가 자동으로 그런 상황들을 대응하도록 만들면 좋겠지만, 그런 것은 통합 테스트가 아닌 E2E(end-to-end) 테스트를 사용해야 한다.

좋다, 이제 이유를 알았고 우리는 API 요청을 위조하야 한다. 그런데 어떻게?

먼저, API 요청이 어떻게 보내지는지 알아야 한다. 우리가 테스트하는 앱에서 그것은 Home 페이지 컴포넌트에서 이뤄진다.

function Home() {
  const [posts, setPosts] = useState([]);
  const [status, setStatus] = useState('idle')

  const onSearch = async (subreddit) => {
    setStatus('loading');
    const url = `https://www.reddit.com/r/${subreddit}/top.json`;
    const response = await fetch(url);
    const { data } = await response.json();
    setPosts(data.children);
    setStatus('resolved');
  };

  ...

fetch로 만들어진 요청을 위조하기 위해서는 jest-fetch-mock npm 패키지를 사용할 수 있다. 우선 패키지를 설치하자.

yarn jest-fetch-mock

이제 테스트 파일 상단에서 jest-fetch-mock을 초기화해야 한다.

import fetchMock from 'jest-fetch-mock';

fetchMock.enableMocks();

여기까지 했다면 폼 테스트는 실패할 것이다. 왜냐하면 위조된 fetch에게 요청에 대한 응답을 어떻게 할지 알려주지 않았기 때문이다.

위조 응답을 만들기 위해 브라우저를 보자. 개발 도구의 네트워크 탭을 열고 폼을 제출한 후 API 응답을 복사한다.

[2020-10-14] 9-copy-response

그 다음, 새로운 파일(ex. src/__mocks__/subreddit-reactjs-response.json) 을 만들어 거기에 개발 도구에서 복사한 응답 데이터를 붙여넣는다.

그러면 jest-fetch-mock 덕분에 fetch.once를 호출하는 것만으로 위조 응답을 쉽게 만들 수 있다.

import mockResponse from './__mocks__/subreddit-reactjs-response.json';

...

test('loads posts and renders them on the page', async () => {
  fetch.once(JSON.stringify(mockResponse));
  setup();
  ...

이제 테스트가 다시 성공해야 한다. 그리고 위조 응답을 사용했기 때문에 테스트하는 요소에 어떤 값이 표시되어야 하는지 확실하게 안다. 그러므로 관련된 테스트를 조금 수정할 수 있다.

expect(await screen.findByText(/number of top posts: 25/i)).toBeInTheDocument();

노트: 당신의 테스트가 이것보다 더 많은 API 요청을 보낸다면 이 방법은 다소 번거로울 수 있다. 그런 경우에는 MSW 패키지를 살펴보길 바란다. Kent C. Dodds의 블로그 글에서 더 많은 정보를 확인할 수 있다.

Mock 함수 테스팅

마지막 단계로, 우리는 올바른 API 주소로 요청이 전달되었는지 테스트하고 싶을 수 있다. 이것으로 유저가 정확한 데이터를 보는지 보장할 수 있다.

위에서 jest-mock-fetch를 사용했으므로 현재 전역 fetch는 위조 함수로 대체된 상황이다. 덕분에 정확한 URL이 사용되었는지 여부는 Jest의 toHaveBeenCalledWith 함수로 간단히 가능하다.

expect(fetch).toHaveBeenCalledWith('https://www.reddit.com/r/reactjs/top.json');

노트: 테스트 중에는 위조 함수를 직접 써야 할 상황이 때때로 생긴다. Jest를 사용하면 새로운 위조 함수를 jest.fn() 으로 쉽게 만들 수 있다. test-mock-fetch 패키지도 내부적으로 저 함수를 활용하고 있다.

이제 되었다! 전체 테스트는 아래와 같다.

describe('Subreddit form', () => {
  test('loads posts and renders them on the page', async () => {
    fetch.once(JSON.stringify(mockResponse));
    setup();

    const subredditInput = screen.getByLabelText('r /');
    userEvent.type(subredditInput, 'reactjs');

    const submitButton = screen.getByRole('button', { name: /search/i });
    userEvent.click(submitButton);

    expect(screen.getByText(/is loading/i)).toBeInTheDocument();

    expect(await screen.findByText(/Number of top posts: 25/i)).toBeInTheDocument();
    expect(fetch).toHaveBeenCalledWith('https://www.reddit.com/r/reactjs/top.json');
  });
});

정리

여기까지 온 것을 축하한다🎉. 이제 당신의 앱에 테스트를 작성하는 일에 자신감을 얻게 되었길 바란다.

핵심 사항은 아래와 같다:

  1. 유저 관점에서의 테스트 작성.
  2. 테스트에서 뭐가 일어나고 있는지 확실하지 않을 때는 언제나 screen.debug()를 사용해라.
  3. 가능하다면 DOM 트리에서 요소에 접근할 때 getByRole, findByRole, … 함수를 사용해라.

그리고 이 블로그 포스트 링크에서는 테스트 리팩토링과 디버깅에 대해 더 심화된 내용을 확인할 수 있다.