react-testing-library

우리가 방금 배웠던 react-test-renderer 만으로 테스트 코드를 작성하는것은 테스트 할 수 있는 것들이 제한되어 있습니다. 예를 들어서, 우리가 방금 작성한 코드에서 다음과 같이 실수로 handleDecrease 가 있어야 할 자리에 handleIncrease 를 넣는다고 해도, 테스트 코드는 통과하게 됩니다.

<button onClick={this.handleIncrease}>+1</button>
<button onClick={this.handleIncrease}>-1</button>

왜냐하면 우리는 버튼을 클릭 하는 것을 테스트 한 것이 아니라 함수를 직접 호출하는 형태로 했기 때문에, 위와 같이 함수를 잘못 연결했다던지, 아예 연결하지 않았다던지 하는 실수를 잡아낼 수 없죠.

이러한 실수를 잡아내려면 DOM 을 접근하여 이벤트도 직접 발생시키고, 실제 DOM 의 속성을 확인해야 하는데 이러한 작업을 하려면 다른 라이브러리를 추가적으로 사용해야 합니다. 대표적으로 Enzyme 혹은 react-testing-library 가 사용 됩니다. 혹은, 페이스북에서 제공하는 react-dom/test-utils 를 사용 할 수도 있지만 꽤나 번거롭기 때문에 공식 매뉴얼에서도 Enzyme 이나 react-testing-library 를 사용 하는 것을 권장하고 있습니다.

Enzyme 의 경우엔 airbnb 에서 개발이 되었고 2015년에 개발이 되었습니다. react-testing-library 의 경우엔 회사에 기반되어 있는 라이브러리가 아닌 Kent C. Dodds 라는 개발자를 중심으로 여러 컨트리뷰터들이 함께 개발되고 있는 라이브러리이고 2017년에 개발이 시작되어 2018년부터 많은 인기를 얻기 시작했습니다.

이 튜토리얼에서는 react-testing-library 라는 라이브러리를 사용하여 컴포넌트를 위한 테스트 코드를 작성하게 됩니다. react-testing-library 는 사용법이 훨씬 쉽고, DOM 에 초점이 더 맞춰져있습니다. 반면 Enzyme 은 제공하는 기능이 너무 많아서 무겁기도 하고, 배우기도 복잡한 편입니다.

설치하기

먼저, 현재 프로젝트에 설치부터 해봅시다.

$ yarn add react-testing-library jest-dom

그 다음에는 src 디렉터리에 setupTests.js 파일을 생성하요 다음 코드를 작성해주세요.

// react-testing-library 는 컴포넌트를 document.body 에 렌더링을 합니다.
// 각 테스트를 마치고 나서 기존의 결과를 초기화 시킵니다.
import 'react-testing-library/cleanup-after-each';

// toHaveTextContent 과 같은 DOM 을 위한 expect 기능들을 추가해줍니다.
import 'jest-dom/extend-expect';

setupTests.js 는 CRA 에서 테스트 환경설정을 할 때 사용하는 파일입니다.

기존 Counter 테스트 대체하기

기존에 작성했던 Counter 컴포넌트의 테스트 코드들을 react-testing-library 를 사용하여 모두 다시 구현해주겠습니다. 테스트 코드를 다음과 같이 다 비워주세요.

Counter.test.js

import React from 'react';
import Counter from './Counter';

describe('Counter', () => {

});

우선, 가장 기본적인 렌더링 성공과 스냅샷 테스트부터 작성해봅시다.

Counter.test.js

import React from 'react';
import { render } from 'react-testing-library';
import Counter from './Counter';

describe('Counter', () => {
  // 렌더링 성공 및 스냅샷 확인
  it('renders correctly', () => {
    const { container } = render(<Counter />);
    expect(container).toMatchSnapshot();
  });
});

스냅샷이 업데이트 되었으니, 터미널에서 u 키를 누르세요.

그 다음엔 UI 상에 숫자와, 두 버튼이 있는지 확인해보겠습니다.

Counter.test.js

import React from 'react';
import { render } from 'react-testing-library';
import Counter from './Counter';

describe('Counter', () => {
  // 렌더링 성공 및 스냅샷 확인
  it('renders correctly', () => {
    const { container } = render(<Counter />);
    expect(container).toMatchSnapshot();
  });

  // 숫자와 두 버튼이 있는지 확인
  it('has number and two buttons', () => {
    const { getByText } = render(<Counter />);
    getByText('값: 0');
    getByText('+1');
    getByText('-1');
  });
});

getByText를 사용하면 렌더링 된 HTML 상에서 문자열을 가지고 HTML 엘리먼트를 조회 할 수 있습니다.

테스트 코드를 작성 할 때는, 정말 잘 작동했는지 확인해보기 위해서 일부러 틀려보는게 중요합니다.

카운터 컴포넌트에서 다음과 같이 일부러 텍스트 내용을 틀리가 입력해보세요.

Counter.js - 버튼 렌더링 부분

<button onClick={this.handleIncrease}>+1!!!</button>
<button onClick={this.handleDecrease}>-1</button>

이렇게 하면, 기존 스냅샷에서도 에러가 나고, 우리가 방금 작성한 테스트 코드 쪽에서도 다음과 같이 에러가 나타날 것입니다.

 FAIL  src/Counter.test.js
  ● Counter › has number and two buttons

    Unable to find an element with the text: +1. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

에러를 확인하셨으면 이전 상태로 원상복구 시키세요.

만약에 텍스트로 HTML 엘리먼트를 찾기 힘든 상황이라면 container 의 querySelector 함수를 사용하시면 됩니다. querySelector 함수를 사용하시면 기존에 우리가 브라우저 상에서 HTML 엘리먼트를 찾을 때와 똑같이 찾으면 됩니다. 다음과 같이 말이죠.

  • .item: item 이라는 클래스명으로 찾기
  • #main: main 이라는 id 로 찾기
  • ul > li: ul 안에 들어있는 li 태그들을 찾기

더 자세한 사항은 MDN 문서 를 참고해보세요.

만약 여러개의 엘리먼트를 찾고 싶을 때는 querySelectorAll 을 사용하시면 됩니다.

기존의 버튼 두개 찾는것을 querySelectorAll 으로 연습삼아 대체해봅시다.

Counter.test.js

import React from 'react';
import { render } from 'react-testing-library';
import Counter from './Counter';

describe('Counter', () => {
  // 렌더링 성공 및 스냅샷 확인
  it('renders correctly', () => {
    const { container } = render(<Counter />);
    expect(container).toMatchSnapshot();
  });

  // 숫자와 두 버튼이 있는지 확인
  it('has number and two buttons', () => {
    const { getByText, container } = render(<Counter />);
    getByText('값: 0');
    const buttons = container.querySelectorAll('button');
    expect(buttons[0].innerHTML).toBe('+1');
    expect(buttons[1].innerHTML).toBe('-1');
  });
});

버튼이 두개있는지 찾고 그 안에 텍스트 값도 우리가 원하는 값인지 확인을 해주었습니다.

이제 증가 기능과 감소 기능을 구현해봅시다!

Counter.test.js

import React from 'react';
import { render, fireEvent } from 'react-testing-library';
import Counter from './Counter';

describe('Counter', () => {
  // 렌더링 성공 및 스냅샷 확인
  it('renders correctly', () => {
    const { container } = render(<Counter />);
    expect(container).toMatchSnapshot();
  });

  // 숫자와 두 버튼이 있는지 확인
  it('has number and two buttons', () => {
    const { getByText, container } = render(<Counter />);
    getByText('값: 0');
    const buttons = container.querySelectorAll('button');
    expect(buttons[0].innerHTML).toBe('+1');
    expect(buttons[1].innerHTML).toBe('-1');
  });

  it('increases', () => {
    const { getByText, container } = render(<Counter />);
    const button = getByText('+1'); // +1 버튼을 찾고
    fireEvent.click(button); // 클릭한다
    const number = container.querySelector('h1'); // h1 태그를 찾아서
    expect(number).toHaveTextContent('1'); // 그 안에 1 이라는 문자가 있는지 확인
  });
});

코드를 저장하세요. 잘 작동하나요? 이전에도 언급했듯이, 테스트 코드를 작성하고 나서는 잘 작동하는지 확실하게 검증하기 위해 일부러 틀려보세요.

handleIncrease 함수에서 숫자에 +1 이 아니라 +2 를 하게 되면 어떤 결과가 나타날까요?

Counter.js - handleIncrease

  handleIncrease = () => {
    this.setState({
      number: this.state.number + 2
    });
  };

테스트 코드가 잘 작성되었다면, 오류가 났을 것입니다.

 FAIL  src/Counter.test.js
  ● Counter › increases

    expect(element).toHaveTextContent()

    Expected element to have text content:
      1
    Received:
      값: 2

      24 |     fireEvent.click(button); // 클릭한다
      25 |     const number = container.querySelector('h1'); // h1 태그를 찾아서
    > 26 |     expect(number).toHaveTextContent('1'); // 그 안에 1 이라는 문자가 있는지 확인
         |                    ^
      27 |   });
      28 | });
      29 | 

      at Object.toHaveTextContent (src/Counter.test.js:26:20)

오류가 난 것을 확인하셨으면 +1으로 원상복구하세요.

이제, decreases 테스트 코드도 구현해봅시다!

Counter.test.js

import React from 'react';
import { render, fireEvent } from 'react-testing-library';
import Counter from './Counter';

describe('Counter', () => {
  // 렌더링 성공 및 스냅샷 확인
  it('renders correctly', () => {
    const { container } = render(<Counter />);
    expect(container).toMatchSnapshot();
  });

  // 숫자와 두 버튼이 있는지 확인
  it('has number and two buttons', () => {
    const { getByText, container } = render(<Counter />);
    getByText('값: 0');
    const buttons = container.querySelectorAll('button');
    expect(buttons[0].innerHTML).toBe('+1');
    expect(buttons[1].innerHTML).toBe('-1');
  });

  it('increases', () => {
    const { getByText, container } = render(<Counter />);
    const button = getByText('+1'); // +1 버튼을 찾고
    fireEvent.click(button); // 클릭한다
    const number = container.querySelector('h1'); // h1 태그를 찾아서
    expect(number).toHaveTextContent('1'); // 그 안에 1 이라는 문자가 있는지 확인
  });

  it('decreases', () => {
    const { getByText, container } = render(<Counter />);
    const button = getByText('-1'); // +1 버튼을 찾고
    fireEvent.click(button); // 클릭한다
    const number = container.querySelector('h1'); // h1 태그를 찾아서
    expect(number).toHaveTextContent('-1'); // 그 안에 1 이라는 문자가 있는지 확인
  });
});

react-testing-library 를 사용하면 더 이상 컴포넌트의 state 나 props 값을 신경 쓰지 않습니다. 그 대신에, 사용자에게 실제로 보여지는 화면에서 일어날 수 있는 작업들을 간단하게 시뮬레이트 할 수 있습니다.

인풋 이벤트 관리하기

이번에는 단순 클릭 이벤트가 아닌 인풋의 입력 이벤트를 발생시켜보겠습니다. 우선 테스트를 진행 할 간단한 컴포넌트 Greeting 을 만들어봅시다.

Greeting.js

import React, { Component } from 'react';

class Greeting extends Component {
  state = {
    text: ''
  };
  handleChange = e => {
    this.setState({
      text: e.target.value
    });
  };
  render() {
    const { text } = this.state;
    return (
      <div>
        <input
          placeholder="이름을 입력하세요"
          value={this.state.text}
          onChange={this.handleChange}
        />
        <h1>안녕하세요, {text}</h1>
      </div>
    );
  }
}

export default Greeting;

다 만드셨으면 App 에서 불러와서 렌더링하세요. 이제 Counter 컴포넌트는 더 이상 사용 할 일이 없으니 App 에서 제거시키곘습니다.

App.js

import React from 'react';
import Greeting from './Greeting';

const App = () => {
  return (
    <div>
      <Greeting />
    </div>
  );
};

export default App;

이제 리액트 개발서버에 해당 컴포넌트가 잘 작동하고 있는지 확인해보세요. 인풋에 값을 입력하면 하단에 인사와 함께 이름이 나타나야 합니다.

그럼, 이 컴포넌트를 위한 테스트 코드도 작성해봅시다.

Greeting.test.js

import React from 'react';
import { render, fireEvent } from 'react-testing-library';
import Greeting from './Greeting';

describe('Greeting', () => {
  // 렌더링 성공 및 초기 스냅샷 확인
  it('renders correctly', () => {
    const { container } = render(<Greeting />);
    expect(container).toMatchSnapshot();
  });

  it('handles change event', () => {
    const { getByPlaceholderText, getByText } = render(<Greeting />);
    // placeholder 값으로 인풋을 찾습니다.
    const input = getByPlaceholderText('이름을 입력하세요');
    // change 이벤트를 발생 시킬땐 이벤트 객체를 임의로 만들어주어야합니다.
    fireEvent.change(input, {
      target: {
        value: 'Bob'
      }
    });
    // input 값이 제대로 바뀐것을 확인합니다.
    expect(input.value).toBe('Bob');

    const heading = getByText(/^안녕하세요/); // h1 을 정규식으로 찾습니다
    expect(heading).toHaveTextContent('Bob'); // 그 안에 Bob 이라는 텍스트가 있는지 확인합니다
  });
});

여기서 우리가 input 을 찾는 과정에서는 placeholder 를 사용하여 HTML 엘리먼트를 선택하는 getByPlaceholderText 함수를 사용하였습니다. 이 외에도 getByLabelText, getByTestId 등 다양한 쿼리 방법이 있는데요, 자세한 사항은 공식 매뉴얼 을 참고하세요.

우리가 이전에는 fireEvent 로 click 이벤트를 발생시켰었죠? 이번에는 change 이벤트를 발생시켰는데, 이 과정에서는 이벤트 객체를 우리가 따로 임의로 만들어주었습니다. 위 코드를 저장하고 테스트가 통과하는지 확인해보세요.

results matching ""

    No results matching ""