react-testing-library 의 fireEvent 를 userEvent로 마이그레이션 하기 with Antd

valley
더핑크퐁컴퍼니 기술 블로그
17 min readMay 1, 2022

안녕하세요. 더핑크퐁컴퍼니에서 프론트엔드 개발 업무를 맡고 있는 밸리 입니다.🐰

이번 글에서는 사내 백오피스 서비스에 프론트엔드 테스트를 붙이고, 마이그레이션한 경험을 공유 드리고자 합니다.

글을 읽기 전

userEvent에 대한 모든 API를 다루지 않습니다. 사용하면서 겪었던 어려움이나, 14로 업데이트 하며 바뀐 점 그리고 사용 경험기를 공유합니다. 자세한 내용은 공식 문서를 참고하시길 바랍니다.

@testing-library

testing-library는 UI 컴포넌트를 테스트하도록 도와주는 패키지 묶음입니다.

해당 라이브러리는 BDT(Behavior Driven Test)라는 행위 주도 테스트 방법론을 기반으로 하고 있습니다.

기존에는 IDT(Implementation Driven Test)라는 구현 주도 테스트를 주로 진행해왔습니다. 이에 대표적인 테스트 도구로 Enzyme이 있습니다.

둘의 차이를 간단하게 설명하기 위해 간단한 코드를 작성해보았습니다. 아래 코드를 테스트한다고 했을 때 BDT와 IDT는 테스트의 목적이 다릅니다.

<div> 
Hello My Test
<strong>important content</strong>
</div>

IDT는 Hello My Test 라는 텍스트가 div 태그에 들어가 있는지, important content 라는 텍스트가 strong 태그 안에 들어 있는지에 대한 여부가 테스트가 중심이 되지만,

BDT는 Hello My Test 가 실제로 페이지 내에 그려졌는지, important content 가 페이지 내에 그려졌는지가 중심이 됩니다.

BDT가 대두되었던 이유는 유저 입장에서는 해당 콘텐츠가 어떤 태그로 구현되어 있고 어떤 구조를 가지고 있는지보다, 내가 원하는 콘텐츠가 보여지고 있는지가 더 중요하기 때문입니다.

@testing-library 에서는 리액트 코드를 테스트 할 수 있도록 @testing-library/react 를 제공합니다.

npm trends 에서 근 1년간 npm 다운로드수를 표시한 그래프 입니다. @testing-library/reactEnzyme을 비교했을 때 그 인기를 실감할 수 있습니다.

react-testing-library의 fireEvent를 userEvent로 변경한 이유

기존에 사내 백오피스에 붙어있던 테스트 코드는 fireEvent를 사용했습니다.

fireEvent는 유저가 발생시키는 액션에 대한 테스트를 진행하기 위해 나온 react-testing-library api 입니다.

click, type, focus, cut 등의 유저의 이벤트를 정의하고 있습니다.

2018년도 무렵 userEvent 의 v1 이 출시 되었습니다.

userEvent는 fireEvent보다 사용자의 상호작용을 더 세세하게 구현했다고 되어있습니다.

간단한 예시를 들어보겠습니다.

유저가 클릭 이벤트를 호출할 때 무슨 일이 일어날까요?

상황마다 다를 수 있지만, 일반적으로는 마우스를 호버한 후에 클릭을 하는 등의 이벤트들이 발생할 것입니다.

대표적인 fireEvent의 click 과 userEvent 의 click 을 비교해보겠습니다.

fireEvent.click 은 단순히 click 이벤트만 발생시키지만,

userEvent.click 은 어떤 element에서 발생한 이벤트인지 체크한 후에 해당 element 에서 클릭이벤트 호출 시 실제 브라우저에서 발생하는 모든 이벤트를 같이 발생시켜줍니다.

아래는 userEvent click의 내부 코드 입니다.

function click(element, init, {skipHover = false, clickCount = 0} = {}) {
if (!skipHover) hover(element, init)
switch (element.tagName) {
case 'LABEL':
clickLabel(element, init, {clickCount})
break
case 'INPUT':
if (element.type === 'checkbox' || element.type === 'radio') {
clickBooleanElement(element, init, {clickCount})
} else {
clickElement(element, init, {clickCount})
}
break
default:
clickElement(element, init, {clickCount})
}
}

코드에서 보다시피, userEvent 의 click API 는 element 의 tagName 에 따라 클릭과 그에 따르는 이벤트들을 발생시켜 줍니다.

이뿐만아니라 셀렉트 옵션을 클릭하거나 날짜를 선택하는 등의 이벤트도 실제 유저가 브라우저와 상호작용하듯이 이벤트를 발생시켜 줍니다.

위와 같은 이유로 기존에 작성되어 있던 테스트 코드를 userEvent로 마이그레이션하는 작업을 진행하게 되었습니다.

테스트 코드를 작성하던 중 (2022.03) 14버전이 출시 되었습니다. 따라서 이번 글은 14버전 기준으로 작성하겠습니다.

userEvent v14 버전은 뭐가 달라졌나?

userEvent v14에는 각종 버그들이 fix되고, 새로운 기능들이 추가 및 변경 되었습니다. 아래 목록은 많이 사용했던 API의 주요 변경사항을 정리해 두었습니다.

자세한 내용은 github 에서 보실 수 있습니다.

  • userEvent api의 return type이 Promise로 변경 되어, 비동기 환경 테스트에서도 await를 걸어 테스트할 수 있게 되었다.
  • pointer의 skipPointerEvents가 사라지고, pointerEventsCheck: PointerEventsCheckLevel.Never를 대신 사용하도록 바뀌었다.
  • userEvent.upload에서 applyAccept: true가 기본값이 되었다.
  • userEvent.paste API에 새로운 매개변수가 나왔다.
  • 아래 API에서 init 매개변수가 제거되었다.

userEvent.upload

userEvent.click

userEvent.dblClick

userEvent.tripleClick

userEvent.hover

userEvent.unhover

userEvent.selectOptions

userEvent.deselectOptions

  • setup api가 추가되었다.
  • userEvent.tab()에서 focusTrap 옵션이 제거되었다.

Install & Setting

yarn add -D @testing-library/user-event @testing-library/jest-dom @testing-library/react

myUserEventTest.test.js

fireEvent를 userEvent 로 마이그레이션 하기

userEvent 에서 인풋값을 변경할 때는 userEvent.type 사용하기

기존 fireEvent 에서는 인풋값을 변경할 때 change API를 사용했습니다.

fireEvent.change(input, {target: {value: 'World'}})

userEvent에서는 type을 사용할 수 있습니다.

userEvent.type(input, 'World');

인풋박스에 이미 ‘Hello,’ 라는 값이 있다고 가정해보겠습니다.

render(<input defaultValue="Hello,"/>)
const input = screen.getByRole('textbox')
await user.type(input, ' World!')expect(input).toHaveValue('Hello, World!')

이 상태에서 해당 코드를 사용하면, 인풋값 value 는 ‘Hello, World’ 가 되어 있는 것을 확인할 수 있습니다.

만약 World만 넣고 싶다면, clear를 사용해서 value를 초기화 시켜주어야 합니다.

user.clear(input);
user.type(input, 'World');
expect(input).toHaveValue('World');

기존 fireEvent는 change를 통해 value 값을 바꿔주는 테스트를 진행했다면, userEvent는 실제로 유저가 브라우저에서 하는 행동을 반영합니다.

방금 예시에서와 같이 input에 defaultValue가 들어있는 상황이라면, fireEvent는 이를 덮어쓰는 반면, userEvent 는 텍스트를 추가 합니다.

새로운 값을 넣으려고 할때 fireEvent 가 더 편해보일 수 있지만, 유저는 input에 있는 값을 지운 적이 없으므로 userEvent의 동작이 더 정확하다고 말할 수 있습니다.

userEvent 로 클릭할 수 없는 요소를 클릭해야 할 때

클릭할 수 없는 요소란, pointer-events: none 상태를 말합니다.

UI 라이브러리를 사용하는 경우, 기능에 걸맞는 htmlElement를 사용하는 것이 아닌, div나 span 등의 다른 태그로 기능과 모양을 흉내내어 구현되어있는 경우가 있습니다.

예시로 antd의 select component의 구현은 htmlSelectElement 가 아닌 div 와 span 태그를 사용하여 구현되어 있습니다.

date picker 또한 날짜를 선택 하는 부분은 table 태그를 사용했습니다.

antd의 date picker component 테스트를 살펴보겠습니다. 테스트의 과정은 아래와 같습니다.

  1. input 창을 클릭하고 원하는 날짜를 클릭한다.
  2. 클릭한 상태로 다음 날짜(end)를 선택한다.
  3. startDate와 endDate 인풋 요소를 업데이트한다.

우선 기존 fireEvent로 작성된 예제를 살펴보겠습니다.

mouseDown, click, change 등 모두 함께 작성하여 테스트를 진행한 것을 보실 수 있습니다.

const [startDate, endDate] = container.querySelectorAll(
'div[name="period"] input'
);

fireEvent.mouseDown(startDate);
fireEvent.change(startDate, {
target: { value: '2022-05-01' },
});
fireEvent.click(document.querySelector('.ant-picker-cell-selected'));fireEvent.change(endDate, { target: { value: '2022-06-05'` } });fireEvent.click(document.querySelector('.ant-picker-cell-selected'));

이번엔 userEvent로 작성해보겠습니다. 이제 저희는 mouseDown 같은 요소들을 신경 쓸 필요가 없습니다.

userEvent.click(startDate);userEvent.type(startDate, `${year}-${padMonth}-01`);userEvent.click(document.querySelector('.ant-picker-cell-selected'));userEvent.type(endDate, `${year}-${padMonth}-02`);userEvent.click(document.querySelector('.ant-picker-cell-selected'));

하지만 위 테스트 코드를 돌려보면 아래와 같은 에러가 발생합니다.

unable to click element as it has or inherits pointer-events set to “none”.

클릭할 수 있는 요소가 아니라는 에러 입니다. 우리가 선택한 태그는 td 이며, td 는 클릭 하도록 나온 요소가 아닙니다. 단순하게 생각하면 아래처럼 요소의 css 속성을 변경해줄 수도 있습니다.

.ant-picker-cell-selected {
pointer-events: auto !important;
}

이런 경우 가장 좋은 방법은 userEvent 에서 제공하는 pointerEventsCheck 옵션을 사용하는 것입니다.

const user = userEvent.setup({
pointerEventsCheck: PointerEventsCheckLevel.Never,
});

Select 테스트 하기

userEvent에서는 selectOptions라는 API를 제공합니다. selectOptions 는 select 태그에만 사용할 수 있으며, 다중선택은 multiple select 에만 사용할 수 있습니다.

Antd Multiple Select Component 테스트

antd multiple select 테스트를 진행해보겠습니다.

위에서 말했다시피 antd의 select component의 구현은 htmlSelectElement가 아닌 div 와 span 태그를 사용하여 구현되어 있습니다.

이런 경우 selectOptions 를 사용할 수 없기에 mocking 을 해주어야 합니다.

mocking은 아래처럼 할 수 있습니다. (setupTests.js)

(아래 코드는 정상작동 하지 않습니다. 이유는 아래에서 설명하겠습니다)

그리고 테스트를 진행했습니다.

기존에는 아래와 같이 fireEvent 로 작성되어 있었습니다.

fireEvent.change(sample, {
target: {
value: 'a'
}
});
fireEvent.change(sample, {
target: {
value: 'b'
}
});

해당 코드를 userEvent 의 selectOptions를 사용하도록 변경했습니다.

(실제 프로젝트 로직상의 이유로 selectOptions 을 두 번 호출했습니다. userEvent의 selectOptions API는 누적되도록 설계되었기 때문에 여러 번 호출하면 여러 개 선택됩니다.)

userEvent.selectOptions(sample, ['a']);
userEvent.selectOptions(sample, ['b']);

그런데, 이렇게 하니 a만 두 번 선택되는 것처럼 보이는 이슈가 생겼습니다.

원인을 알 수가 없어 antd multiple select를 mocking한 코드 그대로 화면상에 그려보고 값이 바뀔 때마다 console을 찍도록 해보았습니다.

위 화면을 보면 분명 a 선택 후 b를 선택했음에도 불구하고, a가 두 번 선택 된 것처럼 동작했습니다.

알고보니 antd select 를 mocking 한 코드의 L14 에 문제가 있었습니다.

value : It gives the value of the first selected option

value는 첫 번째 선택한 옵션의 값을 제공합니다. 라고 되어 있었습니다.

따라서 e.target.selectedOptions를 사용해 setupTest.js의 L14 번째 코드를 변경해주었습니다.

selectValues = Array.from(e.target.selectedOptions).map(element => element.value );

이렇게 하니 테스트가 정상적으로 돌아가는 것을 확인할 수 있었습니다.

🧐 그런데 fireEvent 때는 발생하지 않았을까요?

fireEvent는 change를 통해 말그대로 value를 직접 수정하기에, html multiple select의 기본 동작에 방해받지 않고 테스트가 되었기 때문입니다.

따라서 userEvent에서만 발생하게 되었던 것입니다.

해당 과정을 통해 UI 라이브러리 테스트를 위해 컴포넌트를 mocking 할 때는 html의 기본 특성을 잘 파악하고 있어야 겠다고 느꼈습니다.

blur 요소는 어떻게 테스트?

input 창에 아무 데이터도 입력하지 않았을 때 필수 입력 값 이라는 문구가 잘뜨는지 테스트를 하고 싶은 경우가 있을 겁니다.

fireEvent를 사용하면 아래 처럼 할 수 있습니다.

fireEvent.blur(input);
await waitFor(() => {
expect(screen.getByText('필수 입력사항 입니다.')).toBeInTheDocument();
});

해당 로직을 userEvent로 테스트할 땐 좀 더 유저와 친숙하게 짜야 합니다.

현재 저희는 인풋 창을 클릭하고, 데이터를 입력 안 한 채 다른 아무 곳을 클릭하면 필수 입력 문구가 뜨게 구현이 되어 있습니다.

따라서 아래처럼 작성할 수 있습니다.

userEvent.click(name);
userEvent.click(document.body);
await waitFor(() => {
expect(screen.getByText('필수 입력사항 입니다.')).toBeInTheDocument();
});

끝으로

이렇게 Form UI 테스트 시에 userEvent에서 가장 많이 쓰이는 click, selectOptions, type API 사용 및 UI Library를 mocking하여 테스트하는 일부 과정까지 공유해보았습니다.

예전보다는 프론트엔드 생태계에서 테스트 코드를 짜는 비율이 높아졌지만 아직도 프론트엔드 개발자가 테스트 코드를 짜야 하는가에 대한 갑론을박이 나오고 있는 것 같습니다.

개인적인 생각으로는 Coverage 100%는 굳이? 라는 생각이 들면서도, 비지니스 로직을 담당하는 부분들은 테스트 코드의 여부가 개발자의 수고를 덜어주고 생산성을 높이는데 도움이 될 것 이라고 생각합니다.

읽어주셔서 감사합니다.🙂

--

--