코드와 함께 살펴보는 프론트엔드 단위 테스트 – Part 2. 실전 편
“Part 1. 이론 편”에서 소개한 내용들은 여러분에게 도움이 되셨나요? Part 1에서는 본격적으로 테스트 코드를 작성하기 전 알고 있으면 도움이 되는 내용을 다루었습니다. 말미에서 언급한 것처럼 이제는 머리로 배운 것을 손으로 테스트 코드를 작성하면서 익힐 때가 되었습니다. 저는 글의 시작에 있는 문장처럼 실제로 사용 혹은 적용해 봐야 공부가 비로소 완성되었다고 생각합니다. 개발자라면 배운 지식을 본인이 개발하는 프로덕트에 필요한지 고민해 보고 필요하다면 적용해 봐야죠!
오늘은 이론 편에 이어 “Part 2. 실전 편”이라는 제목을 가지고 왔습니다. 실전 편인 만큼 다양한 기술 스택이 엮인 코드에 대해서 실전에 가까운 테스트 코드와 함께 내용을 소개할 것인데요. 프론트엔드의 특징 중 하나는 사용자와 직접 상호작용하는 부분이 있다는 것입니다. 이 말을 테스트 관점에서 본다면 사용자와 상호작용하는 시나리오를 포함해 테스트 코드를 작성해야 한다고 볼 수 있습니다. Part 2에서 작성할 테스트 코드와 내용 역시 사용자와 상호작용을 포함해 다루어보도록 하겠습니다.
⚠️ 테스트 코드 예제를 읽기 전에
이 글의 모든 테스트 코드는 React와 테스트 도구인 Vitest, React Testing Library와 함께 작성되었습니다. 또한, 테스트 환경과 문법에 대한 이야기는 아니므로 테스트 코드 문법, 테스트 환경에 대한 설정과 테스트 유틸리티에 대한 내용은 생략하고 개별 테스트 코드에만 집중하겠습니다. Vitest가 생소하시더라도 Jest와 대부분의 문법이 호환되니 코드 보시는데 문제없을 거예요. 🙂
간단한 단위 테스트 코드 살펴보기
현실적인 예제를 다루기 전에 먼저 간단하고 쉬운 동작을 가진 함수와 컴포넌트의 단위 테스트 코드를 살펴보겠습니다. 테스트 코드 자체가 어떤 코드의 동작을 검증하기 위한 코드이므로 본 코드와 테스트 코드 양쪽 다 보며 설명하도록 하겠습니다. 이론 편에서 다루었던 내용을 떠올리면서 아래의 내용을 읽어봅시다.
함수의 단위 테스트
const isBornIn2000OrLater = (digits: string) => {
return ['3', '4', '7', '8'].includes(digits[0])
}
isBornIn2000OrLater 함수는 주민등록번호 뒷자리를 받아 2000년 이후에 태어났는지 판별하는 함수입니다. 구현의 단순함을 위해 유효성 검사들은 모두 생략하겠습니다. 위 코드를 봤을 때 테스트 코드는 어떻게 작성하면 될까요?
it('2000년 이후에 태어난 사람의 주민등록번호 뒷자리인지 검증한다', () => {
expect(isBornIn2000OrLater('1234567')).toBe(false);
expect(isBornIn2000OrLater('2134567')).toBe(false);
expect(isBornIn2000OrLater('3124567')).toBe(true);
expect(isBornIn2000OrLater('4123567')).toBe(true);
expect(isBornIn2000OrLater('5123467')).toBe(false);
expect(isBornIn2000OrLater('6123457')).toBe(false);
expect(isBornIn2000OrLater('7123456')).toBe(true);
expect(isBornIn2000OrLater('8123456')).toBe(true);
});
isBornIn2000OrLater 함수를 검증하는 테스트 코드를 작성해 보았습니다. 총 8개의 expect에 각기 다른 값으로 호출한 함수의 실행 결과를 넣고 toBe의 값과 비교해 일치하는지 확인합니다. 이 테스트 케이스에서 문제가 발생하지 않는다면 8개가 모두 정상적으로 통과했다는 의미입니다. 생각보다 간단하고 쉽죠? 그럼, 이제 컴포넌트 예제를 살펴봅시다.
컴포넌트의 단위 테스트
import { useState } from 'react';
export default function CountComponent() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
return (
<>
<p>버튼을 {count}번 클릭했습니다.</p>
<button onClick={handleClick}>여기를 눌러보세요</button>
</>
);
}
CountComponent는 react.dev의 예제 컴포넌트 중 하나를 살짝 변형한 것입니다. 함수와의 차이를 보여줄 수 있도록 상태가 존재하는 컴포넌트를 가져왔습니다. 코드를 간단히 설명하면 버튼을 누를 수 있고 그 위로는 누른 횟수가 출력됩니다. 컴포넌트의 테스트 코드는 어떻게 작성할까요? 기본적인 틀은 함수와 같겠지만 사용자와 실제로 상호작용하는 부분은 Testing Library의 도움을 받아 해결할 수 있습니다. @testing-library/react
와 함께 CountComponent에 대한 단위 테스트 코드를 작성해 봅시다.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
describe('CountComponent 단위 테스트', () => {
it('처음에는 0번 클릭했다는 문구가 노출된다', () => {
render(<CountComponent />);
expect(screen.getByText('버튼을 0번 클릭했습니다.')).toBeInTheDocument();
});
it('버튼이 클릭되면 문구의 숫자가 1씩 증가한다', async () => {
const user = userEvent.setup();
render(<CountComponent />);
const buttonElement = screen.getByRole('button', { name: '여기를 눌러보세요' });
await user.click(buttonElement);
expect(screen.getByText('버튼을 1번 클릭했습니다.')).toBeInTheDocument();
await user.click(buttonElement);
await user.click(buttonElement);
expect(screen.getByText('버튼을 3번 클릭했습니다.')).toBeInTheDocument();
});
});
작성한 테스트 코드에는 총 2개의 테스트 케이스가 있습니다. 하나는 렌더링 시 문구가 노출되는지, 하나는 사용자가 버튼을 클릭하며 상호작용했을 때 변경된 문구가 노출되는지 확인합니다. 테스트 코드에서 이해되지 않는 부분이 있나요? 여전히 쉽죠? 우리의 함수와 컴포넌트가 이렇게 간단하면 참 좋겠습니다.
하지만 현실에서 우리의 코드는
바로 위에서 본 코드 예시들은 대부분 공부하실 때 볼 수 있을 법한 수준의 함수와 컴포넌트입니다. 다시 말하면 간단하고 이상적이라는 코드라는 이야기입니다. 하지만 저희가 만드는 현업의 프로덕트는 예제들처럼 간단하지 않습니다. 지금부터는 현업에서 사용할 법한 여러 가지 기술 스택이 섞인 실제 코드 혹은 그와 가까운 코드들을 보여드릴 텐데요! 아래의 기술 스택을 사용한 좀 더 현실적인 컴포넌트 코드를 살펴봅시다.
- react@18.X.X + typescript@5.X.X
- @tanstack/react-query@5.X.X
- zustand@4.X.X
- msw@2.X.X
const Identification = ({ referrer, onFinish }) => {
const [someText, setSomeText] = useState('');
/* zustand 스토어 */
const { needExtraAuthentication } = useMemberStore();
/* React Query API 호출 */
const { data, error, isFetching } = useQuery({
// ...
queryFn: () =>
fetchIdentificationInfo({
// ...
}),
// ...
});
// ...
const showExtraInformation = referrer === 'something' || needExtraAuthentication;
const handleChangeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value.length > 8) {
// ...
} else {
// ...
setSomeText(e.target.value);
}
};
const handleClickButton = () => {
// ...
onFinish(someText);
};
// ...
useEffect(() => {
if (error) {
// ...
window.location.replace('https://HOST/fail');
}
}, [error]);
if (isFetching) return <Loading aria-label="화면을 불러오는 중" />;
return (
<div>
<h1>인증을 시작합니다</h1>
{/* 컴포넌트 코드 ... */}
<label id="comment-label">Comment</label>
<input type="text" value={someText} onChange={handleChangeInput} aria-labelledby="comment-label"></input>
{/* 컴포넌트 코드 ... */}
{showExtraInformation && <ExtraInfomation>부가 정보</ExtraInforamtion>}
{/* 컴포넌트 코드 ... */}
<button type="button" onClick={handleClickButton}>
확인
</button>
</div>
);
};
한눈에 봐도 여러 가지 로직이 존재하는 컴포넌트입니다. Identification 컴포넌트에 대해 어떤 테스트 코드를 작성해야 할까요? 프로덕트를 개발할 때 여러분은 기획서를 기반으로 컴포넌트를 설계했을 것입니다. 여기서는 구현된 코드 먼저 살펴보았지만, 실제로는 기획서를 기반으로 아래와 같이 컴포넌트를 설계해 개발했다고 가정해 봅시다.
- 최초 진입 시 인증 정보 API를 호출하며 호출 전까지 로딩 컴포넌트를 노출함
- API 호출 성공 시, UI가 노출됨
- API 호출 실패 시, 실패 페이지로 이동함 (location.replace)
- 입력할 수 있는 input이 노출되며 8자까지만 입력 가능함
- ExtraInformation 컴포넌트는 props로 받은 referrer가 something이거나 memberStore의 값 중 needExtraAuthentication가 true인 경우에만 노출함
- 확인 버튼이 노출되며 클릭했을 때 props로 받은 onFinish가 input에 입력된 내용과 함께 호출됨
- (다른 명세는 코드의 간략함을 위해 생략)
Identification 컴포넌트는 기획서와 디자인을 기반으로 API 호출 성공/실패에 따라 처리를 어떻게 할지, 특정 정보가 조건에 맞춰 노출 혹은 노출되지 않는지, 사용자의 입력과 버튼을 눌렀을 때 어떤 처리가 일어나는지 등 여러 정책을 담아 개발되었습니다. 조건이 명확한 명세가 존재한다는 것은 모두 테스트 케이스화해야 하는 정책이라는 이야기입니다. 코드 관점에서 이야기하면, JavaScript에서 특정 조건에 따라 UI가 노출되거나 로직이 실행된다면, 해당 내용은 모두 테스트 대상이라고 볼 수 있습니다. Identification 컴포넌트의 테스트 코드는 어떻게 작성하면 될까요?
시나리오 생각하기
먼저, 테스트 시나리오를 생각해야 합니다. 다르게 말하면 어떤 테스트 케이스를 가질 것인지 고민하고 코드를 작성해야 한다는 뜻인데요! 우리는 이미 컴포넌트가 어떤 기능을 가지고 어떤 역할을 수행하는지 자세히 살펴보았습니다. 이를 바탕으로 주어진 설명에서 어떤 코드를 실행시키고 어떤 것을 검증할지 생각해 시나리오를 작성해 봅시다.
# | 설명/명세 | 조건(실행할 로직) | 검증/확인할 것 |
---|---|---|---|
1 | 인증 정보 API 호출하며 성공 시 페이지 제목이 노출된다 | 인증 정보 API 호출이 성공하도록 한다 | 인증을 시작합니다 문구가 화면에 표시된다 |
2 | 인증 정보 API 호출 실패 시 실패 페이지로 이동한다 | 인증 정보 API 호출이 실패하도록 한다 | 실패 페이지로 location.replace한다 |
3 | 입력창에는 8자리까지만 입력된다 | 입력창에 8자리를 초과해 입력한다 | 입력창에는 8자리까지만 입력되어 노출된다 |
4-1 | ExtraInformation 컴포넌트는 특정 조건에서만 표시된다 | referrer를 something으로 props로 전달한다 | ExtraInformation 컴포넌트가 노출된다 |
4-2 | memberStore의 needExtraAuthentication 상태를 true로 설정한다 | ExtraInformation 컴포넌트가 노출된다 | |
4-3 | referrer을 something 이외의 값으로 props로 전달하고 memberStore의 needExtraAuthentication 상태를 false로 설정한다 | ExtraInformation 컴포넌트가 노출되지 않는다 | |
5 | 확인 버튼 클릭 시 input에 입력한 내용과 함께 onFinish 이벤트를 호출한다 | 입력창에 내용을 입력하고 버튼을 누른다 | onFinish 핸들러가 입력한 내용과 함께 호출된다 |
잠깐! 테스트 코드 with Mocking
시나리오에서 실행할 로직에 보면 클릭이나 키보드 입력 이외에도 API 호출 결과를, props, 스토어의 상태를 조작해야 합니다. 검증할 것에도 보면 단순 문구 노출 이외에 window.alert의 결과를 보는 것도 있고 어떤 함수가 호출되었는지 확인하는 것도 있습니다. 이러한 동작과 결과를 얻기 위한 코드가 필요하겠죠?
테스트 코드에선 목적 달성을 위해 모킹(Mocking)을 활용합니다. 모킹이란 간단히 말하면 내부 혹은 외부 서비스의 가짜 버전을 만드는 것입니다. 모킹을 사용하지 않는다면 테스트 시간이 늘고 복잡해지며 모든 인터페이스가 구현된 환경 위에서 테스트해야 합니다. 반드시 모든 것을 모킹해야하는 것은 아니고 일부는 모킹을 피해야 하는 코드도 존재할 것입니다. 테스트에서 필요한 경우에 모킹을 활용한다면 효과적이고 효율적으로 테스트를 진행할 수 있습니다. API 호출 모킹의 경우에는 테스트뿐만 아니라 로컬 개발 시에도 개발 생산성을 높일 수 있도록 도와줍니다.
Vitest나 Jest 같은 테스트 도구에서도 각종 모킹을 지원합니다. mock 함수(e.g. vi.fn()
, vi.spyOn(~~)
)를 비롯해 전역 수준에서 존재하는 객체를 모킹할 수 있는 인터페이스도 있고 파일이나 라이브러리 단위로도 모킹할 수 있는 인터페이스가 존재합니다. 이번 테스트 케이스에서는 mock 함수만 필요하므로 해당 인터페이스만 활용하겠습니다. API 호출의 경우는 MSW를 활용할 수 있습니다. 서비스 워커의 도움 덕분에 모킹을 한다고 해도 네트워크 레벨에서 실제로 API 호출을 하는 듯이 동작합니다. MSW 공식 홈페이지에서 테스트 환경에 설정하는 가이드가 있으므로 참고해서 원하는 곳에 원하는 API 호출 모킹을 설정할 수 있습니다.
테스트 코드 작성하기
테스트 코드를 작성하기 전에 반드시 적절한 expect가 테스트 코드에 존재해야 한다고 당부드리고 싶습니다. 왜냐하면 개별 테스트 케이스에서 실패하지 않으면 무조건 성공으로 취급되기 때문입니다. expect가 있어야만 테스트 코드에서 기대하는 동작에 대한 검증이 발생합니다. 또, Part 1에서 이야기한 expect 구문이 검증하는 내용도 명확해야 한다는 것도 떠올려 봅시다. 시나리오와 모킹을 기반으로 검증 절차까지 포함하는 완성된 테스트 코드를 살펴봅시다.
const defaultIdentificationProps = {
referrer: '',
onFinish: () => {},
};
describe('Identification 단위 테스트', () => {
// ...
it('인증 정보 API 호출하며 성공 시 페이지 제목이 노출된다', async () => {
/* MSW 성공 응답 설정 */
server.use(
http.get('인증정보 API URL', () => {
return HttpResponse.json({
// 성공 응답 JSON
});
}),
);
render(<Identification {...defaultIdentificationProps} />);
await waitFor(() => {
expect(screen.queryByLabelText('화면을 불러오는 중')).not.toBeInTheDocument();
});
expect(screen.getByText('인증을 시작합니다')).toBeInTheDocument();
});
it('인증 정보 API 호출 실패 시 실패 페이지로 location.replace 처리한다', async () => {
/* MSW 실패 응답 설정 */
server.use(
http.get('인증정보 API URL', () => {
return HttpResponse.json({
// 실패 응답 JSON
});
}),
);
const mockReplace = vi.spyOn(window.location, 'replace');
render(<Identification {...defaultIdentificationProps} />);
await waitFor(() => {
expect(screen.queryByLabelText('화면을 불러오는 중')).not.toBeInTheDocument();
});
expect(mockReplace).toBeCalledWith('https://HOST/fail');
});
it('input에는 8자까지만 입력할 수 있다', async () => {
const user = userEvent.setup();
render(<Identification {...defaultIdentificationProps} />);
await waitFor(() => {
expect(screen.queryByLabelText('화면을 불러오는 중')).not.toBeInTheDocument();
});
const commentInput = screen.getByLabelText('comment-label');
await user.type(commentInput, '여덟자리가넘어갑니다');
expect(commentInput).toHaveValue('여덟자리가넘어갑');
});
describe('ExtraInformation는', () => {
it('props로 전달된 referrer이 something일 때 노출된다', async () => {
const referrer = 'something';
render(<Identification {...defaultIdentificationProps} referrer={referrer} />);
await waitFor(() => {
expect(screen.queryByLabelText('화면을 불러오는 중')).not.toBeInTheDocument();
});
expect(screen.getByText('부가 정보')).toBeInTheDocument();
});
it('사용자 정보 중 needExtraAuthentication가 true일 때 노출된다', async () => {
const { result } = renderHook(() => useMemberStore());
act(() => {
result.current.setMemberInfo({ needExtraAuthentication: true });
});
render(<Identification {...defaultIdentificationProps} />);
await waitFor(() => {
expect(screen.queryByLabelText('화면을 불러오는 중')).not.toBeInTheDocument();
});
expect(screen.getByText('부가 정보')).toBeInTheDocument();
});
it('이외의 경우에는 노출되지 않는다', async () => {
const referrer = 'other';
const { result } = renderHook(() => useMemberStore());
act(() => {
result.current.setMemberInfo({ needExtraAuthentication: false });
});
render(<Identification {...defaultIdentificationProps} referrer={referrer} />);
await waitFor(() => {
expect(screen.queryByLabelText('화면을 불러오는 중')).not.toBeInTheDocument();
});
expect(screen.queryByText('부가 정보')).not.toBeInTheDocument();
});
});
it('확인 버튼 클릭 시 input에 입력한 내용과 함께 onFinish 이벤트를 호출한다', async () => {
const onFinish = vi.fn();
const user = userEvent.setup();
render(<Identification {...defaultIdentificationProps} onFinish={onFinish} />);
await waitFor(() => {
expect(screen.queryByLabelText('화면을 불러오는 중')).not.toBeInTheDocument();
});
const commentInput = screen.getByLabelText('comment-label');
const confirmButton = screen.getByRole('button', { name: '확인' });
await user.type(commentInput, '입력한 내용');
await user.click(confirmButton);
expect(onFinish).toBeCalledWith('입력한 내용');
});
// ...
});
설계한 테스트 시나리오대로 테스트 코드가 작성되었습니다. 컴포넌트 내부 구현과 상관없이 오로지 명세대로 테스트 코드가 구현했는데 느껴지시나요? 예를 하나 들어 보겠습니다. 컴포넌트 코드에 현재 useQuery에 isFetching이 활용되었지만 useSuspenseQuery와 Suspense를 가지고 로딩 컴포넌트를 표시하도록 구현했어도 테스트 코드가 통과할 것입니다. 여기서 드리고 싶은 말은 테스트 코드 작성에 내부 구현은 중요하지 않다는 이야기입니다. 어떤 식으로 구현하든 테스트 코드를 통과하는 컴포넌트를 구현해야 합니다. 작성한 테스트 코드의 설명이 다소 아쉽게 느껴지실 수도 있습니다. 하지만 실제 기획서나 정책이 아닌 현재 주어진 정보만 가지고 작성한 것이기 때문에 현 상황에선 더 고도화하기는 어려울 것으로 보입니다. Part 1에서 이야기드렸듯이 설명은 정책을 담는 게 좀 더 좋은데 실제 코드라면 기획서를 참고해서 만들었을 것 같네요!
⚠️ 만약 API 에러 처리가 React Portal을 이용한 커스텀 팝업 컴포넌트였다면?
화면에 해당 문구와 함께 팝업(여기선 Role이 alertdialog)이 표시되었는지 검증해야 할 것입니다. 보통 screen.getBy~~ 로 찾으실 텐데 이 방법으로는 React Portal을 이용해 root 컴포넌트 밖에서 띄워진 컴포넌트는 screen에서 찾을 수 없을 것입니다. 이 땐 Testing Library의 render 함수에서 반환하는 baseElement를 활용할 수 있습니다.
const { baseElement } = render(...)
를 활용해getQueriesForElement(baseElement).getByText(...)
와 같은 코드로 HTML 요소를 찾을 수 있습니다. 만약 커스텀 팝업이 아니라window.alert
였다면 모킹을 활용하면 되겠죠?
보너스! React Custom Hook 단위 테스트 with 타이머
지금까지 컴포넌트와 함께 테스트 코드를 작성해 보았습니다. 감이 좀 오시나요? 이번엔 여러분들이 직접 명세를 보고 테스트 코드와 본 코드를 작성하는 보너스 예제를 준비했습니다. 여러분들이 React를 사용하신다면 당연히 Custom Hook을 만드신 경험이 있을 겁니다. 훅과 함께 타이머를 사용할 때의 테스트 코드를 준비했는데 위에서는 다루지 않았죠? 역시 알고 있는 걸 쓰면서 새로운 내용을 섞어야 공부가 되는 거죠! ㅎㅎ 그렇다고 너무 걱정하지는 마세요. 약간의 흐름을 잡을 수 있는 힌트와 모범 답안까지 모두 준비했습니다.
어떤 컴포넌트에서 특정 주기로 함수를 실행해야 하며 함수가 실행되는 주기는 조건에 따라 실시간으로 변경될 수 있는 상황을 가정해 봅시다. 처음에는 컴포넌트 내에 구현하려고 했으나 여러 컴포넌트에 걸쳐 해당 기능이 필요해 useInterval이라는 Custom Hook으로 분리해 구현하기로 했습니다. 해당 기능을 구현하기 위해 개발설계를 한다면 어떤 명세를 떠올리시나요? 제가 생각한 명세는 아래와 같습니다.
- 특정 주기로 함수를 실행해야 함
- 함수가 실행되는 주기를 변경할 수 있음
간단하죠? 저희가 코드 구현에 앞서 기획서를 보고 코드에 대한 구상과 설계를 생각할 때, 모든 기능을 하나하나 명세로 분리한다기보단 이런 기능을 하는 코드가 필요하겠구나! 정도로 생각하니 그 느낌대로 작성해 보았습니다. 해당 명세를 기반으로 useInterval의 인터페이스를 고민하면서 테스트 코드를 작성해봅시다. 제가 생각한 모범답안은 아래와 같은데요! 테스트 코드를 먼저 작성해보시고 한 번 비교해서 보면 좋을 것 같습니다. 🙂
// 테스트 코드 - useInterval.test.ts
describe('useInterval 단위 테스트', () => {
const mockAlert = vi.fn();
beforeAll(() => {
window.alert = mockAlert;
});
beforeEach(() => {
vi.useFakeTimers();
mockAlert.mockClear();
});
afterEach(() => {
vi.clearAllTimers();
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it('callback 함수를 설정한 delay 마다 실행시킨다.', () => {
renderHook(() =>
useInterval(() => {
window.alert('호출!');
}, 500),
);
vi.advanceTimersByTime(200);
expect(mockAlert).not.toBeCalled();
vi.advanceTimersByTime(300);
expect(mockAlert).toBeCalledWith('호출!');
vi.advanceTimersByTime(1000);
expect(mockAlert).toBeCalledTimes(3);
});
it('delay가 변경되면 반복 주기를 변경한다', () => {
let delay = 500;
const { rerender } = renderHook(() =>
useInterval(() => {
window.alert('호출!');
}, delay),
);
vi.advanceTimersByTime(1000);
expect(mockAlert).toBeCalledTimes(2);
delay = 200;
rerender();
vi.advanceTimersByTime(1000);
expect(mockAlert).toBeCalledTimes(7);
});
});
useInterval의 인터페이스까지 설계한 완성된 테스트 코드입니다. 타이머와 관련된 코드와 테스트 전체 실행 전, 각각의 케이스 실행 전후로 실행할 함수도 포함되어 있습니다. 위 예제에선 renderHook
의 rerender
만 사용하고 있지만 함수에서 반환하는 메소드나 값을 받아야 했다면 result.current
도 사용했을 것입니다. mockAlert 같은 경우는 상위에 올리지 않고 개별 테스트 코드에서 각각 사용하셔도 무방합니다. spyOn을 사용해서 테스트해도 됩니다. 제시한 테스트 코드는 여러분이 작성한 테스트 코드와 비슷한가요? 테스트 코드 작성을 했다면 이제는 useInterval 코드를 완성하고 테스트를 돌려봅시다.
// 구현 코드 - useInterval.ts
const useInterval = (callback: () => void, delay: number) => {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopInterval = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
const startInterval = (nextDelay: number) => {
stopInterval();
intervalRef.current = setInterval(() => {
callback();
}, nextDelay);
};
useEffect(() => {
startInterval(delay);
}, [delay]);
useEffect(() => {
return () => {
stopInterval();
};
}, []);
};
테스트를 모두 통과하는 useInterval의 완성된 예시 코드입니다. 이제 테스트 코드에 대해서 조금은 익숙해지셨나요? 그럼 여기서 기능을 하나 더 추가한다고 생각해 봅시다. 특정 조건에 따라서만 해당 함수를 실행해야 하는 명세가 추가되었습니다. 구현 계획을 세워본다면 enabled 같은 옵션을 받을 수도 있고 useInterval에서 반복을 시작하고 종료하는 메소드를 반환할 수도 있겠죠! 여러분이 생각하는 인터페이스에 맞춰 한 번 테스트 코드와 코드를 만들어보세요. 지금까지 컴포넌트와 훅 테스트 코드를 보셨으니 이 정도는 쉽게 작성하실 수 있겠죠!?
테스트 코드도 “코드”입니다
지금까지 실전에서 있을법한 기술 스택과 명세에 대해 컴포넌트와 훅으로 테스트 코드를 살펴보았습니다. 같은 테스트 코드를 보면서도 어떤 분은 되게 상세하고 세부적인 것까지 테스트한다고 생각하실 수도 있고, 어떤 분은 반대로 테스트 케이스가 부족한 것 아닌가라고 생각하시는 분이 있을 수 있습니다. 테스트 코드를 얼마나 상세하고 자세히 적어야 할지는 사람마다 그리고 상황마다 기준이 다를 수 있습니다. 테스트 코드 또한 의견이 갈릴 수 있는데요! 여기에 대한 답변으로 아래와 같은 내용을 남기고 싶습니다.
테스트 코드도 유지 보수하며 발전시켜야 합니다
테스트 코드라는 것을 떼고 “코드”라는 관점에서 한 번 살펴봅시다. 저희가 일반적인 코드를 작성할 때도 처음 만든 상태 그대로 방치되는 것이 아니라 계속해서 유지 보수하며 많은 발전을 이루게 됩니다. 테스트 코드 역시 처음 만들고 끝이 아니라 지속적으로 유지 보수해야 합니다. 기능이 확장되면서 테스트 코드도 변할 수도 있고, 이전에 놓쳤던 테스트 케이스를 추가해야 할 수도 있고, 어떨 땐 불필요한 테스트 케이스를 발견해 삭제할 때도 있을 것입니다. 프로덕트를 개발하면서 테스트 코드도 같이 신경 써서 유지 보수하면 프로덕트의 안정성도 같이 확보하면서 작업의 부담감도 줄일 수 있습니다. 또한, 개발자 개인에게도 테스트 코드를 꾸준하게 작성하고 공부하며 발전시킨다면 새로운 분야의 역량을 갈고닦을 수 있을 것입니다.
테스트 코드에서도 가성비를 챙겨봅시다
테스트 코드의 가성비 역시 중요합니다. 테스트 코드의 효용성이 작성 비용보다 높다면 당연히 작성해야 할 것입니다. 여기서 따져야 할 효용은 단순히 개발 편의성뿐만 아니라 장애 위험도, 프로덕트 안정성 및 유지 보수성 등 전반적인 관점에서 따져야 합니다. 테스트 코드가 길고 복잡하더라도 메인 비즈니스 로직이고 자주 변경된다면 비용이 아무리 높더라도 효용이 그 이상일 것이므로 반드시 필요할 것입니다. 만약 간단한 코드인데 작성할까 말까 고민될 때, 필요 없다고 확신이 들지 않는다면 그냥 쓰는 것도 추천드립니다. 고민하는 시간도 비용입니다. 혹시 테스트 코드를 처음 쓰셔서 불안하신 거라면 가성비와 더불어 가심비를 고려해 마음 편할 때까지 쓰시는 것도 나쁘지 않을 것 같네요!
Part 2를 마치며, 일단 써보세요
Part 1과 Part 2에 걸쳐 프론트엔드에서 현실적인 테스트 코드 작성에 관한 이야기와 인사이트 그리고 팁을 코드와 함께 풀어보았습니다. 아무리 이론과 실전 예제로 살펴보았어도 처음 테스트 코드를 작성한다면 무엇을, 어떻게 작성해야 할 것인가 고민이 많으실 것입니다. 그런 분들을 위해 헤밍웨이가 남겼다고 (추정되는) 문장을 하나 소개하겠습니다.
헤밍웨이가 쓴 그 유명한 소설인 노인과 바다도 200번이 넘는 퇴고를 하며 작성했다고 알려져 있는데요. 명문도 200번을 고쳐 써서 만들어졌는데 저희의 테스트 코드도 첫 번째에 완벽할 리 없다고 생각합니다. 우리가 프로덕트를 만들거나 수정할 때 처음부터 모든 코드가 완벽하고 잘 설계되고 작성될까요? 처음부터 잘 쓰려고 시간을 많이 소모하는 것보단 일단 공부해서 간단한 것 하나라도 작성부터 해보시라는 말을 드리고 싶습니다. (도전!)
지금까지 두 편의 글에 걸쳐 좋은 프로덕트를 위해 노력했던 여러 가지 중 단위 테스트에 관한 생각과 경험을 공유해 드렸는데요. 제가 적은 글이 테스트 코드와 프로덕트 안정성에 대해 고민하는 모든 개발자분께 도움이 되었길 바라며 ‘코드와 함께 살펴보는 프론트엔드 단위 테스트’에 마침표를 찍도록 하겠습니다.

서비스의 시작과 끝을 담당하는 프론트엔드 개발자로서 매일 고객분들과 맞닿고 있습니다. 주문, 혜택, 선물하기, 이벤트 등 다양한 프로덕트를 거쳐 요즘은 코어웹프론트개발팀에서 회원과 관련된 도메인을 주로 다루고 있습니다.