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 이벤트를 발생시켰는데, 이 과정에서는 이벤트 객체를 우리가 따로 임의로 만들어주었습니다. 위 코드를 저장하고 테스트가 통과하는지 확인해보세요.