우선, 우리가 테스팅을 연습할 리액트 프로젝트를 만들겠습니다. CRA 를 통하여 프로젝트를 생성해주세요.
$ yarn create react-app react-enzyme-test
# 혹은 npx create-react-app react-enzyme-test
CRA 로 만든 프로젝트에는 Jest 가 처음부터 적용되어있기 때문에 별도로 jest 설치를 하지 않으셔도 됩니다. VS Code 를 사용하시는 경우 IDE 지원을 제대로 받기 위하여 @types/jest
만 설치해주세요.
리액트 프로젝트를 열어서 다음 라이브러리들을 설치하세요.
$ yarn add enzyme enzyme-adapter-react-16
# 또는 npm install --save enzyme enzyme-adapter-react-16
그 다음, src 디렉터리에 setupTests.js 라는 파일을 만들어서 다음 코드를 입력하세요.
src/setupTests.js
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
configure({ adapter: new Adapter() });
그 다음, Profile 이라는 컴포넌트를 만들어보겠습니다. 이 컴포넌트에서는, username 과 name 값을 가져와서 화면상에 보여줍니다.
src/Profile.js
import React from 'react';
const Profile = ({ username, name }) => {
return (
<div>
<b>{username}</b>
<span>({name})</span>
</div>
);
};
export default Profile;
이 컴포넌트를 App 컴포넌트에서 렌더링하고 yarn start
(혹은 npm start
) 를 입력하여 결과를 확인해보세요.
import React from 'react';
import Profile from './Profile';
function App() {
return (
<div>
<Profile username="velopert" name="김민준" />
</div>
);
}
export default App;
!> Cannot find module '@babel/plugin-transform-react-jsx-source' 라는 에러가 발생하면 node_modules 를 제거한 후, yarn install (혹은 npm install) 명령어를 입력하여 패키지들을 재설치해보세요.
위 결과물이 잘 나타났나요? 이제 이 컴포넌트를 위한 테스트 코드를 작성해보겠습니다. 이 컴포넌트의 테스트 코드에서는 props 로 값을 넣어줬을 때 username 과 name 값이 잘 나타났는지 확인해주어야 합니다.
스냅샷 테스팅이란, 렌더링된 결과가 이전에 렌더링한 결과와 일치하는지 확인하는 작업을 의미합니다. Enzyme 에서 스냅샷 테스팅을 하려면 enzyme-to-json
이라는 라이브러리를 설치해주어야 합니다.
$ yarn add enzyme-to-json
그 다음에는, package.json 파일을 열어서 다음과 같이 "jest"
설정을 넣어주세요.
package.json
{
"name": "react-enzyme-test",
"version": "0.1.0",
"private": true,
"dependencies": {
"@types/jest": "^24.0.13",
"enzyme": "^3.9.0",
"enzyme-adapter-react-16": "^1.13.1",
"enzyme-to-json": "^3.3.5",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-scripts": "3.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [">0.2%", "not dead", "not op_mini all"],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"jest": {
"snapshotSerializers": ["enzyme-to-json/serializer"]
}
}
그 다음, Profile.test.js 파일을 다음과 같이 작성해보세요.
Profile.test.js
import React from 'react';
import { mount } from 'enzyme';
import Profile from './Profile';
describe('<Profile />', () => {
it('matches snapshot', () => {
const wrapper = mount(<Profile username="velopert" name="김민준" />);
expect(wrapper).toMatchSnapshot();
});
});
mount
라는 함수는 Enzyme 을 통하여 리액트 컴포넌트를 렌더링 해줍니다. 이를 통해서 만든 wrapper 를 통해서 우리가 추후 props 조회, DOM 조회, state 조회 등을 할 수 있습니다. mount
외에도 shallow
라는 함수도 있는데요. 이에 대해선 나중에 알아보겠습니다.
그리고 나서, 다음 명령어를 입력하여 테스트 코드를 실행하세요.
$ yarn test
위와 같이 1 snapshot updated 라는 문구가 보여지고 src 디렉터리에 \_\_snapshots\_\_/Profile.test.js.snap/
라는 파일이 생겼을 것입니다.
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Profile /> matches snapshot 1`] = `
<Profile
name="김민준"
username="velopert"
>
<div>
<b>
velopert
</b>
<span>
(
김민준
)
</span>
</div>
</Profile>
`;
만약에 컴포넌트를 수정하게 되면 이 스냅샷이 일치하지 않게 되면서 테스트가 실패할 것입니다. 예를 들어서 다음과 같이 username 뒤에 느낌표를 붙이면
Profile.js
import React from 'react';
const Profile = ({ username, name }) => {
return (
<div>
<b>{username}!</b>
<span>({name})</span>
</div>
);
};
export default Profile;
이렇게 실패했다고 나타납니다. 만약 현재 결과물이 제대로 된거고, 스냅샷을 현재 결과물로 업데이트 하고 싶다면, 콘솔창에서 u
키를 누르면 됩니다. 한번 눌러보세요. 스냅샷이 업데이트 된것이 확인 됐다면, 느낌표를 지우고 또 다시 스냅샷을 원래 상태로 다시 업데이트하세요.
Enzyme 에서는 컴포넌트 인스턴스에 접근을 할 수 있습니다. 한번 다음과 같이 새로운 테스트 케이스를 만들어보세요.
src/Profile.test.js
import React from 'react';
import { mount } from 'enzyme';
import Profile from './Profile';
describe('<Profile />', () => {
it('matches snapshot', () => {
const wrapper = mount(<Profile username="velopert" name="김민준" />);
expect(wrapper).toMatchSnapshot();
});
it('renders username and name', () => {
const wrapper = mount(<Profile username="velopert" name="김민준" />);
expect(wrapper.props().username).toBe('velopert');
expect(wrapper.props().name).toBe('김민준');
});
});
이렇게 콘솔에 출력을 하게 하면 테스트 하는 콘솔에서 결과가 나타납니다. 한번 콘솔을 확인해보세요.
DOM 에 우리가 원하는 텍스트가 나타나있는지 확인을 해보겠습니다.
src/Profile.test.js
import React from 'react';
import { mount } from 'enzyme';
import Profile from './Profile';
describe('<Profile />', () => {
it('matches snapshot', () => {
const wrapper = mount(<Profile username="velopert" name="김민준" />);
expect(wrapper).toMatchSnapshot();
});
it('renders username and name', () => {
const wrapper = mount(<Profile username="velopert" name="김민준" />);
expect(wrapper.props().username).toBe('velopert');
expect(wrapper.props().name).toBe('김민준');
const boldElement = wrapper.find('b');
expect(boldElement.contains('velopert')).toBe(true);
const spanElement = wrapper.find('span');
expect(spanElement.text()).toBe('(김민준)');
});
});
find
함수를 사용하면 특정 DOM 을 선택 할 수 있습니다. 여기에 입력하는 값은 브라우저의 querySelector
와 같습니다. CSS 클래스는 find('.my-class')
, id 는 find('#myid')
, 태그는 find('span')
이런식으로 조회를 할 수 있으며, 여기에 컴포넌트의 Display Name 을 사용하면 특정 컴포넌트의 인스턴스도 찾을 수 있습니다 (예: find('MyComponent')
)
이번에는 클래스형 컴포넌트의 내부메서드 호출 및 state 를 조회하는 방법을 알아보겠습니다. 깨어있는 (?) 리액트 개발자라면 Hooks 를 사용하고 싶겠지만 이건 다음 섹션에서 진행하겠습니다.
Counter 컴포넌트를 만들어봅시다.
src/Counter.js
import React, { Component } from 'react';
class Counter extends Component {
state = {
number: 0
};
handleIncrease = () => {
this.setState({
number: this.state.number + 1
});
};
handleDecrease = () => {
this.setState({
number: this.state.number - 1
});
};
render() {
return (
<div>
<h2>{this.state.number}</h2>
<button onClick={this.handleIncrease}>+1</button>
<button onClick={this.handleDecrease}>-1</button>
</div>
);
}
}
export default Counter;
이제 Counter 컴포넌트를 어떻게 테스트 할 수 있는지 알아볼까요?
src/Counter.test.js
import React from 'react';
import { shallow } from 'enzyme';
import Counter from './Counter';
describe('<Counter />', () => {
it('matches snapshot', () => {
const wrapper = shallow(<Counter />);
expect(wrapper).toMatchSnapshot();
});
it('has initial number', () => {
const wrapper = shallow(<Counter />);
expect(wrapper.state().number).toBe(0);
});
it('increases', () => {
const wrapper = shallow(<Counter />);
wrapper.instance().handleIncrease();
expect(wrapper.state().number).toBe(1);
});
it('decreases', () => {
const wrapper = shallow(<Counter />);
wrapper.instance().handleDecrease();
expect(wrapper.state().number).toBe(-1);
});
});
여기서는 우리가 mount
대신에 shallow
라는 함수를 사용해주었는데요, shallow
는 컴포넌트 내부에 또다른 리액트 컴포넌트가 있다면 이를 렌더링하지 않습니다. 만약에 우리가 Profile 컴포넌트를 Counter 컴포넌트에서 렌더링 할 경우에는 shallow
의 경우 다음과 같은 결과가 나타나고,
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Counter /> matches snapshot 1`] = `
<div>
<h2>
0
</h2>
<button
onClick={[Function]}
>
+1
</button>
<button
onClick={[Function]}
>
-1
</button>
<Profile
name="김민준"
username="velopert"
/>
</div>
`;
mount
의 경우 다음과 같은 결과가 나타납니다.
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Counter /> matches snapshot 1`] = `
<Counter>
<div>
<h2>
0
</h2>
<button
onClick={[Function]}
>
+1
</button>
<button
onClick={[Function]}
>
-1
</button>
<Profile
name="김민준"
username="velopert"
>
<div>
<b>
velopert
</b>
<span>
(
김민준
)
</span>
</div>
</Profile>
</div>
</Counter>
`;
보시면, mount
의 경우 Profile 내부의 내용까지 전부 렌더링 된 반면, shallow
에선 이 작업이 생략됐지요? 추가적으로, mount
에서는 최상위 요소가 Counter 컴포넌트인 반면에, shallow
에서는 최상위 요소가 div 입니다. 따라서, shallow
를 할 경우 wrapper.props()
를 조회하게 되면 컴포넌트의 props 가 나타나는 것이 아니라 div 의 props 가 나타나게 됩니다.
expect(wrapper.state().number).toBe(0);
컴포넌트의 state 를 조회 할 때에는 위와 같이 state()
함수를 사용합니다.
wrapper.instance().handleIncrease();
그리고, 내장 메서드를 호출할때에는 instance()
함수를 호출하여 인스턴스를 조회 후 메서드를 호출 할 수 있습니다.
이번에는 내장 메서드를 직접 호출하는게 아니라, 버튼 클릭 이벤트를 시뮬레이트하여 기능이 잘 작동하는지 확인해보겠습니다.
Counter.test.js
import React from 'react';
import { shallow } from 'enzyme';
import Counter from './Counter';
describe('<Counter />', () => {
it('matches snapshot', () => {
const wrapper = shallow(<Counter />);
expect(wrapper).toMatchSnapshot();
});
it('has initial number', () => {
const wrapper = shallow(<Counter />);
expect(wrapper.state().number).toBe(0);
});
it('increases', () => {
const wrapper = shallow(<Counter />);
wrapper.instance().handleIncrease();
expect(wrapper.state().number).toBe(1);
});
it('decreases', () => {
const wrapper = shallow(<Counter />);
wrapper.instance().handleDecrease();
expect(wrapper.state().number).toBe(-1);
});
it('calls handleIncrease', () => {
// 클릭이벤트를 시뮬레이트하고, state 를 확인
const wrapper = shallow(<Counter />);
const plusButton = wrapper.findWhere(
node => node.type() === 'button' && node.text() === '+1'
);
plusButton.simulate('click');
expect(wrapper.state().number).toBe(1);
});
it('calls handleDecrease', () => {
// 클릭 이벤트를 시뮬레이트하고, h2 태그의 텍스트 확인
const wrapper = shallow(<Counter />);
const minusButton = wrapper.findWhere(
node => node.type() === 'button' && node.text() === '-1'
);
minusButton.simulate('click');
const number = wrapper.find('h2');
expect(number.text()).toBe('-1');
});
});
위 테스트 케이스에서는 findWhere()
함수를 사용하여 우리가 원하는 버튼 태그를 선택해주었습니다. 이 함수를 사용하면 우리가 원하는 조건을 만족하는 태그를 선택 할 수 있습니다.
만약에 findWhere()
를 사용하지 않는다면 다음과 같이 코드를 작성해야합니다.
const buttons = wrapper.find('button');
const plusButton = buttons.get(0); // 첫번째 버튼 +1
const minusButton = buttons.get(1); // 두번째 버튼 -1
버튼에 이벤트를 시뮬레이트 할 때에는 원하는 엘리먼트를 찾아서 simulate()
함수를 사용합니다. 첫번째 파라미터에는 이벤트 이름을 넣고 두번째 파라미터에는 이벤트 객체를 넣습니다. 만약에 인풋에 change 이벤트를 발생시키는 경우엔 다음과 같이 하면 됩니다.
input.simulate('change', {
target: {
value: 'hello world'
}
});
그리고, 값이 잘 업데이트 됐는지 확인하기 위해서 두가지 방법을 사용했는데요, 첫번째 방법은 state 를 직접 조회하는 것이고, 두번째 방법은 h2 태그를 조회해서 값을 확인하는 것 입니다. 실제 테스트 코드를 작성하게 될 때에는 이 방법 중 아무거나 선택하셔도 상관없습니다.
이번에는 Hooks 를 사용하는 함수형 컴포넌트의 테스트 코드를 작성해봅시다. HookCounter 라는 컴포넌트를 만들어보세요.
src/HookCounter.js
import React, { useState, useCallback } from 'react';
const HookCounter = () => {
const [number, setNumber] = useState(0);
const onIncrease = useCallback(() => {
setNumber(number + 1);
}, [number]);
const onDecrease = useCallback(() => {
setNumber(number - 1);
}, [number]);
return (
<div>
<h2>{number}</h2>
<button onClick={onIncrease}>+1</button>
<button onClick={onDecrease}>-1</button>
</div>
);
};
export default HookCounter;
컴포넌트를 만들고 App 에서 렌더링하여 잘 작동하는지 직접 먼저 확인해보세요.
src/App.js
import React from 'react';
import HookCounter from './HookCounter';
function App() {
return (
<div>
<HookCounter />
</div>
);
}
export default App;
이 컴포넌트를 위한 테스트케이스를 작성해보겠습니다. 함수형 컴포넌트에서는 클래스형 컴포넌트와 달리 인스턴스 메서드 및 상태를 조회 할 방법이 없습니다. 추가적으로, Hooks 를 사용하는 경우 꼭 shallow
가 아닌 mount
를 사용하셔야 합니다. 그 이유는, useEffect
Hook 은 shallow
에서 작동하지 않고, 버튼 엘리먼트에 연결되어있는 함수가 이전 함수를 가르키고 있기 때문에, 예를 들어 +1 버튼의 클릭 이벤트를 두번 시뮬레이트해도 결과값이 2가 되는게 아니라 1이 됩니다.
HookCounter.test.js
import React from 'react';
import { mount } from 'enzyme';
import HookCounter from './HookCounter';
describe('<HookCounter />', () => {
it('matches snapshot', () => {
const wrapper = mount(<HookCounter />);
expect(wrapper).toMatchSnapshot();
});
it('increases', () => {
const wrapper = mount(<HookCounter />);
let plusButton = wrapper.findWhere(
node => node.type() === 'button' && node.text() === '+1'
);
plusButton.simulate('click');
plusButton.simulate('click');
const number = wrapper.find('h2');
expect(number.text()).toBe('2');
});
it('decreases', () => {
const wrapper = mount(<HookCounter />);
let decreaseButton = wrapper.findWhere(
node => node.type() === 'button' && node.text() === '-1'
);
decreaseButton.simulate('click');
decreaseButton.simulate('click');
const number = wrapper.find('h2');
expect(number.text()).toBe('-2');
});
});
이번 섹션에서는 Enzyme 을 통한 컴포넌트 테스팅에 대해서 알아보았습니다. Enzyme 의 공식 문서를 보면, Enzyme 에 있는 더 많은 기능들을 볼 수 있습니다.
안녕하세요. CRA 를 사용하지 않고 스스로 웹팩을 설정해서 사용하는 경우,
package.json 에서 test 객체의 값을 이전 포스트에 나와있는 jest 와 동일하게 "test": "jest --watchAll --verbose" 이렇게 넣어주면 될까요?
이렇게 넣어주면 › matches snapshot 에러가 나옵니다.
CRA 로 설치후, test 돌리면 잘 나오는데 말입니다