리액트 애플리케이션에서 비동기 작업이 있을 때는 이를 어떻게 테스팅 하는지, 그리고 API 요청을 해야 하는 경우 이를 어떻게 mock 할 수 있는지에 대해서 알아보겠습니다.
우리가 이전에 만들었던 rtl-tutorial 프로젝트 디렉터리를 다시 에디터로 열어주세요.
DelayedToggle 라는 컴포넌트를 만들어보세요.
src/DelayedToggle.js
import React, { useState, useCallback } from 'react';
const DelayedToggle = () => {
const [toggle, setToggle] = useState(false);
// 1초 후 toggle 값을 반전시키는 함수
const onToggle = useCallback(() => {
setTimeout(() => {
setToggle(toggle => !toggle);
}, 1000);
}, []);
return (
<div>
<button onClick={onToggle}>토글</button>
<div>
상태: <span>{toggle ? 'ON' : 'OFF'}</span>
</div>
{toggle && <div>야호!!</div>}
</div>
);
};
export default DelayedToggle;
컴포넌트를 만드셨으면 App 에서 렌더링하고 yarn start
를 해서 브라우저에 띄운 후 버튼을 눌러보세요.
src/App.js
import React from 'react';
import DelayedToggle from './DelayedToggle';
const App = () => {
return <DelayedToggle />;
};
export default App;
이 컴포넌트는 다음과 같이 작동합니다.
버튼이 클릭되면 1초후 상태 값이 바뀌고, 상태가 ON 일때는 "야호!!" 라는 텍스트가 보여집니다.
이런 컴포넌트의 테스트는 어떻게 작성 할 수 있는지 알아봅시다.
이런 테스트는 react-testing-library
에서 지원하는 Async Utilities 함수들을 사용하여 작성 할 수 있습니다.
Aync Utilities 는 총 4가지 함수가 있는데요, 각 함수들을 직접 사용해보면서 사용법을 익혀봅시다.
function wait(
callback?: () => void,
options?: {
timeout?: number
interval?: number
}
): Promise<void>
wait
함수를 사용하면 특정 콜백에서 에러를 발생하지 않을 때 까지 대기할 수 있습니다. DelayedToggle 컴포넌트의 테스트 케이스를 다음과 같이 만들어보세요.
src/DelayedToggle.test.js
import React from 'react';
import DelayedToggle from './DelayedToggle';
import {
render,
fireEvent,
wait,
waitForElement,
waitForDomChange,
waitForElementToBeRemoved
} from 'react-testing-library';
describe('<DelayedToggle />', () => {
it('reveals text when toggle is ON', async () => {
const { getByText } = render(<DelayedToggle />);
const toggleButton = getByText('토글');
fireEvent.click(toggleButton);
await wait(() => getByText('야호!!')); // 콜백 안의 함수가 에러를 발생시키지 않을 때 까지 기다립니다.
});
});
wait
함수는 콜백 안의 함수가 에러가 발생시키지 않을 때 까지 기다리다가, 대기시간이 timeout 을 초과하게 되면 테스트 케이스가 실패합니다. timeout 은 기본값 4500ms이며, 이는 다음과 같이 커스터마이징을 할 수 있습니다.
await wait(() => getByText('야호!!'), { timeout: 3000 }); // 콜백 안의 함수가 에러를 발생시키지 않을 때 까지 기다립니다.
이제 yarn test
를 입력하면 테스트가 진행이 될텐데, 리액트 16.8을 쓰고 계시다면 다음과 같은 경고가 발생 할 것입니다.
● Console
console.error node_modules/react-dom/cjs/react-dom.development.js:506
Warning: An update to DelayedToggle inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that you're testing the behavior the user would see in the browser. Learn more at https://fb.me/react-wrap-tests-with-act
in DelayedToggle (at DelayedToggle.test.js:14)
이는 리액트 16.9 에서는 고쳐지는 버그인데요, 아직 릴리즈되지는 않았습니다. 따라서, 이 경고를 숨기기 위하여 setupTests.js 파일을 다음과같이 수정해보세요.
src/setupTests.js
import 'react-testing-library/cleanup-after-each';
import 'jest-dom/extend-expect';
// this is just a little hack to silence a warning that we'll get until react
// fixes this: https://github.com/facebook/react/pull/14853
const originalError = console.error;
beforeAll(() => {
console.error = (...args) => {
if (/Warning.*not wrapped in act/.test(args[0])) {
return;
}
originalError.call(console, ...args);
};
});
afterAll(() => {
console.error = originalError;
});
작성 후 테스트 CLI 를 종료 후 다시 실행하세요.
function waitForElement<T>(
callback: () => T,
options?: {
container?: HTMLElement
timeout?: number
mutationObserverOptions?: MutationObserverInit
}
): Promise<T>
waitForElement
함수는 특정 엘리먼트가, 나타났거나, 바뀌었거나, 사라질때까지 대기를 해줍니다. 그리고 프로미스가 끝날 때 우리가 선택한 엘리먼트를 resolve 합니다.
DelayedToggle 컴포넌트의 텍스트가 바뀌는 것을 검증하는 테스트 케이스를 waitForElement
로 한번 구현을 해보겠습니다.
src/DelayedToggle.test.js
import React from 'react';
import DelayedToggle from './DelayedToggle';
import {
render,
fireEvent,
wait,
waitForElement,
waitForDomChange,
waitForElementToBeRemoved
} from 'react-testing-library';
describe('<DelayedToggle />', () => {
it('reveals text when toggle is ON', async () => {
const { getByText } = render(<DelayedToggle />);
const toggleButton = getByText('토글');
fireEvent.click(toggleButton);
await wait(() => getByText('야호!!')); // 콜백 안의 함수가 에러를 발생시키지 않을 때 까지 기다립니다.
});
it('toggles text ON/OFF', async () => {
const { getByText } = render(<DelayedToggle />);
const toggleButton = getByText('토글');
fireEvent.click(toggleButton);
const text = await waitForElement(() => getByText('ON'));
expect(text).toHaveTextContent('ON');
});
});
function waitForDomChange<T>(options?: {
container?: HTMLElement
timeout?: number
mutationObserverOptions?: MutationObserverInit
}): Promise<T>
waitForDomChange
의 특징은, 콜백함수가 아니라 검사하고 싶은 엘리먼트를 넣어주면 해당 엘리먼트에서 변화가 발생 할 때 까지 기다려준다는 것 입니다. 우리가 render
를 했을때 결과값에 있는 container
를 넣어주면, 사전에 쿼리를 통하여 엘리먼트를 선택하지 않아도 변화가 발생했음을 감지할 수 있습니다. 또한, 프로미스가 resolve 됐을 땐 mutationList
를 반환하여 DOM이 어떻게 바뀌었는지에 대한 정보를 알수있습니다.
src/DelayedToggle.test.js
import React from 'react';
import DelayedToggle from './DelayedToggle';
import {
render,
fireEvent,
wait,
waitForElement,
waitForDomChange,
waitForElementToBeRemoved
} from 'react-testing-library';
describe('<DelayedToggle />', () => {
it('reveals text when toggle is ON', async () => {
const { getByText } = render(<DelayedToggle />);
const toggleButton = getByText('토글');
fireEvent.click(toggleButton);
await wait(() => getByText('야호!!')); // 콜백 안의 함수가 에러를 발생시키지 않을 때 까지 기다립니다.
});
it('toggles text ON/OFF', async () => {
const { getByText } = render(<DelayedToggle />);
const toggleButton = getByText('토글');
fireEvent.click(toggleButton);
const text = await waitForElement(() => getByText('ON'));
expect(text).toHaveTextContent('ON');
});
it('changes something when button is clicked', async () => {
const { getByText, container } = render(<DelayedToggle />);
const toggleButton = getByText('토글');
fireEvent.click(toggleButton);
const mutations = await waitForDomChange({ container });
console.log(mutations);
});
});
function waitForElementToBeRemoved<T>(
callback: () => T,
options?: {
container?: HTMLElement
timeout?: number
mutationObserverOptions?: MutationObserverInit
}
): Promise<T>
waitForElementToBeRemove
는 특정 엘리먼트가 화면에서 사라질때까지 기다리는 함수입니다.
src/DelayedToggle.test.js
import React from 'react';
import DelayedToggle from './DelayedToggle';
import {
render,
fireEvent,
wait,
waitForElement,
waitForDomChange,
waitForElementToBeRemoved
} from 'react-testing-library';
describe('<DelayedToggle />', () => {
it('reveals text when toggle is ON', async () => {
const { getByText } = render(<DelayedToggle />);
const toggleButton = getByText('토글');
fireEvent.click(toggleButton);
await wait(() => getByText('야호!!')); // 콜백 안의 함수가 에러를 발생시키지 않을 때 까지 기다립니다.
});
it('toggles text ON/OFF', async () => {
const { getByText } = render(<DelayedToggle />);
const toggleButton = getByText('토글');
fireEvent.click(toggleButton);
const text = await waitForElement(() => getByText('ON'));
expect(text).toHaveTextContent('ON');
});
it('changes something when button is clicked', async () => {
const { getByText, container } = render(<DelayedToggle />);
const toggleButton = getByText('토글');
fireEvent.click(toggleButton);
const mutations = await waitForDomChange({ container });
});
it('removes text when toggle is OFF', async () => {
const { getByText, container } = render(<DelayedToggle />);
const toggleButton = getByText('토글');
fireEvent.click(toggleButton);
await waitForDomChange({ container }); // ON 이 됨
getByText('야호!!');
fireEvent.click(toggleButton);
await waitForElementToBeRemoved(() => getByText('야호!!'));
});
});
이제, 컴포넌트의 UI 가 비동기적으로 바뀔 때 어떻게 처리해야 되는지 잘 알겠지요?
이번에는 리액트 컴포넌트에서 REST API 를 연동하는 경우 어떻게 테스트를 해야하는지 알아봅시다.
테스트 할 컴포넌트를 먼저 만들어봅시다!
우선 HTTP Client 라이브러리인 axios 를 설치하세요.
$ yarn add axios
우리는 JSONPlaceholder 에서 제공하는 가짜 API 를 사용하겠습니다.
GET https://jsonplaceholder.typicode.com/users/1
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz",
"address": {
"street": "Kulas Light",
"suite": "Apt. 556",
"city": "Gwenborough",
"zipcode": "92998-3874",
"geo": {
"lat": "-37.3159",
"lng": "81.1496"
}
},
"phone": "1-770-736-8031 x56442",
"website": "hildegard.org",
"company": {
"name": "Romaguera-Crona",
"catchPhrase": "Multi-layered client-server neural-net",
"bs": "harness real-time e-markets"
}
}
id 값을 props 로 받아오면, 위 API 를 호출하고 결과에서 username 과 email 을 보여주는 컴포넌트를 만들어봅시다.
src/UserProfile.js
import React, { useEffect, useState } from 'react';
import axios from 'axios';
const UserProfile = ({ id }) => {
const [userData, setUserData] = useState(null);
const [loading, setLoading] = useState(false);
const getUser = async id => {
setLoading(true);
try {
const response = await axios.get(
`https://jsonplaceholder.typicode.com/users/${id}`
);
setUserData(response.data);
} catch (e) {
console.log(e);
}
setLoading(false);
};
useEffect(() => {
getUser(id);
}, [id]);
if (loading) return <div>로딩중..</div>;
if (!userData) return null;
const { username, email } = userData;
return (
<div>
<p>
<b>Username: </b>
{username}
</p>
<p>
<b>Email: </b>
{email}
</p>
</div>
);
};
export default UserProfile;
컴포넌트를 만드셨으면, App 에서 렌더링해서 이 컴포넌트가 어떻게 작동하는지 확인해보세요.
이렇게 REST API 를 호출해야 하는 컴포넌트의 경우, 테스트 코드에서도 똑같이 요청을 보낼 수는 있지만, 일반적으로 서버에 API 를 직접 호출하지는 않고 이를 mocking 합니다. 왜냐하면, 서버의 API 가 실제로 작동하고 안하고는 서버쪽의 일이기 때문이기 때문입니다.
때문에, axios 를 사용했을 때 실제로 요청이 발생하지는 않지만 마치 발생한것처럼 작동하게 하는 방법이 있는데요, 대표적으로 두가지가 있는데 node_modules 를 mocking 하는 방법이 있고, axios-mock-adapter 라는 라이브러리를 쓰는 방법이 있습니다.
우리는 axios-mock-adapter
를 사용하겠습니다. 라이브러리를 사용하는편이 준비해야 할 코드도 적고 훨씬 편리합니다.
UserProfile 의 테스트 코드를 다음과 같이 작성해보세요.
src/UserProfile.test.js
import React from 'react';
import { render } from 'react-testing-library';
import UserProfile from './UserProfile';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
describe('<UserProfile />', () => {
const mock = new MockAdapter(axios, { delayResponse: 200 }); // 200ms 가짜 딜레이 설정
// API 요청에 대하여 응답 미리 정하기
mock.onGet('https://jsonplaceholder.typicode.com/users/1').reply(200, {
id: 1,
name: 'Leanne Graham',
username: 'Bret',
email: 'Sincere@april.biz',
address: {
street: 'Kulas Light',
suite: 'Apt. 556',
city: 'Gwenborough',
zipcode: '92998-3874',
geo: {
lat: '-37.3159',
lng: '81.1496'
}
},
phone: '1-770-736-8031 x56442',
website: 'hildegard.org',
company: {
name: 'Romaguera-Crona',
catchPhrase: 'Multi-layered client-server neural-net',
bs: 'harness real-time e-markets'
}
});
it('loads userData properly', () => {
// TODO
});
});
MockAdapter
를 사용하면 특정 API 요청이 발생했을 때 어떤 응답이 와야 하는지 직접 정의해줄 수 있습니다. 그러면, 컴포넌트 내부에서 API 요청이 발생하게 될 때, 실제로 서버까지 요청이 날라가지 않고, 우리가 정의한 가짜 응답을 사용하게 됩니다.
MockAdapter 를 사용 할 때는 delayResponse
옵션을 설정하면 딜레이를 임의적으로 설정할 수 있습니다. 이 설정은 없어도 상관 없습니다.
이렇게 axios 요청을 mocking 한 이후에는 우리가 이전에 배웠던 Async Utilities 를 사용해주면 됩니다.
src/UserProfile.test.js
import React from 'react';
import { render, waitForElement } from 'react-testing-library';
import UserProfile from './UserProfile';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
describe('<UserProfile />', () => {
const mock = new MockAdapter(axios, { delayResponse: 200 }); // 200ms 가짜 딜레이 설정
// API 요청에 대하여 응답 미리 정하기
mock.onGet('https://jsonplaceholder.typicode.com/users/1').reply(200, {
id: 1,
name: 'Leanne Graham',
username: 'Bret',
email: 'Sincere@april.biz',
address: {
street: 'Kulas Light',
suite: 'Apt. 556',
city: 'Gwenborough',
zipcode: '92998-3874',
geo: {
lat: '-37.3159',
lng: '81.1496'
}
},
phone: '1-770-736-8031 x56442',
website: 'hildegard.org',
company: {
name: 'Romaguera-Crona',
catchPhrase: 'Multi-layered client-server neural-net',
bs: 'harness real-time e-markets'
}
});
it('calls getUser API loads userData properly', async () => {
const { getByText } = render(<UserProfile id={1} />);
await waitForElement(() => getByText('로딩중..')); // 로딩중.. 문구 보여줘야함
await waitForElement(() => getByText('Bret')); // Bret (username) 을 보여줘야함
});
});
테스트가 잘 통과했나요?
axios-mock-adapter
의 공식 문서를 보면 더 많은 활용방법을 볼 수 있는데요, 그 중 일부를 어떤 용도로 사용 할 수 있는지 소개시켜드리겠습니다.
mock.onGet('/users').replyOnce(200, users);
이렇게 하면 요청을 딱 한번만 mocking 할 수 있습니다. 한번 요청을 하고 나면 그 다음 요청은 정상적으로 요청이 됩니다.
mock
.onGet('/users')
.replyOnce(200, users) // 첫번째 요청
.onGet('/users')
.replyOnce(500); // 두번째 요청
이렇게 하면 첫번째 요청과 두번째 요청을 연달아서 설정 할 수 있습니다. 요청을 여러번 해야 하는 경우 이런 형태로 구현하시면 됩니다.
보통 메서드에 따라 onGet()
, onPost()
이런식으로 사용하는데요, onAny()
를 사용하면 어떤 메서드던 mocking 을 할 수 있습니다.
mock.onAny('/foo').reply(200);
만약에 주소까지 생략하면 어떤 주소던 mocking 합니다.
mock.onAny().reply(200);
mock 인스턴스에는 reset
과 restore
라는 함수가 있습니다.
mock.reset();
reset
은 mock 인스턴스에 등록된 모든 mock 핸들러를 제거합니다. 만약에 테스트 케이스별로 다른 mock 설정을 하고 싶으시면 이 함수를 사용하시면 됩니다.
mock.restore();
restore
은 axios 에서 mocking 기능을 완전히 제거합니다. 만약에 실제 테스트를 하다가 요청이 실제로 날라가게 하고 싶으면 이 함수를 사용하면 됩니다.
waitFor error "MutationObserver is not a constructor" with latest version 에러 나시는 분들은 아래 사이트 참고하시면 해결할 수 있습니다!! 🔥
https://github.com/testing-library/dom-testing-library/issues/477
문서 감사히 잘 읽었습니다. waitForDomChange()는 이제 deprecated되어 waitFor()를 통해 specific한 테스트를 권장하는 것 같습니다. https://spectrum.chat/testing-library/help-react/waitfordomchange-vs~0dd3a14d-27dd-4e45-9769-14eac75a1f06?m=MTU4ODg3MTQ0NDk4Mw== 순서대로 하나씩 따라해보며 테스트를 배울 수 있었습니다. 감사합니다!
그리고 현재 cra 3.4.1 기준으로 waitFor() 사용시 TypeError: MutationObserver is not a constructor 가 발생합니다. https://github.com/testing-library/dom-testing-library/releases/tag/v7.0.0 여기 Option 2 (recommended)를 참고하셔서 jest-environment-jsdom-sixteen를 설치하시고, "test": "react-scripts test --env=jest-environment-jsdom-sixteen"로 테스트를 진행하시면 잘 됩니다.
좋은 강의 올려주셔서 정말 감사합니다!!!
정말 많은 도움이 되었습니다 💕