테스트 코드란?
소프트웨어 개발에서 주요 기능이나 모듈이 예상대로 작동하는지 검증하기 위한 코드
그거 백엔드 서버에만 작성하는거 아님?
나의 대답은 No이다. 백엔드 테스트는 데이터, 비즈니스 로직, API 등에 중점을 둔다면, 프론트엔드 테스트는 사용자 인터페이스와 상호 작용에 중점을 두고 테스트를 진행해야 한다고 생각한다.
여기서 "생각한다" 라고 말한 이유는 테스트 코드 작성이 필수적이라고 생각하지 않는 의견도 있기 때문이다.
물론 나도 필수적이라고 생각하진 않는다. 개발 인력이 부족하여 빠르게 기능 개발에 집중해야 할 때, 정해진 개발 기한이 있고 서비스 규모가 작으며, 앞으로 발전되지 않을 서비스라면 테스트 코드 작성이 오히려 생산성을 저하 시킬 수 있다고 생각한다.
하지만, 코드가 계속 더해지고, 규모가 계속 커져 나갈 서비스라면 테스트 코드 작성은 필수적이다. 막대한 양의 코드 앞에 테스트 코드 없이 기능을 수정하는 일은 총 없이 전쟁터에 나가는 꼴이다. 개발자는 수정된 코드가 다른 기능엔 영향이 미치지 않는지 일일히 테스트 해보며 에너지를 다 쓰게 될 수도 있다.
"리팩토링 할때 가장 먼저 해야할 일은 테스트 코드를 작성하는 일이다" -마틴 파울러-
이제 서비스에 테스트 코드를 도입하는 과정과 겪었던 문제에 대해 이야기 해보자.
우선 나는 컴포넌트 테스트와 사용자 관점에서 테스트 코드를 작성할것이기 때문에 React Testing Library를 사용하기로 하였다. React Testing Library를 사용하면 컴포넌트의 마운트, 업데이트, 언마운트 등의 생명주기를 테스트하는 데 도움을 준다.
현재 서비스에 테스트 코드가 전무한 상태이기 때문에 컴포넌트 테스트 부터 시작해서 모듈 테스트, e2e 테스트로 나아가도록 하기로 하고 컴포넌트 테스트 코드부터 작성해 보았다.
이 컴포넌트는 플레이 리스트의 정보를 보여주는 컴포넌트이다. 많은 페이지들에서 재사용 되므로 컴포넌트로 분리하여 사용중이었다.
일단 해당 컴포넌트에서 어떠한 것을 테스트 해볼 수 있을지 생각했다. 어떤 형식이 정해져 있는 것이 아닌 개발자 본인에게 안정감을 주는 어떠한 것도 테스트 할 수 있다고 생각하기에, 내 입맛에 맛는 기능 위주에 테스트를 작성해 보기로 생각했다.
우선 1번을 테스트 하기 위해 nextjs의 useRouter를 모킹해서 push함수가 실행 되는지 테스트 해보기로 하였다.
그런데 여기서 문제가 발생했다.
export const useRouter = () => {
return {
push: jest.fn(),
};
};
컴포넌트와 테스트 코드에서 next의 useRouter를 사용하도록 모킹한 함수이다. 테스트 할 때 테스트 코드에서 import한 useRouter의 push 함수와 컴포넌트에서 호출한 push 함수가 동일한 지 비교하도록 되어있는데, 계속 테스트 실패가 발생했다.
it('플레이 리스트를 클릭하면 상세 페이지로 이동한다.', async () => {
render(<PlaylistItem title="테스트 플레이리스트" playlistId="123" />);
const playlistItem = await screen.findByTestId('playlist-item-container');
expect(playlistItem).toBeInTheDocument();
userEvent.click(playlistItem);
await waitFor(() => {
expect(useRouter().push).toHaveBeenCalledWith('/detail/123');
});
});
이유를 알아차리고 아직도 언어와 CS 대한 이해가 부족하다고 생각하였다. 이유는 테스트 코드에서 모킹된 useRouter와 컴포넌트에서 모킹된 useRouter의 인스턴스가 서로 다르기 때문이다. push 함수는 이름만 같지 다른 인스턴스에서 호출된 함수이기 때문에 당연히 서로 다른 함수이기 때문에 테스트가 계속 실패했다. push 함수가 동일한 함수를 참조하도록 수정하여 문제를 해결하였다.
const mockedPush = jest.fn();
export const useRouter = () => {
return {
push: mockedPush,
};
};
3번은 클릭 이벤트가 발생했을 때 조회수가 올랐는지, 아이콘이 렌더링 됐는지, 모킹한 api 호출이 발생하는지 테스트하여 손쉽게 테스트를 통과 할 수 있었다.
it('좋아요를 누르면 아이콘이 바뀌고 좋아요 카운트가 1 증가하고 api 요청이 발생한다', async () => {
const playlistId = '123';
const likeCount = 0;
render(
<PlaylistItem
title="테스트 플레이리스트"
playlistId={playlistId}
isLiked={false}
likeCount={likeCount}
/>
);
const outlinedIcon = await screen.findByTestId('outlined-heart-icon');
expect(outlinedIcon).toBeInTheDocument();
userEvent.click(outlinedIcon);
const filledIcon = await screen.findByTestId('filled-heart-icon');
expect(filledIcon).toBeInTheDocument();
expect(await screen.findByText(1));
await waitFor(() => {
expect(http.patch).toHaveBeenCalledWith(`/playlists/like`, {
playlistId,
like: true,
});
});
});
마지막으로 스크립트에 prebuild로 test를 거치도록 하여 빌드 전 테스트를 통과 해야 빌드가 수행 될 수 있도록 하였다.
테스트 코드를 작성함으로서 버그를 미리 잡아내어 사용자 경험을 보장할 수 있고, 언제든지 리팩토링을 할 수 있겠다는 자신감을 얻게 되었다. 또한 얻게된 제일 좋은 교훈은 테스트 하기 좋은 코드를 작성하기 위해 컴포넌트들을 분리하게 되는데, 이 때 자연스럽게 관심사 분리가 되어 품질 좋은 코드가 탄생하며, 자연스럽게 리팩토링이 된다는 것이었다. 추후에는 요즘 핫한 TDD를 도입한 프로젝트를 진행하여 개발자에게 어떤 개발경험을 가져다 주는지 경험해 보고 싶다.