리액트
#9. 테스팅(1) - ESLint, Redux와 React Component 테스트하기
YJH3968
2021. 4. 2. 20:18
728x90
1. ESLint
- 대부분의 프로그래밍 언어는 코드 실행 전 컴파일해서 코드 작성 규칙을 지켰는지 확인하는 반면, javascript는 컴파일러를 사용하지 않아 코드를 작성한 뒤 브라우저에서 직접 프로그램을 실행해야 오류가 있는지 확인할 수 있다. 그러나 이를 보완하기 위해 javascript에는 코드 분석을 해주는 많은 도구들이 있다.
- javascript를 분석하는 과정을 hinting 또는 linting이라고 하는데, JSHint와 JSLint는 원래 javascript를 분석하고 형식에 대한 제안을 제공하는 도구였다.
- ESLint는 최신 javascript 문법을 지원하는 코드 linter다. 여기에 플러그인을 추가할 수 있어 ESLint의 기능을 확장하기 위해 플러그인을 만들어서 공유하거나 다른 사람이 만든 플러그인을 활용할 수도 있다.
- 여기서는 eslint-plugin-react라는 플러그인을 사용한다. 이 플러그인은 javascript 외에 JSX와 리액트 문법도 분석해준다.
// eslint global 설치
sudo npm install -g eslint
- ESLint를 사용하기 전에 몇 가지 규칙을 지정해야 한다. 프로젝트의 최상위 디렉토리에 JSON이나 YAML를 이용해 설정 파일을 만들어 규칙을 지정할 수 있다.
- ESLint는 설정을 돕는 도구를 제공한다. ESLint 설정을 만들려면 eslint --init을 실행하고 코딩 스타일에 대한 몇 가지 질문에 답해야 한다.
- 이제 아래와 같은 코드를 script에 입력하면 ESLint를 이용해 javascript 파일을 검사할 수 있다.
// script에 입력하면 ESLint가 javascript 파일을 검사하고 피드백을 해준다.
eslint sample.js
// 전체 디렉토리에 대한 linting을 수행한다.
eslint .
- .eslintignore 파일에 ESLint가 무시해야 하는 파일이나 디렉토리를 추가할 수 있다.
// .eslintignore에 다음을 입력하면 eslint는 sample.js 파일과
// dist/assets/ 디렉토리 안에 있는 모든 내용을 무시한다.
dist/assets/
sample.js
- package.json 파일에 linting을 수행하는 script를 추가하면 npm run을 실행할 때마다 ESLint도 실행된다.
"script": {
"lint": "./node_modules/.bin/eslint ."
}
2. Redux 테스트하기
- 테스트 주도 개발(TDD)은 모든 개발 과정을 테스트 중심으로 진행해나가는 방법이다. TDD는 다음 단계를 거친다.
- 테스트 작성
- 테스트 실행 및 실패
- 테스트 통과 위한 최소한의 코드 작성
- 코드와 테스트를 함께 리팩토링
- reducer는 입력 인자에 따라 결과를 계산해 반환하는 순수 함수로 테스트에서는 입력과 action을 제어한다.
- 테스트를 작성하기 전에 테스트 프레임워크를 설치해야 한다. 여기서는 리액트를 염두에 두고 개발한 제스트를 사용한다. transpile을 위해 babel-jest도 같이 설치한다.
// jest 설치
sudo npm install -g jest
// babel-jest 설치
npm install --save-dev babel-jest
- 제스트는 테스트를 만들기 위해 describe와 it이라는 두 가지 함수를 사용한다. describe는 테스트 suite를 만들 때 사용하며 it은 각 테스트를 사용한다. 두 함수 모두 테스트나 suite 이름을 첫 번째 인자로 받고 테스트나 테스트 suite를 포함하는 콜백 함수를 두 번째 인자로 받는다.
- color reducer를 테스트하는 중이므로 reducer를 import해야 한다. color reducer 함수를 테스트 대상 시스템(SUT)라고 부른다. color reducer를 import하고 reducer에 action을 보낸 다음 결과를 검증한다.
- jest expect 함수는 SUT의 실행 결과를 검증할 때 사용하는 matcher를 반환한다. color reducer를 테스트하기 위해 .toEqual matcher를 사용한다. .toEqual은 SUT가 돌려주는 결과 객체가 .toEqual에 전달된 인자와 같은지 검사한다.
// describe와 it 사용 예제
import C from '../../../src/constants'
import { color } from '../../../src/stroe/reducers'
describe("color 리듀서", () => {
it("ADD_COLOR 성공", () => {
const state = {}
const action = {
type: C.ADD_COLOR,
id: 0,
title: 'Test Teal',
color: '#90C3D4',
timestamp: new Date().toString()
}
const results = color(state, action)
expect(results)
.toEqual({
id: 0,
title: 'Test Teal',
color: '#90C3D4',
timestamp: action.timestamp,
rating: 0
})
})
it("RATE_COLOR 성공", () => {
const state = {
id: 0,
title: 'Test Teal',
color: '#90C3D4',
timestamp: 'Sat Mar 12 2016 16:12:09 GMT-0800 (PST)',
rating: undefined
}
const action = {
type: C.RATE_COLOR,
id: 0,
rating: 3
}
const results = color(state, action)
expect(results)
.toEqual({
id: 0,
title: 'Test Teal',
color: '#90C3D4',
timestamp: 'Sat Mar 12 2016 16:12:09 GMT-0800 (PST)',
rating: 3
})
})
})
- TDD로 코드를 작성 중인 경우 테스트를 작성한 뒤 이에 적절한 color reducer를 위한 stub을 작성한다. 이렇게 해야 테스트 프레임워크가 좀 더 구체적인 피드백을 제공할 수 있다.
// 위 테스트에 대한 color reducer stub을 작성한다.
import C from '../constants'
export const color = (state={}, action={ type: null }) => {
return state
}
- 이제 script에 jest를 입력해 테스트를 실행하면 실패한 테스트를 stack trace를 포함해 자세히 보여준다.
- 제스트가 보여준 테스트 실패 피드백은 해야 할 일의 목록에 해당한다.
- 그러나 이 테스트는 상태의 불변성을 검사하지 않는다. deep-freeze를 사용하면 상태와 action 객체의 변경을 방지해서 불변 객체로 만들 수 있다.
// deep-freeze 설치
npm install deep-freeze --save-dev
// deepFreeze 적용 예제
import C from '../../../src/constants'
import { color } from '../../../src/stroe/reducers'
import deepFreeze from 'deep-freeze'
describe("color 리듀서", () => {
it("ADD_COLOR 성공", () => {
const state = {}
const action = {
type: C.ADD_COLOR,
id: 0,
title: 'Test Teal',
color: '#90C3D4',
timestamp: new Date().toString()
}
// 상태와 action을 불변 객체로 만든다.
deepFreeze(state)
deepFreeze(action)
const results = color(state, action)
expect(results)
.toEqual({
id: 0,
title: 'Test Teal',
color: '#90C3D4',
timestamp: action.timestamp,
rating: 0
})
})
it("RATE_COLOR 성공", () => {
const state = {
id: 0,
title: 'Test Teal',
color: '#90C3D4',
timestamp: 'Sat Mar 12 2016 16:12:09 GMT-0800 (PST)',
rating: undefined
}
const action = {
type: C.RATE_COLOR,
id: 0,
rating: 3
}
deepFreeze(state)
deepFreeze(action)
const results = color(state, action)
expect(results)
.toEqual({
id: 0,
title: 'Test Teal',
color: '#90C3D4',
timestamp: 'Sat Mar 12 2016 16:12:09 GMT-0800 (PST)',
rating: 3
})
})
})
- store를 테스트할 때는 reducer로 store를 만들고 상태를 가정해 주입하고 결과를 검증하는 단계로 이루어진다.
- store를 테스트하는 동안 action 생성기를 통합할 수 있기 때문에 store와 action 생성기를 함께 검사할 수 있다.
- 제스트에는 테스트 suite를 실행하기 전과 후에 정해진 코드를 실행하도록 해주는 준비와 정리 기능이 있다. beforeAll과 afterAll이 각각 테스트 suite 실행 전과 후에 호출된다. beforeEach와 afterEach가 it으로 정의한 각 테스트를 실행하기 전과 후에 호출된다.
// addColor action 생성자 테스트
import C from '../scr/constants'
import storeFactory from '../src/store'
import { addColor } from '../src/actions'
describe("addColor", () ={
let store
const colors = [
{
id: "8658c1d0-9eda-4a90-95e1-8001e8eb6036",
title: "green",
color: "#44ef37",
timestamp: "Mon Apr 11 2016 12:54:19 GMT-0700 (PDT)",
rating: 4
},
{
id: "f9005b4e-975e-433d-a646-79df172e1dbb",
title: "blue",
color: "#0061ff",
timestamp: "Mon Apr 11 2016 12:54:31 GMT-0700 (PDT)",
rating: 2
},
{
id: "58d9caee-6ea6-4d7b-9984-65b145031979",
title: "red",
color: "#ff4b47",
timestamp: "Mon Apr 11 2016 12:54:43 GMT-0700 (PDT)",
rating: 0
}
]
beforeAll(() => {
store = storeFactory({colors})
store.dispatch(addColor("dark_blue", "#000033"))
})
it("should add a new color", () =>
expect(store.getState().colors.length).toBe(4))
it("should add a unique id", () =>
expect(store.getState().colors[3].id.length).toBe(36))
it("should set the rating to 0", () =>
expect(store.getState().colors[3].rating).toBe(0))
it("should set timestamp", () =>
expect(store.getState().colors[3].timestamp).toBeDefined())
})
- .toBe matcher는 결과를 '===' 연산자를 사용해 인자와 비교한다. .toEqual matcher는 두 객체의 내부까지 깊숙이 비교하는 반면, .toBe matcher는 숫자나 문자열 같은 기본 type을 검사할 때 사용한다.
- .toBeDefined matcher는 변수나 함수의 존재 여부를 검사할 수 있다.
3. 리액트 component 테스트하기
- 리액트 component를 테스트할 때는 component를 렌더링해서 생성되는 DOM을 예상 DOM과 비교해봐야 한다.
- component 테스트는 브라우저에서 수행하지 않고 노드를 사용해 terminal에서 테스트한다.
- 노드에는 브라우저와 달리 표준으로 제공하는 DOM API가 없어 제스트가 제공하는 jsdom이라는 npm 패키지를 이용해 노드 상에서 브라우저 환경을 시뮬레이션할 수 있다.
- 제스트는 테스트를 실행하기 전에 script를 실행하는 기능을 제공한다. 그 script 안에 테스트에 사용할 전역 변수를 추가할 수 있다.
- 예를 들어 모든 테스트에서 접근할 수 있게 몇몇 예제 색과 리액트를 전역 영역에 추가하고 싶다면 /__test__/global.js 파일을 만들어서 그런 전역 변수를 추가할 수 있다.
// 전역 변수 추가
import React from 'react'
import deepFreeze from 'deep-freeze'
global.React = React
global._testColors = deepFreeze([
{
id: "8658c1d0-9eda-4a90-95e1-8001e8eb6036",
title: "green",
color: "#44ef37",
timestamp: "Mon Apr 11 2016 12:54:19 GMT-0700 (PDT)",
rating: 4
},
{
id: "f9005b4e-975e-433d-a646-79df172e1dbb",
title: "blue",
color: "#0061ff",
timestamp: "Mon Apr 11 2016 12:54:31 GMT-0700 (PDT)",
rating: 2
},
{
id: "58d9caee-6ea6-4d7b-9984-65b145031979",
title: "red",
color: "#ff4b47",
timestamp: "Mon Apr 11 2016 12:54:43 GMT-0700 (PDT)",
rating: 0
}
])
- 그리고 제스트가 테스트를 실행하기 전에 이 파일을 실행하게 만들어야 하기에 package.json 파일의 jest 노드에 setupFiles 필드를 추가한다.
- modulePathIgnorePatterns 필드는 테스트 suite가 들어 있지 않은 global.js 파일을 무시하라고 제스트에 지시한다.
"jest": {
"setupFiles": ["./__test__/global.js"],
"modulePathIgnorePatterns":["global.js"]
}
- SCSS, CSS, SASS를 component에 직접 import한다면 테스트를 진행하는 동안에는 이런 import를 무시해야 한다.
- 이를 위해 module mapper를 사용해야 한다.
// module mapper인 jest-css-modules 설치
npm install jest-css-modules --save-dev
- 이제 제스트가 .scss를 import하는 대신 이 모듈을 사용하게 만들어야 한다. 이를 위해 package.json 파일의 jest 노드에 moduleNameMapper 필드를 추가한다.
"jest": {
"setupFiles": ["./__test__/global.js"],
"modulePathIgnorePatterns":["global.js"]
"moduleNameMapper": {
"\\.(scss)$": "<rootDir>/node_modules/jest-css-modules"
}
}
- 그러면 제스트가 .scss를 import하는 대신 jest-css-modules 모듈을 사용한다.
- 엔자임은 에어비앤비가 만든 리액트 component 테스트 유틸리티다. 엔자임을 사용하려면 테스트 중에 component와 상호작용하고 component를 렌더링할 때 필요한 react-addons-test-utils를 설치해야 한다.
// 엔자임과 react-addons-test-utils 설치
npm install enzyme react-addons-test-utils --save-dev
- 엔자임을 사용하면 쉽게 component를 렌더링하고 렌더링한 출력을 순회할 수 있다. 엔자임은 테스트나 조건 검사 프레임워크가 아닌 테스트를 위해 component를 렌더링하는 과정을 처리하고 자식 element를 순회하거나, 상태나 프로퍼티를 검증하거나, 이벤트를 시뮬레이션하거나, DOM에 대한 질의를 던질 때 필요한 도구를 제공한다.
- 엔자임은 다음 세 가지 렌더링 method를 제공한다.
- shallow : 단위 테스트를 위해 component를 한 단계 렌더링해준다.
- mount : 브라우저 DOM을 사용해 component를 렌더링한다. component 생애주기 전체를 테스트해야 하거나 자식 element의 상태나 프로퍼티를 테스트할 때 mount가 필요하다.
- render : component로 정적 HTML 마크업을 렌더링할 때 필요하다. render가 있으면 component가 적절한 HTML을 생성하는지 테스트할 수 있다.
- Star component를 예로 들면 이 component는 selected 프로퍼티에 따라 className이 결정되는 div element를 렌더링해야 하고, 클릭 이벤트에 반응해야 한다.
const Star = ({ selected=false, onClick=f=>f }) =>
<div className={(selected) ? "star selected" : "star"} onClick={onClick}></div>
- 이에 대한 테스트를 엔자임의 shallow method를 사용해 작성하면 다음과 같다.
// 엔자임을 통한 star component 테스트
import { shallow } from 'enzyme'
import Star from '../../../src/components/ui/Star'
describe("<Star /> UI Component", () => {
it("renders default star", () =>
expect(
shallow(<Star />)
.find('div.star')
.length
).toBe(1)
)
it("render selected star", () =>
expect(
shallow(<Star selected={true} />)
.find('div.selected.star')
.length
).toBe(1)
)
})
- 엔자임은 제이쿼리와 비슷한 함수를 제공해 find method와 selector 문법을 사용해 결과 DOM을 검색할 수 있다.
- 클릭 이벤트를 테스트하는 경우 엔자임은 이벤트를 시뮬레이션하고 그 이벤트가 발생되었는지 검증해주는 도구를 제공한다. 이 테스트의 경우 onClick 프로퍼티가 작동하는지 검증하기 위해 mock 함수가 필요한데, 제스트가 이를 제공한다.
// click 이벤트 테스트
it("invokes onClick", () => {
const _click = jest.fn()
shallow(<Star onClick={_click}) />)
.find('div.star')
.simulate('click')
expect(_click).toBeCalled()
})
- jest.fn을 이용해 mock 함수를 만들고 Star를 렌더링해 onClick 프로퍼티에 mock 함수를 넘긴다. 그 후 엔자임의 simulate method를 사용해 클릭 이벤트를 시뮬레이션한다. .toBeCalled matcher는 mock 함수가 호출되었는지 검사할 때 사용한다.
- mock은 테스트를 할 떄 실제 객체를 대신할 수 있는 객체이고, mocking은 단위 테스트 자체에 초점을 맞추기 위해 중요한 기술이다.
- mocking의 목적은 테스트를 작성할 때 테스트 대상 component나 객체, 즉 SUT에만 집중하게 해주는 것이다. mock은 SUT가 의존하는 실제 객체, component, 함수 등을 대신해줘 의존하는 다른 존재와는 독립적으로 SUT가 제대로 작동하는지 확인할 수 있다.
- HOC를 테스트하는 경우 mock component를 만들어서 HOC에 보냈을 때 HOC가 그 mock component에 프로퍼티 등을 제대로 추가해주는지 검증할 수 있다.
// Expandable HOC 테스트
import { mount } from 'enzyme'
import Expandable from '../../../src/components/HOC/Expandable'
describe("Expandable Higher-Order Component", () => {
let props,
wrappers,
ComposedComponent,
MockComponent = ({collapsed, expandCollapse}) =>
<div onClick={expandCollapse}>
{(collapsed) ? 'collapsed': 'expanded'}
</div>
describe("Rendering UI", ... )
describe("Expand Collapse Functionality", ... )
})
- MockComponent는 단순한 상태가 없는 함수형 component로 expandCollapse 함수를 테스트하기 위한 onClick 핸들러가 들어 있는 div를 반환한다. 상태가 collapsed인지 expanded인지는 mock component 내부에 표시된다. 이 component는 오직 이 테스트를 위한 component다.
- 반환받은 component의 프로퍼티와 상태를 검사해야 하기 때문에 shallow 대신 mount를 사용한다.
describe("Rendering UI", () => {
beforeAll(() => {
ComposedComponent = Expandable(MockComponent)
wrapper = mount(<ComposedComponent foo="foo" gnar="gnar" />)
props = wrapper.find(MockComponent).props()
})
it("starts off collapsed", () =>
expect(props.collapsed).toBe(true)
)
it("passes the expandCollapse function to composed component", () =>
expect(typeof props.expandCollapse).toBe("function")
)
it("passes additional foo prop to composed component", () =>
expect(props.foo).toBe("foo")
)
it("passes additional gnar prop to composed component", () =>
expect(props.gnar).toBe("gnar")
)
})
describe("Expand Collapse Functionality", () => {
let instance
beforeAll(() => {
ComposedComponent = Expandable(MockComponent)
wrapper = mount(<ComposedComponent collapsed={false} />)
instance = wrapper.instance()
})
it("renders the MockComponent as the root element", () => {
expect(wrapper.first().is(MockComponent))
})
it("starts off expanded", () => {
expect(instance.state.collapsed).toBe(false)
})
it("toggles the collapsed state", () => {
instance.expandCollapse()
expect(instance.state.collapsed).toBe(true)
})
})
- component를 마운트하면 렌더링된 인스턴스의 정보를 wrapper.instance에서 얻을 수 있다. 프로퍼티와 인스턴스의 상태를 검사해서 시작 시 component가 펼쳐진(collapsed가 false인) 상태인지 검사할 수 있다.
- wrapper.first는 첫 번째 자식 element를 반환한다.
- 제스트에서는 HOC가 아닌 component에도 mock을 주입할 수 있다.
- mocking을 사용하면 SUT에만 집중해 테스트할 수 있고 문제가 발생할 수 있는 다른 모듈은 신경쓰지 않아도 된다.
- ColorList를 예로 들면 ColorList component는 Color component를 import하는데, 테스트하는 경우에는 Color component에는 관심이 없기 때문에 Color component를 mock으로 대체한다.
// Color component를 mock으로 대체한 ColorList 테스트
import { mount } from 'enzyme'
import ColorList from '../../../src/components/ui/ColorList'
jest.mock('../../../src/components/ui/Color', () =>
({rating, onRate=f=>f}) =>
<div className="mock-color">
<button className="rate" onClick={() => onRate(rating)} />
</div>
)
describe("<ColorList /> UI Component", () => {
describe("Rating a Color", () => {
let _rate = jest.fn()
beforeAll(() =>
mount(<ColorList colors={_testColors} onRate={_rate} />)
.find('button.rate')
.first()
.simulate('click')
)
it("invokes onRate Handler", () =>
expect(_rate).toBeCalled()
)
it("rates the correct color", () =>
expect(_rate).toBeCalledWith(
"8658c1d0-9eda-4a90-95e1-8001e8eb6036", 4
)
)
})
})
- jest.mock의 첫 번째 인자는 mock을 만들고 싶은 모듈이고 두 번째 인자는 mock component를 반환하는 함수다.
- 여기서 Color mock은 Color component를 줄인 버전으로 색의 평점 변화를 살펴보기 때문에 평점과 관련한 프로퍼티만 처리한다.
- ColorList를 렌더링할 때 앞에서 설정해둔 전역 _testColors를 보낸다. ColorList가 각 색을 렌더링라면 Color 대신 mock component가 렌더링된다. 첫 번째 버튼을 클릭한 것처럼 시뮬레이션하면 이벤트가 첫 번째 mock component에 발생한다.
- 제스트에서는 직접 만든 mock을 사용하기 위한 모듈을 생성할 수도 있다. 테스트에 mock 코드를 직접 넣는 대신 __mocks__ 디렉토리에 mock 정의가 들어 있는 파일을 넣을 수도 있다. 제스트는 그 디렉토리에서 mock 정의를 가져온다.
// __mocks__ 내부의 ColorList.js
const ColorListMock = () => <div className="color-list-mock"></div>
ColorListMock.displayName = "ColorListMock"
export default ColorListMock
- 위와 같이 __mocks__ 내부에 ColorList.js mock을 만들면 /src/components/ui/ColorList component를 jest.mock으로 mocking했을 때 제스트가 __mocks__ 디렉토리에서 적절한 mock을 가져온다. 그러면 테스트에서 직접 mock을 정의할 필요가 없다.
- store에 대한 mock도 만들 수 있으나 dispatch, subscribe, getState 함수를 제공해야 한다. getState 함수는 전역 테스트에 사용할 예제 상태를 반환하는 mock 함수 구현을 제공한다.
// 직접 만든 ColorList mock과 store mock을 이용한 Colors 테스트
import { mount, shallow } from 'enzyme'
import { Provider } from 'react-redux'
import { Colors } from '../../../src/components/containers'
jest.mock('../../../src/components/ui/ColorList')
describe("<Colors /> Container ", () => {
let wrapper
const _store = {
dispatch: jest.fn(),
subscribe: jest.fn(),
getState: jest.fn(() =>
({
sort: "SORTED_BY_DATE",
colors: _testColors
})
)
}
beforeAll(() => wrapper = mount(
<Provider store={_store}>
<Colors />
</Provider>
))
it("renders three colors", () => {
expect(wrapper
.find('ColorListMock')
.props()
.colors
.length
).toBe(3)
})
it("sorts the colors by data", () => {
expect(wrapper
.find('ColorListMock')
.props()
.colors[0]
.title
).toBe("red")
})
afterEach(() => jest.resetAllMocks())
it("dispatches a REMOVE_COLOR action", () => {
wrapper.find('ColorListMock')
.props()
.onRemove('f9005b4e-975e-433d-a646-79df172e1dbb')
expect(_store.dispatch.mock.calls[0][0])
.toEqual({
id: 'f9005b4e-975e-433d-a646-79df172e1dbb',
type: 'REMOVE_COLOR'
})
})
it("dispatches a RATE_COLOR action", () => {
wrapper.find('ColorListMock')
.props()
.onRemove('58d9caee-6ea6-4d7b-9984-65b145031979', 5)
expect(_store.dispatch.mock.calls[0][0])
.toEqual({
id: '58d9caee-6ea6-4d7b-9984-65b145031979',
type: 'RATE_COLOR',
rating: 5
})
})
})
출처 : learning react(2018)
728x90