React 테스트 코드
1. 개요
현대 웹 개발 환경은 매우 복잡해졌고, 개발자들은 애플리케이션의 품질을 보장하는데 많은 시간을 투자하고 있습니다. 특히, 프런트엔드 개발에서는 사용자 경험을 최적화하는 것이 중요한데, 이를 달성하기 위해선 코드의 품질과 안정성이 보장되어야 합니다.
React를 처음 설치하기 위해 create-react-app 명령어를 사용하면, 흥미롭게도 테스트 라이브러리인 jest와 React Testing Library(RTL)가 기본적으로 설치됩니다. 또한, 프로젝트 디렉토리에는 App.test.js라는 기본 테스트 파일이 생성됩니다.
이는 React 개발 팀 자체에서도 테스트의 중요성을 인지하고, 개발자들이 쉽게 테스트를 시작할 수 있도록 위함이라고 생각합니다.
이제 테스트는 프로젝트의 일부로 간주되며, 선택이 아닌 필수가 아닐까요?
이번글을 통해 테스트코드에 대한 내용과 실제 프로젝트에 적용한 간단한 사례를 정리하려고합니다.
2. 테스트 코드의 중요성
테스트 코드는 소프트웨어 품질을 보장하는 데 필수적입니다. 그 이유는 다음과 같습니다.
- 소프트웨어의 품질 및 유지보수성 향상
- 버그를 사전에 발견하고 코드 변경에 대한 부작용을 사전에 찾을 수 있음
- 실제 동작확인은 오래걸리지만 빠른 실행으로 결과 확인가능
- 내부 케이스가 변경되어도 테스트 로직의 수정으로 바로 테스트 가능
특정 페이지를 삭제하는 작업을 진행했었는데 의도치않게 관련된 페이지의 기능에 문제가 있다는 것을 “나중에” 알아차려서 급하게 수정했습니다
기능 추가&삭제 및 코드변경은 자주 일어나는데 관련된 모든 요소를 하나하나 다 확인하는데에는 사람이기때문에 한계가 있었죠…
즉 이러한 유지보수 측면에서도 기존에 테스트코드가 정의되어있었다면 문제점을 바로 확인할 수 있겠다고 생각했고 테스트 코드의 필요성을 다시 느낄 수 있었습니다!
- 협업에 용이
- 테스트 코드 자체가 하나의 문서와 의사소통도구로 활용될 수 있음
- 마치 하나의 명세서 역할을 하는 것
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
// 예시 컴포넌트
function capitalizeString(str) {
if (typeof str !== "string") {
throw new Error("입력값은 문자열이어야 합니다.");
}
return str.toUpperCase();
}
// 테스트 스위트
describe("capitalizeString 함수", () => {
// 테스트 케이스
it("문자열을 대문자로 변환해야 함", () => {
// 예상 결과
expect(capitalizeString("hello")).toBe("HELLO");
});
it("숫자가 입력되면 에러를 던져야 함", () => {
// 예상 결과: 에러 발생
expect(() => {
capitalizeString(123);
}).toThrow("입력값은 문자열이어야 합니다.");
});
});
// 테스트 코드를 딱 봤을때 "문자열을 대문자로 변환해야 함과 숫자가 입력되면 에러를 던져야 한다"는 의미가 드러남
3. 테스트 라이브러리
테스트를 작성하고 실행하는 데는 다양한 라이브러리와 도구가 사용됩니다.
이 섹션에서는 React 애플리케이션 테스팅에 널리 사용되는 라이브러리인 Jest, Mocha, 그리고 React Testing Library(RTL)에 대해 살펴보겠습니다.
3.1 Jest
React 애플리케이션의 테스트를 위한 가장 인기있는 테스트 프레임워크 Jest는 기본적으로 React 애플리케이션의 테스트를 위한 설정과 라이브러리를 제공
- 장점:
- 간편한 설정: Jest는 기본적으로 필요한 설정을 제공하여 빠르게 시작할 수 있습니다.
- 풍부한 기능: Jest는 테스트 실행, 모의 객체(Mock), 코드 커버리지 리포팅 등 다양한 기능을 내장하고 있어서 추가적인 설정이 필요하지 않습니다.
- 자동 모의화: Jest는 모의 객체(Mock) 생성을 자동화하여 외부 종속성을 가진 코드를 테스트하기 쉽게 만듭니다.
- Snapshot 테스팅: Jest는 컴포넌트의 스냅샷을 캡처하고 비교하여 UI 변화를 감지하는 Snapshot 테스트 기능을 제공합니다.
- 단점:
- 학습 곡선: Jest는 다른 테스트 프레임워크에 비해 몇 가지 고유한 개념과 기능을 가지고 있기 때문에 처음 사용자들은 학습 곡선을 겪을 수 있습니다.
- 확장성: Jest는 대규모 프로젝트에서 일부 기능에서 제한이 있을 수 있습니다.
- “Mocha에 비해 느리다”
3.2 Mocha
Node 테스트를 위해 나왔으며 백엔드 테스트에 용이함
- 장점:
- 유연한 설정: Mocha는 유연한 설정 옵션을 제공하여 다양한 테스트 환경에 적용할 수 있습니다. 사용자가 원하는 대로 테스트 스위트와 테스트 케이스를 구성할 수 있습니다.
- 다양한 어설션(assertion) 라이브러리: Mocha는 어설션 라이브러리에 종속되지 않으며, 다양한 어설션 라이브러리(예: Chai, Should.js, Assert)와 함께 사용할 수 있습니다.
- 확장성: Mocha는 다양한 플러그인과 연동하여 확장성을 높일 수 있습니다.
- 단점:
- 초기 설정: Mocha는 설정을 직접 구성해야 하기 때문에 초기 설정이 필요합니다. 이는 사용자에게 더 많은 설정 작업을 요구할 수 있습니다.
- 추가적인 라이브러리 필요: Mocha는 테스트 실행 및 모의(Mock) 기능을 내장하지 않기 때문에 추가적인 라이브러리(예: Chai, Sinon)를 함께 사용해야 할 수 있습니다.
3.3 RTL
Jest의 경우 자바스크립트 코드에 대한 테스트는 가능하지면 프론트에서는 결국 화면단에 표현되는 UI렌더링에 대한 테스트를 진행하기위해 별도의 라이브러리를 사용해야합니다.
React Testing Library(RTL)는 React 컴포넌트를 테스팅하는 데에 특화된 라이브러리입니다. 이 라이브러리는 사용자의 관점에서 컴포넌트를 테스트하는 것을 목표로 합니다.
즉, RTL은 컴포넌트의 내부 구현보다는 사용자 인터랙션과 렌더링 결과에 집중합니다.
3.4 Enzyme
Enzyme은 Airbnb에서 만들어진 React 컴포넌트 테스팅 라이브러리로, React 컴포넌트를 유연하게 테스트할 수 있습니다.
RTL과 다르게 실제 사용자가 경험하는 결과보다는 개발자 위주의 구현 테스트에 집중합니다.
3.5 기타
따라서 저는 사용자 입장의 결과 테스트와 간단한 유닛테스트를 주로 진행하기 위해 Jest와 RTL을 사용하였습니다.
추가로 개인적으로 궁금해서 찾아본 npm 다운로드 수 비교입니다. (2023 6/11기준)
-
Jest vs Mocha
-
react-testing-library vs enzyme
Enzyme은 2015 , RTL은 2018년에 출시되어 다운로드 수의 차이는 많지만 최근에는 React Testing Library가 최신 React 기능과 잘 통합되며, 간결하고 직관적인 API를 제공한다는 장점 때문에 인기가 상승하고 있다고 합니다.
4. 적용 예시
위 내용을 바탕으로 현재 프로젝트에서 사용중인 “로그인” 페이지에 테스트 코드를 적용한 사례를 정리하려고합니다.
크게 단위 테스트, 통합 테스트, 시스템 테스트중 가장 간단한 “단위 테스트”를 적용시켜보겠습니다.
단위 테스트 : 가장 작은범위의 테스트로 함수, 클래스, 컴포넌트 등을 테스트
통합 테스트 : 여러 컴포넌트 또는 시스템을 테스트 (연동 테스트)
시스템 테스트 : 전체 시스템을 테스트하는 것
4.1 테스트 사항 분석
사실 처음부터 테스트코드를 작성했다면 좋겠지만 이번 경우에는 이미 생성된 페이지에 대해 테스트 코드를 작성하려합니다.
대신 향후 기능추가나 리펙토링을 진행했을 때 이 로그인 페이지가 기존처럼 잘 돌아가는지는 확인할 수 있겠죠!
해당 페이지에서 발생할 수 있는 시나리오는 아래와 같습니다.
-
사용자는 각 필드값을 입력합니다.
-
만약 특정 필드가 입력되지 않았다면 “~~”를 입력해주세요 메세지를 출력합니다
-
만약 password와 Password Check가 일치하지 않다면 “재확인 비밀번호가 일치하지 않습니다.” 메세지를 출력합니다
-
폼을 다 입력하고 SIGN UP 버튼을 누르면 회원가입 api를 요청하고 성공하면 로그인 페이지로 이동합니다
-
이미 사용중인 이메일이라는 에러메세지가 오면 “이미 사용중인 이메일 입니다” 메세지를 띄웁니다
-
이미 사용중인 닉네임이라는 에러메세지가 오면 “이미 사용중인 닉네임 입니다” 메세지를 띄웁니다.
테스트의 경우 사용자 입장에서 마주할 수 있는 각 요소와 시나리오를 하나하나 테스트항목으로 분리하고 상황을 가정해서 테스트를 진행했습니다.
4.2 테스트 코드 작성
우선 해당 페이지 하위에 테스트 파일을 생성합니다.
별도의 테스트 폴더를 만들고 관리하는 방법도 있겠지만 여기서는 각 페이지에 대한 단위테스트를 진행할 것이며 많은 파일이 존재하지 않기때문에 해당 페이지 하위에 바로 생성했습니다.
1
2
3
4
│ ├── SignUpPage
│ │ ├── index.test.tsx
│ │ └── index.tsx
- useNavigate mock함수 생성
1
2
3
4
5
6
const navigateMock = jest.fn();
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useNavigate: () => navigateMock,
}));
회원가입의 경우 성공적으로 처리되었을 때 로그인 페이지로 이동하는 useNavigate()가 호출되지만
테스트 환경에서는 실제 페이지 이동이 발생하지 않도록 하기 위해
실제 코드내에서 react-router-dom
부분을 모킹해서 useNavigate함수를 사용하면 실제 페이지 이동이 아니라 만든 가짜함수가 실행되도록 합니다.
- api Mock
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { setupServer } from 'msw/node';
const server = setupServer(
rest.post('http://test/api/v1/user/signup', (req, res, ctx) => {
const { email, nickname } = req.body as {
email: string;
nickname: string;
};
if (nickname === 'duplicateNickname') {
return res(ctx.status(400), ctx.json({ errorCode: 'U003' }));
}
if (email === 'duplicateEmail@test.com') {
return res(ctx.status(400), ctx.json({ errorCode: 'U004' }));
}
return res(ctx.status(200), ctx.json({ message: '회원가입 성공' }));
}),
);
가상의 서버를 설정하는 부분입니다. api또한 실제로 호출하게된다면 네트워크환경에 따라 일관적인 테스트 결과가 나올 수 없으므로 별도의 가상 서버를 설정합니다.
즉 http://test/api/v1/user/signup
경로로 post 요청이 오면 다음과 같이 응답값을 반환해라 라는 의미를 수행하는거죠
이때는 각 케이스에따라 의도적으로 에러케이스에 해당하는 응답값을 처리했기때문에 if문을 사용해서 각각 다른 응답을 정의합니다.
(저는 닉네임 중복, 이메일 중복에 대한 테스트를 진행하기 위해 특정 input을 입력하면 해당 에러를 반환하도록 설계했어요)
또한 테스트 코드의 api요청에 대한 url을 임의로 test로 설정하기 위해 ` “test”: “VITE_API_URL=http://test/ jest”` 부분을 package.json파일의 script에 추가시켰습니다.
jest.mock자체로도 특정 응답값을 모킹할 수 있지만 더 직관적인 처리를 위해 msw라이브러리를 사용했습니다.
- 서버 제어 설정
1
2
3
beforeAll(() => server.listen()); // 서버가 HTTP 요청을 수신 대기(listen) 상태로 전환하도록 합니다.
afterEach(() => server.resetHandlers()); // 가상 서버의 핸들러를 초기화합니다.
afterAll(() => server.close()); // 가상 서버를 종료합니다.
beforeAll
- jest테스트 프레임워크의 함수로 모든 테스트 케이스가 실행전 “한번” 호출됩니다
afterEach
- 테스트 케이스가 실행된 후마다 호출됩니다
afterAll
- 모든 테스트 케이스가 끝나면 호출됩니다
- 컴포넌트에 대한 테스트 그룹 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
describe('로그인 페이지 테스트', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
beforeEach(() => {
render(
<BrowserRouter>
<RecoilRoot>
<QueryClientProvider client={queryClient}>
<SignUpPage />
</QueryClientProvider>
</RecoilRoot>
</BrowserRouter>,
);
});
여기서는 회원가입 페이지에 대한 컴포넌트에 대해 테스트 그룹을 생성합니다.
render(...)
함수를 사용하여 SignUpPage 컴포넌트를 렌더링하는데 이 렌더링은 테스트 동안 가상의 DOM에 발생하며, 실제 브라우저에는 표시되지 않습니다
이때 SignUpPage에서 Recoil과 Reactquery를 사용하고 있었기 때문에 해당 컴포넌트를 QueryClientProvider, RecoilRoot 및 BrowserRouter 로 감싸서 렌더링합니다.
5. 각 테스트 케이스 정의
1
2
3
4
5
6
7
test('컴포넌트가 처음 렌더링될 때 각 필드가 비어있는지 확인', () => {
expect(screen.getByPlaceholderText('Email')).toHaveValue('');
expect(screen.getByPlaceholderText('Nickname')).toHaveValue('');
expect(screen.getByPlaceholderText('Password')).toHaveValue('');
expect(screen.getByPlaceholderText('Password Check')).toHaveValue('');
});
test
함수는 Jest에서 제공하는 함수로, 테스트 케이스를 정의합니다expect
함수는 Jest에서 제공하는 함수로, 특정 값에 대한 단언(assertion)을 수행합니다screen.getByPlaceholderText('Email)
는 가상 DOM에서 placeholder 텍스트가 ‘Email’인 요소를 찾습니다.toHaveValue('')
는 Jest와 React Testing Library에서 제공하는 매처(matcher)로, 해당 요소의 값이 빈 문자열(‘‘)인지 확인합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
test("회원가입 정상 처리에 대한 페이지 이동 확인", async () => {
fireEvent.change(screen.getByPlaceholderText("Nickname"), {
target: { value: "testNickname" },
});
fireEvent.change(screen.getByPlaceholderText("Email"), {
target: { value: "test@test.com" },
});
fireEvent.change(screen.getByPlaceholderText("Password"), {
target: { value: "testPassword" },
});
fireEvent.change(screen.getByPlaceholderText("Password Check"), {
target: { value: "testPassword" },
});
fireEvent.click(screen.getByText("SIGN UP"));
await waitFor(() => {
expect(navigateMock).toHaveBeenCalledWith("/signin");
});
});
fireEvent는
React Testing Library에서 제공하는 함수로, DOM 이벤트를 시뮬레이션하는데 사용됩니다. 여기서는 ‘change’ 이벤트를 발생시키고 있습니다.{ target: { value: 'test@test.com' } }
는 발생시키는 이벤트에 전달할 데이터입니다. 여기서는 ‘Email’ 입력 필드에 ‘test@test.com’ 값을 설정합니다.await waitFor
부분은 비동기 코드를 테스트하기 위해 사용하는데 위 코드에서는 signin인자와 함께 라우터가 호출될때까지 기다립니다.
- 전체 코드
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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
import { render, fireEvent, screen, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import SignUpPage from '.';
import { QueryClient, QueryClientProvider } from 'react-query';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { RecoilRoot } from 'recoil';
const navigateMock = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => navigateMock,
}));
const server = setupServer(
rest.post('http://test/api/v1/user/signup', (req, res, ctx) => {
const { email, nickname } = req.body as {
email: string;
nickname: string;
};
if (nickname === 'duplicateNickname') {
return res(ctx.status(400), ctx.json({ errorCode: 'U003' }));
}
if (email === 'duplicateEmail@test.com') {
return res(ctx.status(400), ctx.json({ errorCode: 'U004' }));
}
return res(ctx.status(200), ctx.json({ message: '회원가입 성공' }));
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('<SignUpPage />', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
beforeEach(() => {
render(
<BrowserRouter>
<RecoilRoot>
<QueryClientProvider client={queryClient}>
<SignUpPage />
</QueryClientProvider>
</RecoilRoot>
</BrowserRouter>,
);
});
test('컴포넌트가 처음 렌더링될 때 각 필드가 비어있는지 확인', () => {
expect(screen.getByPlaceholderText('Email')).toHaveValue('');
expect(screen.getByPlaceholderText('Nickname')).toHaveValue('');
expect(screen.getByPlaceholderText('Password')).toHaveValue('');
expect(screen.getByPlaceholderText('Password Check')).toHaveValue('');
});
test('각 필드에 입력값을 제공하면 상태가 제대로 변경되는지 확인', () => {
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'test@test.com' },
});
expect(screen.getByPlaceholderText('Email')).toHaveValue('test@test.com');
fireEvent.change(screen.getByPlaceholderText('Nickname'), {
target: { value: 'testNickname' },
});
expect(screen.getByPlaceholderText('Nickname')).toHaveValue('testNickname');
fireEvent.change(screen.getByPlaceholderText('Password'), {
target: { value: 'testPassword' },
});
expect(screen.getByPlaceholderText('Password')).toHaveValue('testPassword');
fireEvent.change(screen.getByPlaceholderText('Password Check'), {
target: { value: 'testPassword' },
});
expect(screen.getByPlaceholderText('Password Check')).toHaveValue(
'testPassword',
);
});
test('각 필드가 비어 있거나 비밀번호가 일치하지 않는 경우 에러 메시지가 표시되는지 확인', async () => {
fireEvent.click(screen.getByText('SIGN UP'));
expect(screen.getByText('이메일을 입력해 주세요')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'test@test.com' },
});
fireEvent.click(screen.getByText('SIGN UP'));
expect(screen.getByText('닉네임을 입력해 주세요')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Nickname'), {
target: { value: 'testNickname' },
});
fireEvent.click(screen.getByText('SIGN UP'));
expect(screen.getByText('비밀번호를 입력해 주세요')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Password'), {
target: { value: 'testPassword' },
});
fireEvent.click(screen.getByText('SIGN UP'));
expect(
screen.getByText('비밀번호 확인을 입력해 주세요'),
).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Password Check'), {
target: { value: 'wrongPassword' },
});
fireEvent.click(screen.getByText('SIGN UP'));
expect(
screen.getByText('재확인 비밀번호가 일치하지 않습니다.'),
).toBeInTheDocument();
});
test('중복된 닉네임에 대한 에러 메세지 출력 확인', async () => {
fireEvent.change(screen.getByPlaceholderText('Nickname'), {
target: { value: 'duplicateNickname' },
});
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'test@test.com' },
});
fireEvent.change(screen.getByPlaceholderText('Password'), {
target: { value: 'testPassword' },
});
fireEvent.change(screen.getByPlaceholderText('Password Check'), {
target: { value: 'testPassword' },
});
fireEvent.click(screen.getByText('SIGN UP'));
await waitFor(() => {
expect(
screen.getByText('이미 사용중인 닉네임 입니다'),
).toBeInTheDocument();
});
});
test('중복된 이메일에 대한 에러 메세지 출력 확인', async () => {
fireEvent.change(screen.getByPlaceholderText('Nickname'), {
target: { value: 'testNickname' },
});
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'duplicateEmail@test.com' },
});
fireEvent.change(screen.getByPlaceholderText('Password'), {
target: { value: 'testPassword' },
});
fireEvent.change(screen.getByPlaceholderText('Password Check'), {
target: { value: 'testPassword' },
});
fireEvent.click(screen.getByText('SIGN UP'));
await waitFor(() => {
expect(
screen.getByText('이미 사용중인 이메일 입니다'),
).toBeInTheDocument();
});
});
test('회원가입 정상 처리에 대한 페이지 이동 확인', async () => {
fireEvent.change(screen.getByPlaceholderText('Nickname'), {
target: { value: 'testNickname' },
});
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'test@test.com' },
});
fireEvent.change(screen.getByPlaceholderText('Password'), {
target: { value: 'testPassword' },
});
fireEvent.change(screen.getByPlaceholderText('Password Check'), {
target: { value: 'testPassword' },
});
fireEvent.click(screen.getByText('SIGN UP'));
await waitFor(() => {
expect(navigateMock).toHaveBeenCalledWith('/signin');
});
});
});
결과
yarn test명령어를 수행했을때 정상적으로 각 항목에 대한 테스트가 수행되었습니다!
코드 전체의 신뢰성이 높아지고 해당 테스트코드를 보면 누구나 어떤 로직을 수행하는지 쉽게 알 수 있겠죠
위 예시는 간단하지만 복잡한 컴포넌트나 페이지의 경우 유지보수나 코드 수정에 대해 문제점이 있는지 바로바로 확인이 가능하다는 점에서 조금 귀찮더라도 이런 테스트코드 작성을 습관화 하는게 좋을 것 같습니다.
참고자료
https://velog.io/@binimini/Mocha-vs-Jest-JS-Test-Framework https://techblog.woowahan.com/8942/ https://yong-nyong.tistory.com/45
댓글남기기