같은글 노션링크 | 코드 변경 전후를 명확하게 보실수있습니다.
1.1 테스트의 필요성
1.2 프론트엔드 테스트
1.3 도입 목표
2.1 어떤 도구로 테스트 할까?
2.2 테스트 케이스 생각해보기
2.2.1 발생가능한 시나리오
2.2.2 작성할 테스트 케이스
3.1 테스트 환경 설정하기
3.2 테스트 케이스 1,2,3번 테스트 코드 작성하기
3.3 테스트 케이스 4,5,6번 테스트 코드 작성하기
테스트는 애플리케이션이 기대한 결과대로 동작하는지 검증하는 일련의 모든 행위를 말한다.
보통 개발은 요구사항 분석- 설계 - 개발 - 배포 순으로 이뤄진다. 완벽한 초기설계는 없으므로, 이후 발생한 버그나 추가 요구사항으로 인해 재설계 및 소스코드의 수정이 이뤄짐. 이 과정에서 불필요한 코드가 추가될 가능성이 높고, 사소한 코드 수정후에도 앱의 모든 부분에 대해 테스트가 진행돼야 하는데, 테스트 코드가 없다면 버그의 원인을 찾기가 더 어려워진다. 서비스 규모가 커질수록 테스트 코드의 필요성은 더욱 높아진다. 따라서, 테스트는 앱의 안정성을 유지할 수 있고, 추가 구현한 기능에서 버그를 빠르게 파악할 수 있게 돕는다.
프론트엔드 테스트는 클라이언트 영역에 대한 사용자의 인터랙션(마우스 클릭, 키보드 입력 등)도 고려하여 올바른 결과가 나오는지 검증하는 것이 중요하다. 뿐만아니라 DOM 요소들이 원하는 위치에 지정한 크기나 여백을 가지고 적절하게 배치되는지 시각적인 부분도 검증해야한다. 그러나 아직은 사용자의 동작을 완벽하게 재현하여 테스트 할 도구가 없어 어떤 테스트가 적합할지 전략적으로 접근해야한다. 모든 상태나 동작을 단위 테스트로 검증할지, 통합테스트를 통해 여러 요소(모듈)의 상호작용이 제대로 이뤄지는지 검증할지 등 어떤 테스트방식을 선택할지 고민해야한다.
프론트엔드 테스트는 크게 단위테스트, 통합테스트, E2E 테스트 그리고 시각적 회귀 테스트로 나눌수있다.
테스트를 작성하는 것은 결국 Trade Off 이다. 각각의 테스트에 들어가는 비용이 다르기 때문에 이를 작성해서 얻을 수 있는 효과와 신뢰성등을 따져보고 어떤 테스트를 진행할지 균형을 찾아야한다.
본 프로젝트는 앱이 유저와 인터렉션을 잘하는지 테스트하기 위해, 모듈간 상호작용을 테스트 해 볼 수 있는 통합테스트를 진행해보고자 한다.
Jest와 React Testing library, API 모킹은 msw 를 이용하려고 한다.
Jest는 Facebook에서 만든 All-in-one 테스팅 프레임 워크로 검증을 실행하는 매처와 모킹에 필요한 API를 제공하고있다. (Test Runner, Test Mathcher, Test Mock를 모두 제공해줘서 편리하다.) 다만, node.js 환경에서 테스트를 실행하기 때문에 DOM 에 접근하거나 조작하는 것이 불가능한데, 이를 가능케 하기 위해 jsdom이라는 환경을 제공한다. jsdom 환경을 사용해서 jest에서도 브라우저처럼 모든 DOM API를 호출해 검증할 수 있다.
React Testing library은 리액트 컴포넌트의 렌더링이 잘되는지 테스트할 수있는 도구다. 주로 어떠한 결과가 화면상에 잘 나타났는지, 그리고 어떠한 이벤트 혹은 함수가 호출 됐을 때 원하는 업데이트가 잘 반영이 되는지를 확인한다.
MSW는 Service Worker API를 이용해 API를 모킹하는 라이브러리로 네트워크 요청을 가로채서 모의 응답을 응답해준다. 실제 API 응답시 발생할 수 있는 상황을 네트워크 수준에서 모킹할 수 있다. (로딩, 에러, 정상응답)
최초 진입시, 화면에 모달배경에 start 버튼이 정중앙에 뜬다. 슬롯 버튼이 눌러지면 안된다
→ 유저 동작 없으므로 테스트 ❌
start 버튼을 클릭하면 슬롯이 돌아간다 ⇒ 테스트 케이스 ✅
슬롯 버튼을 누르면 슬롯이 멈춘다.
→ 이걸 테스트하기엔 지엽적임. 테스트 ❌
슬롯 버튼을 전부 누르면 로딩창이 뜨고(API 요청중), 요청 결과 창이 뜬다. ⇒ 테스트 케이스 ✅
결과 창의 처음으로 버튼을 클릭하면 start 버튼이 뜬다. ⇒ 테스트 케이스 ✅
404 에러가 발생하면 404 페이지가 나타난다. ⇒ 테스트 케이스 ✅
500 에러가 발생하면 에러 모달이 나타난다. ⇒ 테스트 케이스 ✅
네트워크 에러가 발생하면 에러 모달이 나타난다. ⇒ 테스트 케이스 ✅
"START" 버튼을 누르면 "슬롯" 버튼이 활성화 된다’
"슬롯" 버튼을 전부 누르면 선택된 값이 서버로 전송된다
⇒ 모킹응답의 영화제목 :비와 당신의 이야기
“처음으로” 버튼을 누르면 “START” 버튼이 나타난다.
404 에러가 발생하면 404 페이지가 나타난다.
⇒ 메시지: 존재하지 않는 페이지에요
500 에러가 발생하면 에러 모달이 나타난다.
⇒ 메시지: 영화데이터 서버에 문제가 생겼어요! 게임을 다시 해보시고, 그래도 안된다면 잠시후 시도해주세요
네트워크 에러가 발생하면 에러 모달이 나타난다.
⇒ 메시지: /네트워크 연결이 약한것같아요. 와이파이 연결을 확인해주시고, 재시도 해주세요!/
jest는 node에서만 돌아가므로 DOM을 조작할수없다. jestdom 함수를 사용하면 DOM을 조작할 수 있으므로 jest-dom 사용을 파일상단에 명시해야한다.
// src/test/App.test.tsx
*import* '@testing-library/jest-dom';
msw는 브라우저와 노드 모두에서 돌아가게 설정할 수 있다. 요청 핸들러를 작성해서, 요청url과 요청메소드에 따라 매칭된 모킹된 응답이 반환되도록 했다.
// src/mock/handlers.ts
import { rest } from 'msw';
const MOCK_MOVIE_DATA = {
boxOfficeResult: {
boxofficeType: '[mocked] 주말 박스오피스',
showRange: '20210514~20210516',
yearWeekTime: '202119',
weeklyBoxOfficeList: [
{
rnum: '1',
rank: '1',
rankInten: '0',
rankOldAndNew: 'OLD',
movieCd: '20193068',
movieNm: '[mocked] 비와 당신의 이야기',
openDt: '2021-04-28',
salesAmt: '272877330',
salesShare: '54.4',
salesInten: '-164072410',
salesChange: '-37.5',
salesAcc: '3203457430',
audiCnt: '27778',
audiInten: '-17547',
audiChange: '-38.7',
audiAcc: '346994',
scrnCnt: '616',
showCnt: '3889',
},
],
},
};
export const getMockMovieData: Parameters<typeof rest.get>[1] = (
_,
res,
ctx,
) => {
return res(ctx.status(200, 'ok'), ctx.json(MOCK_MOVIE_DATA));
};
export const get404Error: Parameters<typeof rest.get>[1] = (_, res, ctx) => {
return res(ctx.status(404));
};
export const get500Error: Parameters<typeof rest.get>[1] = (_, res, ctx) => {
return res(ctx.status(500));
};
export const getNetworkError: Parameters<typeof rest.get>[1] = (_, res) => {
return res.networkError('network');
};
export const handlers = [
rest.get('/test', getMockMovieData),
rest.get('/notFoundError', get404Error),
rest.get('/serverError', get500Error),
rest.get('/networkError', getNetworkError),
];
테스트 케이스 1,2,3번 테스트 코드 작성하기"START" 버튼을 누르면 "슬롯" 버튼이 활성화 된다’
test('"S T A R T" 버튼을 누르면 "슬롯" 버튼이 활성화 된다', async () => {
render(
<Router>
<App />
</Router>,
);
// 앱 렌더시 최초에 start 버튼 존재 여부 확인
await waitFor(() => {
expect(screen.queryByText(/S T A R T/)).toBeInTheDocument();
});
// start 버튼 클릭
user.click(await screen.findByText(/S T A R T/));
// start 버튼 사라졌는지 여부 확인
await waitFor(() => {
expect(screen.queryByText(/S T A R T/)).not.toBeInTheDocument();
});
const slotButton1 = screen.queryByRole('button', {name: 'country',});
const slotButton2 = screen.queryByRole('button', {name: 'type',});
const slotButton3 = screen.queryByRole('button', {name: 'year',});
// 슬롯 버튼이 활성화(누를 수 있는 상태) 상태인지 확인
expect(slotButton1).toBeEnabled();
expect(slotButton2).toBeEnabled();
expect(slotButton3).toBeEnabled();
});
"슬롯" 버튼을 전부 누르면 선택된 값이 서버로 전송된다
⇒ 모킹응답의 영화제목 :비와 당신의 이야기
test('"슬롯" 버튼을 전부 누르면 선택된 값을 서버로 전송하고, 응답결과를 받는다.', async () => {
server.use(rest.get('/test', getMockMovieData));
render(
<Router>
<App />
</Router>,
);
await waitFor(() => {
expect(screen.queryByText(/S T A R T/)).toBeInTheDocument();
});
user.click(await screen.findByText(/S T A R T/));
// 각 슬롯 버튼 클릭
user.click(await screen.findByRole('button', { name: 'country' }));
user.click(await screen.findByRole('button', { name: 'type' }));
user.click(await screen.findByRole('button', { name: 'year' }));
// 모의응답결과가 올바르게 렌더링 되는지 확인
await waitFor(() => {
expect(screen.queryByText(/비와 당신의 이야기/)).toBeInTheDocument();
});
});
“처음으로” 버튼을 누르면 “START” 버튼이 나타난다.
test('“처음으로” 버튼을 누르면 “START” 버튼이 나타난다.', async () => {
render(
<Router>
<App />
</Router>,
);
await waitFor(() => {
expect(screen.queryByText(/S T A R T/)).toBeInTheDocument();
});
user.click(screen.getByText(/S T A R T/));
// 각 슬롯 버튼 클릭
user.click(await screen.findByRole('button', { name: 'country' }));
user.click(await screen.findByRole('button', { name: 'type' }));
user.click(await screen.findByRole('button', { name: 'year' }));
// 뽑기결과창 모달이 뜰때까지 10초 기다림
await waitFor(
() => {
expect(screen.queryByText(/뽑기결과/)).toBeInTheDocument();
},
{ timeout: 10000 },
);
// 다시뽑기 버튼 클릭시, START 버튼이 렌더링 되는지 확인
user.click(await screen.findByText(/다시뽑기/));
await waitFor(() => {
expect(screen.queryByText(/S T A R T/)).toBeInTheDocument();
});
});
테스트 케이스 4,5,6번 테스트 코드 작성하기3.2에서 작성했던 테스트 코드와 다르게 render 함수에 Router, ModalProvider, Suspense, ErrorBoundary를 인수로 전달했다. App만 렌더하면, 에러가 발생해도 에러 Fallback 컴포넌트나, 에러모달이 동작하지 않기 때문이다.
404 에러가 발생하면 404 페이지가 나타난다.
test('404 에러가 발생하면 404 페이지가 나타난다.', async () => {
render(
<Router>
<ModalProvider>
<Suspense fallback={<Loading whiteBoard={false} />}>
<ErrorBoundary FallbackComponent={RootErrorFallback}>
<App />
</ErrorBoundary>
</Suspense>
</ModalProvider>
</Router>,
);
// yarn test 환경에서 useMovieData.ts 의 영화데이터 API 요청 로직이
// '/test'로 요청하도록 분기처리했다.
// 영화데이터 API 요청로직이 동작해 '/test'로 요청하면 본 테스트 코드에서는
// 모의응답은 매칭된 핸들러 get404Error로 인해 항상 상태코드 404를 반환한다.
server.use(rest.get('/test', get404Error));
user.click(await screen.findByText(/S T A R T/));
user.click(await screen.findByRole('button', { name: 'country' }));
user.click(await screen.findByRole('button', { name: 'type' }));
user.click(await screen.findByRole('button', { name: 'year' }));
// 404 페이지에 해당 에러메시지가 존재하는지 확인한다.
await waitFor(() => {
expect(
screen.queryByText(/존재하지 않는 페이지에요/),
).toBeInTheDocument();
});
});
500 에러가 발생하면 에러 모달이 나타난다.
test('500 에러가 발생하면 에러 모달이 나타난다.', async () => {
// CreatePortal 사용시 Jest가 모달을 인식못함. 분기 후 테스트 중
render(
<Router>
<ModalProvider>
<Suspense fallback={<Loading whiteBoard={false} />}>
<ErrorBoundary FallbackComponent={RootErrorFallback}>
<App />
</ErrorBoundary>
</Suspense>
</ModalProvider>
</Router>,
);
// 상태코드 500 리턴
server.use(rest.get('/test', get500Error));
user.click(await screen.findByText(/S T A R T/));
user.click(await screen.findByRole('button', { name: 'country' }));
user.click(await screen.findByRole('button', { name: 'type' }));
user.click(await screen.findByRole('button', { name: 'year' }));
// 에러 모달창이 렌더되어 적절한 에러메시지가 존재하는지 확인
await waitFor(() => {
expect(
screen.queryByText(
/영화데이터 서버에 문제가 생겼어요! 게임을 다시 해보시고, 그래도 안된다면 잠시후 시도해주세요/,
),
).toBeInTheDocument();
});
});
네트워크 에러가 발생하면 에러 모달이 나타난다.
test('네트워크 에러가 발생하면 에러 모달이 나타난다.', async () => {
// CreatePortal 사용시 Jest가 모달을 인식못함. 분기 후 테스트 중
render(
<Router>
<ModalProvider>
<Suspense fallback={<Loading whiteBoard={false} />}>
<ErrorBoundary FallbackComponent={RootErrorFallback}>
<App />
</ErrorBoundary>
</Suspense>
</ModalProvider>
</Router>,
);
server.use(rest.get('/test', getNetworkError));
user.click(await screen.findByText(/S T A R T/));
user.click(await screen.findByRole('button', { name: 'country' }));
user.click(await screen.findByRole('button', { name: 'type' }));
user.click(await screen.findByRole('button', { name: 'year' }));
await waitFor(() => {
expect(
screen.queryByText(
/네트워크 연결이 약한것같아요. 와이파이 연결을 확인해주시고, 재시도 해주세요!/,
),
).toBeInTheDocument();
});
});
⚠️주의
ReactDom.createPortal 로 모달을 렌더링하면, Jest가 해당 모달을 인식하지 못한다.
Jest는 <div id=”root”></div> 하위 컴포넌트만 인식하는 듯 하다.
테스트를 돌리면 터미널에 아래와 같이 뜬다.
Ignored nodes: comments, script, style
<html>
<div />
</html>
이 결과를 통해 ‘아 모달컴포넌트는 <div id=”root”></div>가 아니라 <div id="modal"></div>로 렌더링하고 있지!’ 하며 힌트를 얻었던 것 같다.
createPortal 사용을 해제하면 jest가 모달을 인식하기에 yarn test 환경에서는 createPortal 사용하지 않는 방향으로 코드를 수정했다.
테스트케이스 1-3번
테스트케이스 4-6번
테스트코드를 짜는 기본지식을 학습해서 뿌듯했다.
참고자료
RTL
https://testing-library.com/docs/user-event/intro
msw
https://han-py.tistory.com/400
https://velog.io/@zzi99/MSWMock-Service-Worker-적용기
https://blog.mathpresso.com/msw로-api-모킹하기-2d8a803c3d5c
https://tech.kakao.com/2021/09/29/mocking-fe/
https://mswjs.io/docs/
Integration 테스트 코드가 중요한 이유 (React Testing Library, MSW로 작성해보기)
kakao Ent | MSW를 활용하는 Front-End 통합테스트
mock error scenarios with MSW
Jest Preview | React Testing Library - How to see current state of the DOM when testing
Jest Preveiw 공식문서
velog | MSW로 모킹 테스트 with testing-library
MSW를 활용해 프론트엔드 테스트하기 (feat. Jest)
E2E 테스트 도입 경험기
#궁금증
axios를 jest.fn 으로도 모킹할 수 있고, msw 로도 모킹할 수 있다면 둘의 차이점은 뭐고, 뭘 선택해야하는 걸까?
msw를 사용하고, 반드시 그럴 필요가 없다면 Jest.fn 를 사용해라.msw는 실제 네트워크 동작을 시뮬레이션하는 방식으로 앱이 네트워크 요청에 어떻게 응답해 작동하는지 테스트하려고 할때 쓴다. 통합 및 E2E테스트에 특히 적합하다.jest.fn은 함수 모킹을 주로하는데 함수의 호출, 호출 추적, 함수의 동작을 감시하는데 사용된다. 특정 기능이나 모듈을 격리해 테스트하는 단위테스트에 적합하다.@testing-library/user-event 와 fireEvent(@testing-library/react) 의 차이는 뭘까?
fireEvent는 @testing-library/user-event만큼 밀접하게 사용자 행동을 시뮬레이션하기보다 특정 이벤트에 대한 세밀하게, 구체적으로 제어 한다userEvent.type(element)이라는 메서드를 제공한다.queryBy() 와 getBy() 차이점
getBy()즉시 DOM에서 요소를 찾으려고 시도합니다. 요소가 발견되면 해당 요소를 반환합니다. 그렇지 않으면 오류가 발생하여 테스트가 실패하게 됩니다
const element = getByText("Hello, World!");
queryBy()queryBy 함수를 사용하면 DOM에서 요소를 찾으려고 시도하지만 getBy와 달리 요소를 찾을 수 없어도 오류가 발생하지 않습니다. 대신 null을 반환
용례: 요소가 컴포넌트에 존재할 수도 있고 존재하지 않을 수도 있는 경우. 테스트에서 두 경우를 모두 적절하게 처리하려는 경우
const element = queryByText("Hello, World!");
if (element) {
// Handle the case when the element is found
} else {
// Handle the case when the element is not found
}
findBy()queryBy() 와 findBy() 차이점
두 함수 엘리먼트가 나타나거나 변경될때까지 기다리는 것 같은 비동기 작업에 사용된다. 주요 차이점은 비동기 동작과 반환 값 처리 방법이다.
발견된 요소 또는 null 둘 중 하나의 결과를 즉시 반환한다.const element = screen.queryByTestId('my-element');
expect(element).toBeInTheDocument(); // This assertion works as expectedconst elementPromise = screen.findByTestId('my-element');
await expect(elementPromise).resolves.toBeInTheDocument(); // This assertion waits for the element to appearmsw 를 테스트에 사용하면 단위 테스트인가?
단위테스트는 개별 단위 또는 기능, 모듈 같은 작은 코드 조각을 격리해 테스트하는데 중점을 둔다. 따라서, 모킹함수를 별도로 만드는 등 테스트 코드를 외부 종속성과 격리시킨다.
⇒ msw는 api 상호작용 에 중점을 두기때문에 단위테스트에 사용되지 않는다.
통합테스트는 앱의 일정 부분, 여러 컴포넌트가 함께 올바르게 동작하는지 확인한다. 따라서, 이 테스트에는 여러 코드 단위 간의 상호작용 테스트가 포함된다.
⇒ API 응답을 모의하고 애플리케이션이 모의 API와 올바르게 상호 작용하는지 확인하려는 경우 통합테스트에 msw가 사용될 수 있다.
E2E테스트는 처음부터 끝까지 앱과 유저의 상호작용을 시뮬레이션한다. 보통 Cypress, Selenium과 같은 도구를 사용해 테스트한다.
⇒ msw는 전체 스택을 포괄하지 않으므로 E2E테스트에 사용되지 않는다.