들어가며
리액트에서 테스트라는 개념이 있다는 걸 알고 있었지만, 정확히 무슨 개념인지 또한 구체적으로 어떻게 하는 건지 몰랐었다.
react deep dive를 공부하며 08 좋은 리액트 코드 작성을 위한 환경 구축하기 단원 에서 리액트 컴포넌트 테스트코드를 작성하는 부분이 있어서 이 부분에서 공부하면서 배운 리액트 컴포넌트의 기초적인 테스트 코드를 작성하는 방법을 정리해보고자 한다.
리액트 컴포넌트 테스트
기본적으로 리액트에서 컴포넌트 테스트는 다음과 같은 순서로 진행된다.
App.tsx
import React from 'react';
import logo from './logo.svg';
import './App.css';
import StaticComponent from './components/StaticComponent';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
<StaticComponent />
</div>
);
}
export default App;
테스트 코드
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
App.test.tsx가 App.tsx에서 테스트하는 내용은 다음과 같이 요약할 수 있다.
- App 렌더링
- App에서 'leartn react' 라는 문자열 가진 DOM 요소 찾기
- expect(linkElement).toBeInTheDocument() 라는 어설션을 활용해 2번에서 찾은 요소가 document 내부에 있는지 확인하기
위와 같이 일반적인 리액트 컴포넌트 테스트 시나리오는 특정한 무언가를 지닌 HTML 요소가 있는지 여부이다.
이를 확인하는 방법은 3가지인데,
- getBy... : 인수에 조건에 맞는 요소를 반환한다. 복수 개를 찾으려면 getAllBy.. 를 사용해야 한다.
- findBy... : getBy와 유사하지만 Promise를 반환, 즉 비동기로 찾는 것을 의미하며 기본적으로 1s의 타임아웃을 가지고 있다. 복수 개는 findAllBy를 사용한다.
- queryBy... : 인수에 조건에 맞는 요소를 반환하는 대신, 요소를 찾지 못한다면 null이 반환된다. 찾지 못한경우 에 에러를 일으키지 않고 싶을 때 사용한다. 복수 개는 queryAllBy를 사용한다.
자 그럼 본격적으로 리액트 컴포넌트들의 테스트 코드를 작성해보자.
정적 컴포넌트
정적 컴포넌트란 별도의 상태가 존재하지 않아 항상 같은 결과를 반환하는 컴포넌트이다.
정적 컴포넌트
import { memo } from 'react'
const AnchorTagComponent = memo(function AnchorTagComponent({
name,
href,
targetBlank,
}: {
name: string
href: string
targetBlank?: boolean
}) {
return (
<a
href={href}
target={targetBlank ? '_blank' : undefined}
rel="noopener noreferrer"
>
{name}
</a>
)
})
export default function StaticComponent() {
return (
<>
<h1>Static Component</h1>
<div>유용한 링크</div>
<ul data-testid="ul" style={{ listStyleType: 'square' }}>
<li>
<AnchorTagComponent
targetBlank
name="리액트"
href="https://reactjs.org"
/>
</li>
<li>
<AnchorTagComponent
targetBlank
name="네이버"
href="https://www.naver.com"
/>
</li>
<li>
<AnchorTagComponent name="블로그" href="https://yceffort.kr" />
</li>
</ul>
</>
)
}
이 컴포넌트에서 링크가 제대로 있는지 확인해볼 것이다.
테스트 코드
import { render, screen } from '@testing-library/react'
import StaticComponent from './index'
beforeEach(() => {
render(<StaticComponent />)
})
describe('링크 확인', () => {
it('링크가 3개 존재한다.', () => {
const ul = screen.getByTestId('ul')
expect(ul.children.length).toBe(3)
})
it('링크 목록의 스타일이 square다.', () => {
const ul = screen.getByTestId('ul')
expect(ul).toHaveStyle('list-style-type: square;')
})
})
describe('리액트 링크 테스트', () => {
it('리액트 링크가 존재한다.', () => {
const reactLink = screen.getByText('리액트')
expect(reactLink).toBeVisible()
})
it('리액트 링크가 올바른 주소로 존재한다.', () => {
const reactLink = screen.getByText('리액트')
expect(reactLink.tagName).toEqual('A')
expect(reactLink).toHaveAttribute('href', 'https://reactjs.org')
})
})
describe('네이버 링크 테스트', () => {
it('네이버 링크가 존재한다.', () => {
const naverLink = screen.getByText('네이버')
expect(naverLink).toBeVisible()
})
it('네이버 링크가 올바른 주소로 존재한다.', () => {
const naverLink = screen.getByText('네이버')
expect(naverLink.tagName).toEqual('A')
expect(naverLink).toHaveAttribute('href', 'https://www.naver.com')
})
})
describe('블로그 링크 테스트', () => {
it('블로그 링크가 존재한다.',()=>{
const blogLink = screen.getByText('블로그')
expect(blogLink).toBeVisible()
})
it('블로그 링크가 올바른 주소로 존재한다.', ()=>{
const blogLink = screen.getByText('블로그')
expect(blogLink.tagName).toEqual('A');
expect(blogLink).toHaveAttribute('href','https://yceffort.kr')
})
it('블로그는 같은 창에서 열려야 한다.', () => {
const blogLink = screen.getByText('블로그');
expect(blogLink).not.toHaveAttribute('target');
})
})
각 테스트를 수행하기전에 StaticComponent를 렌더링하고 describe로 연관된 테스트를 묶어서 it으로 it 함수 내부에 수행하는 테스트파일이다.
몇 가지 새로운 jest 메서드가 보인다.
- beforeEach : 각 테스트들을 실행하기 전에 실행하는 함수
- describe : 비슷한 속성을 가진 테스트를 하나로 묶음 describe 안에 describe를 사용하는것도 가능
- it : test와 완전히 동일하며 축약어이다.
- testId : get등의 선택자로 선택하기 어렵거나 곤란한 요소를 선택하기 위해 사용한다.
참고로 HTML에 data-testid를 추가하면 getByTestId를 사용할 수 있다. 이는 button의 개수가 많을 때와 같은 특정 시나리오에서 유용하게 사용할 수 있다.
동적 컴포넌트
상태값이 있는 컴포넌트, 예를 들어 useState를 사용해 상태값을 관리하는 컴포넌트는 어떻게 테스트할까?
일반적으로 리액트에서는 정적인 경우보다 동적인 경우가 훨씬 많다.
이 경우도 살펴보자.
import { useState } from 'react'
export function InputComponent() {
const [text, setText] = useState('')
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
const rawValue = event.target.value
const value = rawValue.replace(/[^A-Za-z0-9]/gi, '')
setText(value)
}
function handleButtonClick() {
alert(text)
}
return (
<>
<label htmlFor="input">아이디를 입력하세요.</label>
<input
aria-label="input"
id="input"
value={text}
onChange={handleInputChange}
maxLength={20}
/>
<button onClick={handleButtonClick} disabled={text.length === 0}>
제출하기
</button>
</>
)
}
사용자의 키보드 타이핑 입력을 받는 input, 이를 alert로 띄우는 button으로 구성된 간단한 컴포넌트다.
input은 최대 20자까지, 한글 입력만 가능하도록 제한되어있으며, 이는 onChange에서 정규식을 통해 작성되어 있다. 그리고 버튼은 글자가 없으면 disable되도록 처리되어있고, 클릭 시 alert 창을 띄운다.
테스트 코드는 다음과 같다.
import { fireEvent, render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputComponent } from '.'
describe('InputComponent 테스트', () => {
const setup = () => {
const screen = render(<InputComponent />)
const input = screen.getByLabelText('input') as HTMLInputElement
const button = screen.getByText(/제출하기/i) as HTMLButtonElement
return {
input,
button,
...screen,
}
}
it('input의 초기값은 빈 문자열이다.', () => {
const { input } = setup()
expect(input.value).toEqual('')
})
it('input의 최대길이가 20자로 설정되어 있다.', () => {
const { input } = setup()
expect(input).toHaveAttribute('maxlength', '20')
})
it('영문과 숫자만 입력된다.', () => {
const { input } = setup()
const inputValue = '안녕하세요123'
userEvent.type(input, inputValue)
expect(input.value).toEqual('123')
})
it('아이디를 입력하지 않으면 버튼이 활성화 되지 않는다.', () => {
const { button } = setup()
expect(button).toBeDisabled()
})
it('아이디를 입력하면 버튼이 활성화 된다.', () => {
const { button, input } = setup()
const inputValue = 'helloworld'
userEvent.type(input, inputValue)
expect(input.value).toEqual(inputValue)
expect(button).toBeEnabled()
})
it('버튼을 클릭하면 alert가 해당 아이디로 뜬다.', () => {
const alertMock = jest
.spyOn(window, 'alert')
.mockImplementation((_: string) => undefined)
const { button, input } = setup()
const inputValue = 'helloworld'
userEvent.type(input, inputValue)
fireEvent.click(button)
expect(alertMock).toHaveBeenCalledTimes(1)
expect(alertMock).toHaveBeenCalledWith(inputValue)
})
})
-
setup : 내부에서 컴포넌트를 렌더링하고 테스트에 필요한 button,input을 반환한다.
-
userEvent.type : 사용자가 타이핑 하는것을 흉내내는 메서드이다. userEvent.type을 사용하면 사용자가 타이핑하는 것과 동일한 작동을 만들 수 있다.
-
userEvent는 fireEvent의 여러 이벤트를 순차적으로 실행해 좀 더 자세하게 사용자의 작동을 흉내낸다. 예를 들어 userEvent.click를 수행하면 - fireEvent.MouseOver
- fireEvent.mouseMove - fireEvent.mouseDown - fireEvent.mouseUp
- fireEvent.click 을 수행한다.즉 userEvent는 사용자의 작동을 여러 fireEvent를 통해 좀 더 자세하게 흉내 내는 모듈이라고 볼 수 있는 것이다.
대부분의 이벤트를 테스트할 때는 fireEvent로 충분하고 훨씬 빠르지만, 특별히 사용자의 이벤트를 흉내 내야 할 때만 userEvent를 사용한다.
-
jest.spyOn(window,'alert').mockImplementation()
-
spyOn : 특정 메서드를 오염시키지 않고 실행이 되었는지 , 어떤 인수로 실행되었는지 실행과 관련된 정보만 얻고 싶을때 사용한다. 즉 위의 코드에서는 window 객체의 메서드 alert를 구현하지 않고 해당 메서드가 실행되었는지를 관찰한다는 뜻이다.
spyon으로
한번 호출되었는지(toBeCalledTimes(1)),원하는 인수와 함께 호출되었는지(toBeCalledWith(1,2))를 확인할 수 있다. calc.add자체에는 영향을 미치지 않는다. -
mockImplementation: 해당 메서드에 대한 모킹을 도와준다. Jest환경에서는 window.alert가 존재하지 않는데, 이를 모의함수(mock)로 구현할 수 해주는 메서드이다.
즉 jest.spyOn을 사용해 Node.js에서 존재하지 않는 window.alert를 관찰하고, mockImpletation을 사용해 window.alert가 실행되었는지의 정보를 확인할 수 있도록 처리한 것이다.
-
비동기 이벤트가 발생하는 컴포넌트
그렇다면 비동기 이벤트 중 fetch가 실행되는 컴포넌트를 예로 들어보자
이 코드는 버튼을 클릭하면 /todos/:id로 fetch 요청을 보내 데이터를 불러온다.
이 fetch를 어떻게 테스트 할 수 있을까?
우리는 이를 위해 MSW를 사용해 볼 것이다.
MSW는 브라우저에서 사용할 때는 서비스 워커를 활용해 실제 네트워크 요청을 가로채는 방식으로 모킹을 구현하고, Node.js 환경에서는 https나 XMLHttpRequest의 요청을 가로채는 방식으로 작동한다.
즉 node.js에서나 브라우저에서 fetch 요청을 하는 것과 동일하게 네트워크 요청을 수행하고, 이 요청을 중간에 MSW가 감지하고 미리 준비한 모킹 데이터를 제공하는 방식이다.
이는 fetch의 모든 기능을 그대로 사용하고 응답에 대해서만 모킹할 수 있으므로 fetch를 모킹하는게 훨씬 수월해진다.
비동기 함수 컴포넌트 테스트 코드
import { fireEvent, render, screen } from '@testing-library/react'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { FetchComponent } from '.'
const MOCK_TODO_RESPONSE = {
userId: 1,
id: 1,
title: 'delectus aut autem',
completed: false,
}
// 서버를 만드는 역할
const server = setupServer(
rest.get('/todos/:id', (req, res, ctx) => {
const todoId = req.params.id
if (Number(todoId)) {
return res(ctx.json({ ...MOCK_TODO_RESPONSE, id: Number(todoId) }))
} else {
return res(ctx.status(404))
}
}),
)
//
beforeAll(() => server.listen())
// 초기화했던 초기값유지위해서 이 테스트는 마지막이어서 상관없지만, 그렇지않으면 다른 곳에서도 503에러
// afterEach(() => server.resetHandlers());
afterAll(() => server.close())
beforeEach(() => {
render(<FetchComponent />)
})
describe('FetchComponent 테스트', () => {
it('데이터를 불러오기 전에는 기본 문구가 뜬다.', async () => {
const nowLoading = screen.getByText(/불러온 데이터가 없습니다./)
expect(nowLoading).toBeInTheDocument()
})
it('버튼을 클릭하면 데이터를 불러온다.', async () => {
const button = screen.getByRole('button', { name: /1번/ })
fireEvent.click(button)
const data = await screen.findByText(MOCK_TODO_RESPONSE.title)
expect(data).toBeInTheDocument()
})
//여기서만 에러떠야하므로 위에서 resetHandlers 사용
it('버튼을 클릭하고 서버요청에서 에러가 발생하면 에러문구를 노출한다.', async () => {
server.use(
rest.get('/todos/:id', (req, res, ctx) => {
return res(ctx.status(503))
}),
)
const button = screen.getByRole('button', { name: /1번/ })
fireEvent.click(button)
const error = await screen.findByText(/에러가 발생했습니다/)
expect(error).toBeInTheDocument()
})
})
전체 코드를 나눠서 살펴보자
// 서버를 만드는 역할
const server = setupServer(
rest.get('/todos/:id', (req, res, ctx) => {
const todoId = req.params.id
if (Number(todoId)) {
return res(ctx.json({ ...MOCK_TODO_RESPONSE, id: Number(todoId) }))
} else {
return res(ctx.status(404))
}
}),
)
- setupServer: 이름 그대로 서버를 만드는 역할이다. 라우트 /todos/:id의 요청만 가로채서 todoId가 숫자인지 확인한 다음, 숫자일 때만 MOCK_TODO_RESPONSE와 id를 반환하고, 숫자가 아니라면 404를 반환하도록 코드를 구성했다.
테스트 코드를 시작하기 전에는 서버를 기동하고, 종료되면 서버를 종료시킨다.
afterEach에서 server.resetHandlers()가 있는 이유는
만일 서버가 실패가 발생하는 경우를 테스트할 때 ctx.status(503)과 같은 형태로 변경하는데, 이를 리셋하지 않으면 실패하는 코드로 남아있을 것이므로 테스트 실행마다 resetHandlers를 통해 setupServer로 초기화했던 초깃값을 유지하기 위한 것이다.
그 다음 describe를 시작으로 테스트하고 싶은 내용을 테스트 코드로 작성해보자.
it('버튼을 클릭하면 데이터를 불러온다.', async () => {
const button = screen.getByRole('button', { name: /1번/ })
fireEvent.click(button)
const data = await screen.findByText(MOCK_TODO_RESPONSE.title)
expect(data).toBeInTheDocument()
})
버튼을 클릭해 fetch가 발생하는 시나리오를 테스트한다. 버튼을 클릭하는 것 까지는 동일하지만 이후 fetch 응답이 온 뒤에서야 비로소 찾고자 하는 값을 렌더링할 것이다.
요소가 렌더링될 때까지 일정 시간 동안 기다리는 find 메서드를 사용해 요소를 검색한다.
//여기서만 에러떠야하므로 위에서 resetHandlers 사용
it('버튼을 클릭하고 서버요청에서 에러가 발생하면 에러문구를 노출한다.', async () => {
server.use(
rest.get('/todos/:id', (req, res, ctx) => {
return res(ctx.status(503))
}),
)
const button = screen.getByRole('button', { name: /1번/ })
fireEvent.click(button)
const error = await screen.findByText(/에러가 발생했습니다/)
expect(error).toBeInTheDocument()
})
앞서 setupServer는 정상적인 응답만 모킹했기 때문에 에러가 발생하는 경우를 테스트하기 힘들다.
서버 응답이 실패하는 경우를 테스트하기 위해서는 server.use를 사용해 기존 setupServer의 내용을 새롭게 덮어쓴다. 모든 경우에 503이 오도록 작성했고, 서버 설정이 끝난 후에는 findBy를 이용해 에러 문구가 정상적으로 노출됐는지 확인한다.
server.use를 활용해 서버 기본 작동을 덮어쓰는 작업은 위의 코드에서만 유효해야하고, 다른 코드에서는 원래대로 다시 서버 작동이 되어야하므로 위에서 afterEach에서 resetHandlers를 실행하는 것이다.
비동기 컴포넌트의 테스트에서 중요한 것은 MSW를 사용한 fetch 응답 모킹과 findBy를 활용해 비동기 요청이 끝난 뒤에 제대로 된 렌더링이 일어났는지 기다린 후에 확인하는 것이다.
마치며
지금까지 리액트에서 사용될 수 있는 3가지 종류의 컴포넌트 정적 컴포넌트, 동적 컴포넌트, 비동기 이벤트가 발생하는 컴포넌트의 간단한 테스트 코드를 살펴보았다.
책에는 이외에도 추가로 사용자 정의 훅을 테스트하기가 있는데, 이 부분은 조만간 테스트가 익숙해진 이후 실제로 프로젝트에서 사용해본 사용자 정의 훅을 테스트하는 글을 포스팅해볼 예정이다.
테스트에 관한 글은 계속 올릴 것 이니 많관부.