React Testing Library를 이용한 Third party library 단위 테스트

Jest와 React Testing Library를 사용하여 third-party 라이브러리 단위테스트의 어려움과 해결하는 사례를 공유한다.

개인적으로 최근까지 Vue기반 프론트엔드 개발을 하다가, 오랜만에 React를 사용하여 개발하고 있다. 예전에는 Enzyme를 사용하여 shallow render에서 단위 테스트를 진행했는데, 최근 React에는 Testing Libary를 사용하여 브라우저에서 동작하는 Full render 방식에서 단위 테스를 작성하는 것이 추세인 것 같다. (참고로, Enzyme은 글을 작성하는 현재 React 18을 지원하지 않고 있다.)
React Testing Library

1. 게시글 등록 설명

앞서 Jodit Editor를 사용하여, 게시글을 등록하는 기능을 만들었는데, Jodit Editor를 감싼 Editor 컴포넌트를 만들었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Editor.js

const handleChange = (htmlContents) => {
const plaintext = Jodit.modules.Helpers.stripTags(htmlContents);
onChange({
htmlContents,
plainContents: plaintext,
});
};

return (
<JoditEditor
ref={editor}
value={content}
config={config}
tabIndex={1} // tabIndex of textarea
onBlur={(newContent) => setContent(newContent)} // preferred to use only this option to update the content for performance reasons
onChange={handleChange}
/>
);

게시판 등록 페이지(NewArticle.js)에서는 Editor 컴포넌트를 사용하여, onChange 이벤트 핸들러 함수(handleChangeContents)에서 에디터에서 변경된 html과 plainHtml을 전달받아 state로 관리한다.
저장 버튼을 클릭하면, 게시글 제목과 에디터에서 등록된 htmlContents, plainContents 내용을 가지고 신규 게시글을 등록한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# NewArticle.js

export default function NewArticle() {
const [contents, setContents] = useState({
htmlContents: '내용을 입력하세요.',
plainContents: '',
});

const handleChangeContents = (contents) => {
setContents({ ...contents });
};

const addNewArticle = async () => {
const { htmlContents, plainContents } = contents;
await bulletinBoardApi.addArticle(title, htmlContents, plainContents);
};

return (
<PageContainer>
<TitleContainer>
<Title>게시글 추가</Title>
</TitleContainer>
<div className="new-article--container">
<div className="new-article--editor-wrapper">
<Editor html={contents.htmlContents} onChange={handleChangeContents} />
</div>
<ButtonGroupWrapper>
<Button variant="outlined" onClick={moveBack}>
취소
</Button>
<Button variant="contained" onClick={addNewArticle}>
저장
</Button>
</ButtonGroupWrapper>
</div>
</PageContainer>

2. Third-pary 라이브러리의 Full render에서 테스트 어려움

Jodit Editor의 onChange 이벤트를 실행하기 위해서, 내부적으로 Jodit Editor가 동작하는 방식을 확인해야 한다. Shallow render 방식에서는 Editor 컴포넌트를 찾고, 이벤트를 실행하면 간단하지만 Full render 방식에서는 Jodit editor가 어떻게 render되고, 그 안에서 onChange 이벤트를 실행하기 위한 동작 확인이 필요하다. (단순히 <p> 태그를 찾아서 innerHtml에 컨텐츠를 수정하는 방식으로는 onChange 이벤트가 실행되지 않았다.)

Jodit Editor에서 onChange 이벤트를 실행해야만 NewArticle 페이지에서 컨텐츠 내용을 세팅할 수 있는데, 테스트를 작성하려는 대상인 SUT(System Under Test)에 집중해야 하는데 테스트 대상보다는 내부에 의존하고 있는 Third-party 라이브러리 분석에 시간이 오래 걸리는 상황이 단위 테스트 작성하기 어렵게 만들었다.

이런 문제를 해결하기 위해서 테스트 하기 쉬운 방법을 찾아야만 했다.

Third-party 라이브러리르 사용하고 있는 컴포넌트 Mocking을 통해서 문제를 쉽게 해결할 수 있다. 따라서 Shallow render 방식으로 테스트 방식을 바꾸지 않고, Full render 방식의 테스트 일관성을 유지할 수 있다.

3. 테스트용 간접 컴포넌트 생성 및 Mocking을 통한 해결

Editor.js 컴포넌트 대신에 단위 테스트용 간접 컴포넌트를 생성하여, Jest의 모듈 mocking 방식을 이용하여 Third-party 라이브러리의 내부 파악에 시간을 크게 들이지 않고, 쉽게 테스트 작성이 가능하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# NewArticle.test.js

jest.mock('@/components/editor/Editor', () => {
// eslint-disable-next-line react/display-name
return ({ html, onChange }) => {
const handleChange = (e) => {
const htmlContents = e.target?.value;
onChange({
htmlContents,
plainContents: NEW_PLAIN_CONTENTS,
});
};
return <input data-testid="editor" value={html} onChange={handleChange} />;
};
});


[참고] jest.mock 함수
jest.mock('모듈명', factory 함수)
- factory 함수 내부에서 테스트 목적의 함수형 컴포넌트를 리턴하게끔 한다.

NewArticle.js 페이지 내부에서 Editor 컴포넌트를 사용하는데 jest.mock 함수를 사용하여 Editor 컴포넌트 대신에 테스트용 간접 컴포넌트를 사용하게끔 mocking이 가능하다.

또한 간접 컴포넌트에서 data-testid=”editor” 값을 지정했기 때문에, screen.getByTestId(‘editor’)을 통해서 쉽게 가상 dom element를 접근이 가능하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# NewArticle.test.js

jest.mock('@/components/editor/Editor', () => {
// eslint-disable-next-line react/display-name
return ({ html, onChange }) => {
const handleChange = (e) => {
const htmlContents = e.target?.value;
onChange({
htmlContents,
plainContents: NEW_PLAIN_CONTENTS,
});
};
return <input data-testid="editor" value={html} onChange={handleChange} />;
};
});

describe('NewArticle', () => {
beforeEach(() => {
render(
<RecoilRoot>
<NewArticle />
</RecoilRoot>
);
});

describe('add newArticle', () => {
const updateContents = async () => {
const editor = screen.getByTestId('editor');
await fireEvent.change(editor, {
target: {
value: NEW_HTML_CONTENTS,
},
});
};

beforeEach(async () => {
bulletinBoardApi.addArticle.mockResolvedValue();
await updateContents();

const saveButton = screen.getByText('저장');
fireEvent.click(saveButton);
});

it('should add new article', async () => {
expect(bulletinBoardApi.addArticle).toBeCalledWith(NEW_TITLE, NEW_HTML_CONTENTS, NEW_PLAIN_CONTENTS);
});
});
});

마무리

다른 화면에서 Editor 컴포넌트를 사용하는 경우에는 테스트용 간접 컴포넌트를 선언하고 Mocking 하는 방식을 개발자들이 반복해야 할 수 있다.

이런 문제를 해결하기 위해서 1) 테스트용 컴포넌트를 별도로 관리하고 2) 모듈 mocking하는 선언을 setupTest.js으로 이동하여 테스트 전체에 일괄로 적용한다.

이렇게 필요할 때마다 테스트용 간접 컴포넌트를 만들고, 글로벌 선언하는 방식을 차곡차곡 반복하면서, 케이스들이 쌓일 수록 개발자들은 단위 테스트 작성하기가 쉽고, 유지보수 하기 쉬운 테스트를 작성할 수 있다.

참고

Share