지난 글에서는 프론트엔드에도 테스트가 필요한 이유와 테스트의 종류 그리고 테스팅 방법에 대해서 알아보았습니다.
아직 읽어 보지 못하신 분은 여기'를 참고해 주시길 바랍니다.
이번 글에서는 React-Testing-Library를 이용하여 소프트웨어 요구사항을 테스트 코드로 바꾸고 테스트하는 작업을 진행 해 보겠습니다.
React Testing Library
는 유닛테스트 및 통합테스트 시 사용하는 테스트 도구로, 모든 환경 구성을 하고 사용자 관점에서 테스트하는 e2e 테스팅 툴과 달리 코드 레벨에서 특정한 모듈을 테스트하기 위해 사용하는 도구입니다.
우리가 React-Testing-Library로 할 수 있는 테스팅 전략은 크게 두가지가 있는데
1번 케이스의 경우 모든 모듈을 독립적으로 테스팅 할 수 있다는 장점이 있으나, 소프트웨어 구조 변경에 따라 너무 많이 테스트를 바꿔야하는 점과, mock을 과도하게 사용해야하는 단점이 있습니다.
1번을 기준으로 어느정도 독립성을 유지하되 적절하게 2번을 섞어가면서 개발하는 것이 좋습니다.
// React-Testing-Library
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
import Fetch from './fetch'
test('loads and displays greeting', async () => {
// GIVEN
render(<Fetch url="/greeting" />)
// WHEN
await userEvent.click(screen.getByText('Load Greeting'))
await screen.findByRole('heading')
// THEN
expect(screen.getByRole('heading')).toHaveTextContent('hello there')
expect(screen.getByRole('button')).toBeDisabled()
})
render
는 React Component를 html로 랜더링하는 메서드입니다. 테스트를 하고자하는 컴포넌트를 render 메서드에 집어넣습니다.
screen
은 render를 통해 랜더링한 html에 대한 정보를 가지고 있는 객체입니다. getByRole
, getByText
, getByTestId
등을 통해 html컴포넌트를 가져올 수 있습니다.
userEvent
는 사용자 행동을 js로 실행할 수 있는 메서드를 제공하는 객체입니다.
위의 예제에서는 await userEvent.click(screen.getByText('Load Greeting'))
처럼 사용하였는데 screen에서 Load Greeting
이라는 텍스트를 가지고 있는 component를 클릭하는 행동을 합니다.
expect
는 컴포넌트를 검증하는데 사용합니다.
expect(screen.getByRole('heading')).toHaveTextContent('hello there')
의 경우 <heading>
에 hello there
이라는 텍스트를 가지고 있는지 확인하고 없다면 error를 만들어 냅니다.
특정 컴포넌트를 테스팅할때 테스팅 범위가 아니지만 의존성이 있는 다른 컴포넌트가 있을 수 있습니다. 이럴 경우 다른 컴포넌트의 행동에 따라 테스팅하려는 컴포넌트의 결과가 바뀔 수 있기 때문에 독립적인 테스트를 하기 어렵습니다. 특히 백엔드나 다른 라이브러리(OAuth), 혹은 아직 개발하지 않은 컴포넌트에 의존할 경우 테스팅이나 개발이 어렵죠. 이럴때 사용하는 것이 Mock
입니다.
jest.spyOn(window, "fetch").mockImplementation(() => {
return Promise.resolve({
json: () => Promise.resolve([
{ id: 1, title: "Blog 1", author: "Author 1" },
{ id: 2, title: "Blog 2", author: "Author 2" },
{ id: 3, title: "Blog 3", author: "Author 3" },
])
})
});
위의 예제는 fetch를 mocking한 예제입니다. fetch를 호출하고 json 메서드를 호출하면 실제 서버를 호출하는 것이 아닌
[
{ id: 1, title: "Blog 1", author: "Author 1" },
{ id: 2, title: "Blog 2", author: "Author 2" },
{ id: 3, title: "Blog 3", author: "Author 3" },
]
를 응답하게 변경하였습니다.
위의 방식으로 API를 Mocking할 수있지만 이렇게 API를 호출하는 라이브러리를 mocking하는 방식은 나중에 호출 방식이 바뀌었을때 테스트또한 바꿔야하는 문제가 생기므로 msw 와 같은 mock server를 이용해 테스팅을 하는 것이 좋습니다.
이전에 Axios를 사용하다가 ReactQuery로 컴포넌트를 바꾸게 되면 Mock을 사용한 테스트코드도 전부 바꿔야하는데 mock server를 사용하면 그럴 필요가 없다는 장점이 있습니다.
yarn add -D msw
or
npm install --save-dev msw
import { rest } from "msw";
import {setupServer} from "msw/node";
const server = setupServer(
rest.get("/blogs", (req, res, ctx) => {
const query = req.url.searchParams.get("query");
if(query === "Search") {
return res(ctx.json([
{ id: 1, title: "Search 1", author: "Author 1" },
{ id: 2, title: "Search 2", author: "Author 2" },
]));
} else if (query === "") {
return res(ctx.json([
{ id: 1, title: "Blog 1", author: "Author 1" },
{ id: 2, title: "Blog 2", author: "Author 2" },
{ id: 3, title: "Blog 3", author: "Author 3" },
{ id: 4, title: "Search 1", author: "Author 3" },
{ id: 5, title: "Search 2", author: "Author 3" },
]));
} else {
return res(ctx.json([]));
}
})
);
/blogs
라는 API를 호출하면 특정 데이터를 응답하도록 만든 예제입니다. rest.get
은 get으로 api를 호출하면 응답을 만들어주는 메서드입니다. 자세한 사용법은 공식홈페이지를 참고해주세요
여기서 가장 주의할점은 Mock은 반드시 로직을 통한 응답이 아닌 정해진 데이터로 응답을 해야한다는 점 입니다.
가끔 Mock Server를 만든다면서 Backend를 재구현하는 경우가 있습니다. 디비를 연결해서 데이터를 가져오거나, 계산을 한다던가.. 사실상 백엔드 개발을 두 번하고 있죠..
그러나 이렇게 구현을 할 경우 Mock Server는 백엔드와 로직이 중복되게 되고, Mock의 응답을 장담할 수 없다는 단점이 있습니다.
테스트가 실패할경우 Mock의 문제인지 테스트컴포넌트의 문제인지 알수 가 없죠.
그래서 Mock Server나 Mock은 로직이 들어가지 않은 정해진 응답을 주는게 매우 중요합니다.
간단한 BlogPage 컴포넌트를 작성해보도록 하겠습니다. 요구사항은 다음과 같습니다.
1. BlogsPage가 로드되었을때 /blogs를 호출하여 전체 블로그 리스트를 가져온다.
2. 검색창에 검색어를 넣으면 해당 검색어가 포함된 블로그 리스트만 가져오고 다른 데이터는 랜더링 되지 않는다.
백엔드에서 받을 데이터를 리턴하는 Mock Server를 만들어보자
// BlogsPage.test.js
import { rest } from "msw";
import {setupServer} from "msw/node";
const server = setupServer(
rest.get("/blogs", (req, res, ctx) => {
// /blogs?query=:검색어
const query = req.url.searchParams.get("query");
if(query === "Search") {
console.log("Search " + query);
return res(ctx.json([
{ id: 1, title: "Search 1", author: "Author 1" },
{ id: 2, title: "Search 2", author: "Author 2" },
]));
} else if (query === "") {
return res(ctx.json([
{ id: 1, title: "Blog 1", author: "Author 1" },
{ id: 2, title: "Blog 2", author: "Author 2" },
{ id: 3, title: "Blog 3", author: "Author 3" },
{ id: 4, title: "Search 1", author: "Author 3" },
{ id: 5, title: "Search 2", author: "Author 3" },
]));
} else {
return res(ctx.json([]));
}
})
);
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
beforeAll
은 같은 파일의 테스트를 실행할때 전체 테스트중 단 1번 먼저 시작되는 메서드입니다.
beforeEach
는 모든 테스트케이스 전에 실행되는 메서드
afterAll
은 모든 테스트 후 실행되는 메서드
afterEach
는 모든 테스트 이전 실행되는 메서드입니다.
각각에 서버를 초기화 하고 종료하는 로직을 넣었습니다.
React-Testing-Library를 통해 테스트를 작성해 보겠습니다.
예제는 Redux
를 사용할때의 예제를 포함해서 진행하였습니다. Redux를 안쓸 경우 store 부분만 생략해서 진행하면 됩니다.
// BlogsPage.test.js
test('BlogsPage를 랜더링하면 모든 블로그가 표시되어야한다.', async () => {
// Redux Store
const store = configureStore({
reducer: blogReducer,
});
// when render BlogList
render (
<Provider store={store}>
<BlogsPage/>
</Provider>
);
//wait for useEffect to finish
await waitFor(() => {
expect(screen.getByText('Blog 1')).toBeInTheDocument();
});
expect(screen.getByText('Blog 2')).toBeInTheDocument();
expect(screen.getByText('Blog 3')).toBeInTheDocument();
expect(screen.getByText('Search 1')).toBeInTheDocument();
expect(screen.getByText('Search 2')).toBeInTheDocument();
});
waitFor
메서드는 안에 있는 내용이 랜더링 성공 할 때 까지 기다려주는 메서드입니다. useEffect
를 통해 가져오는 것을 기다리기 위해 사용했습니다.
test('검색창에 Search를 넣으면 해당 검색어가 포함된 블로그 리스트만 가져오고 다른 데이터는 랜더링 되지 않는다.', async () => {
// create redux store
const store = configureStore({
reducer: blogReducer,
});
// when render BlogList
render (
<Provider store={store}>
<BlogsPage/>
</Provider>
);
await waitFor(() => {
expect(screen.getByText('Blog 1')).toBeInTheDocument();
});
// put input query
userEvent.type(screen.getByRole('textbox'), 'Search');
await waitFor(() => {
expect(screen.queryByText('Blog 1')).toBeNull();
});
await waitFor(() => {
expect(screen.getByText('Search 1')).toBeInTheDocument();
});
expect(screen.queryByText('Blog 2')).toBeNull();
expect(screen.queryByText('Blog 3')).toBeNull();
expect(screen.getByText('Search 1')).toBeInTheDocument();
expect(screen.getByText('Search 2')).toBeInTheDocument();
});
테스트 코드를 짤때 이런식으로 컴포넌트의 요구사항을 먼저 정의하고 테스트를 작성하면, 필요 한 기능을 정확하게 테스트할 수 있습니다. 테스트를 만들기전에 어떤 요구사항이 있는지 글로 적어보는게 중요합니다.
만약 요구사항을 작성하고 그에 따른 테스트를 먼저 작성한 후에 코드를 작성하면 이는 TDD(테스트 주도 개발)이 됩니다. 테스트 주도개발의 핵심은 요구사항을 테스트로 만들고, 해당 코드를 작성하여, 요구사항을 정확하게 통과하는 코드를 작성하는 방식입니다.
카우치코딩에서는 1:1 코딩 문제해결 멘토링 서비스입니다. 가르치는데 관심있는 멘토분들이나 문제해결이 필요한 멘티분들 방문해주세요~
또한 별도로 6주 포트폴리오 수업을 진행중에있습니다. 혼자 포트폴리오 준비를 하는데 어려움이 있으면 관심가져주세요~
카우치코딩 고동휘 멘토의 글입니다.