성능 최적화가 사용자 경험을 더 빠르고 부드럽게 만드는 것이라면, 테스트 코드는 내가 만든 기능이 의도대로 동작하는지 확인하고, 이후에 코드가 망가지는 것을 막는 것에 가깝다.
테스트 코드의 핵심은 이것이다.
사용자가 어떤 행동을 했을 때, 화면이나 결과가 기대한 대로 나오는지 자동으로 확인하는 것
테스트 코드는 사람이 직접 브라우저에서 하나하나 눌러보지 않아도, 코드로 기능 동작을 검증하는 것이다.
예를 들면 이런 것들을 확인할 수 있다.
로그인 버튼을 누르면 로그인 요청이 가는가?
검색어를 입력하고 Enter를 누르면 검색 결과가 보이는가?
API 요청이 실패하면 에러 메시지가 뜨는가?
좋아요 버튼을 누르면 좋아요 수가 증가하는가?
아주 단순하게 보면 테스트 코드는 이런 구조를 가진다.
expect(실제 결과).toBe(기대한 결과);
예를 들어 두 숫자를 더하는 함수가 있다고 해보자.
function add(a: number, b: number) {
return a + b;
}
이 함수가 제대로 동작하는지 테스트하면 이렇게 작성할 수 있다.
import { expect, test } from 'vitest';
test('두 숫자를 더한다', () => {
expect(add(1, 2)).toBe(3);
});
add(1, 2)의 결과가 3이면 테스트가 통과하고, 아니면 실패한다.
Unit Test는 단위 테스트라고 부른다.
말 그대로 가장 작은 단위의 기능을 테스트하는 것이다.
프론트엔드에서는 보통 이런 것들이 단위 테스트 대상이 된다.
유틸 함수
커스텀 훅
작은 컴포넌트
상태 변경 로직
계산 로직
조건부 렌더링
예를 들어 가격을 원화 형식으로 바꿔주는 함수가 있다고 해보자.
export const formatPrice = (price: number) => {
return `${price.toLocaleString()}원`;
};
이 함수는 다음과 같이 테스트할 수 있다.
import { describe, expect, test } from 'vitest';
import { formatPrice } from './formatPrice';
describe('formatPrice', () => {
test('숫자를 원화 형식으로 변환한다', () => {
expect(formatPrice(12000)).toBe('12,000원');
});
});
여기서 중요한 점은 작은 기능 하나만 고립해서 확인한다는 것이다.
이 테스트가 실패하면 거의 바로 알 수 있다.
가격 포맷팅 함수에 문제가 있구나.
그래서 단위 테스트는 빠르고, 실패 원인을 찾기 쉽다.
하지만 단위 테스트만으로는 실제 기능 전체가 잘 동작하는지 알기 어렵다.
함수 하나, 컴포넌트 하나는 잘 동작해도 실제 페이지에서 API 연결이나 사용자 흐름이 깨질 수 있기 때문이다.
그래서 통합 테스트가 필요하다.
통합 테스트는 여러 단위가 함께 연결되었을 때 제대로 동작하는지 확인하는 테스트다.
프론트엔드에서는 보통 다음과 같은 흐름을 테스트한다.
컴포넌트 렌더링
사용자 입력
버튼 클릭
API 요청
응답에 따른 UI 변경
예를 들어 검색 기능이 있다고 해보자.
단위 테스트에서는 이런 것들을 볼 수 있다.
검색어 상태가 잘 바뀌는가?
검색 버튼 컴포넌트가 잘 렌더링되는가?
하지만 통합 테스트에서는 실제 사용자 흐름에 더 가깝게 본다.
사용자가 검색어를 입력한다.
검색 버튼을 누른다.
검색 API 요청이 발생한다.
검색 결과가 화면에 나타난다.
즉, 통합 테스트는 이런 질문에 답하는 테스트다.
이 기능이 실제 사용자 입장에서 제대로 동작하는가?
프론트엔드 테스트에서는 단순히 함수의 반환값만 확인하는 것이 아니라, 사용자가 보는 화면과 행동 흐름을 기준으로 테스트하는 경우가 많다.
Vitest는 테스트를 실행해주는 도구다.
쉽게 말하면 테스트를 돌리는 엔진 같은 역할을 한다.
Vitest가 해주는 일은 다음과 같다.
테스트 파일 찾기
테스트 실행하기
성공 / 실패 결과 알려주기
test, describe, expect 같은 문법 제공하기
mock 함수 제공하기
React + Vite 프로젝트에서는 Jest 대신 Vitest를 사용하는 경우가 많다.
Vite 기반 프로젝트와 잘 맞고, 설정도 비교적 간단하기 때문이다.
Vitest의 기본 문법은 다음과 같다.
import { describe, expect, test } from 'vitest';
describe('계산 테스트', () => {
test('1 + 2는 3이다', () => {
expect(1 + 2).toBe(3);
});
});
각각의 의미는 다음과 같다.
describe: 관련 테스트들을 묶는 그룹
test 또는 it: 실제 테스트 하나
expect: 결과를 검증하는 함수
toBe, toEqual, toHaveTextContent: 기대값과 비교하는 matcher
Vitest는 테스트의 종류라기보다는, 테스트를 실행하는 기반 도구라고 이해하면 된다.
즉, 유틸 함수 테스트, 컴포넌트 테스트, 통합 테스트 모두 Vitest 위에서 실행될 수 있다.
React Testing Library는 React 컴포넌트를 테스트할 때 사용하는 도구다.
Vitest가 테스트를 실행하는 도구라면, React Testing Library는 컴포넌트를 화면에 렌더링하고, 사용자의 행동처럼 테스트할 수 있게 도와주는 도구다.
예를 들어 버튼 컴포넌트가 있다고 해보자.
interface ButtonProps {
onClick: () => void;
}
const Button = ({ onClick }: ButtonProps) => {
return <button onClick={onClick}>저장</button>;
};
export default Button;
이 버튼은 onClick 함수를 props로 받아서, 사용자가 버튼을 클릭했을 때 실행한다.
이 버튼을 테스트하면 다음과 같이 작성할 수 있다.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import Button from './Button';
test('저장 버튼을 클릭하면 onClick이 실행된다', async () => {
// 호출 여부를 기록할 수 있는 가짜 함수
const handleClick = vi.fn();
// Button 컴포넌트를 테스트 화면에 렌더링한다.
// 이때 onClick props로 handleClick을 넘긴다.
render(<Button onClick={handleClick} />);
// 화면에서 사용자가 보는 기준으로 "저장" 버튼을 찾는다.
const button = screen.getByRole('button', { name: '저장' });
// 사용자가 버튼을 클릭한 것처럼 동작시킨다.
await userEvent.click(button);
// 버튼 클릭 후 handleClick 함수가 호출되었는지 확인한다.
expect(handleClick).toHaveBeenCalled();
});
즉, 이 테스트는 “저장 버튼을 클릭하면 onClick으로 넘긴 함수가 실행되는가?”를 확인하는 테스트다.
여기서 중요한 점은 className이나 내부 state 이름을 검사하지 않는다는 것이다.
예를 들어 이런 방식은 좋지 않은 테스트에 가깝다.
expect(container.querySelector('.save-button')).toBeTruthy();
왜냐하면 사용자는 .save-button이라는 클래스 이름을 보지 않기 때문이다.
더 좋은 방식은 사용자가 실제로 보는 요소를 기준으로 찾는 것이다.
screen.getByRole('button', { name: '저장' });
사용자는 “저장”이라고 적힌 버튼을 본다.
그래서 React Testing Library는 내부 구현보다 사용자에게 보이는 결과를 중심으로 테스트하게 도와준다.
MSW는 Mock Service Worker의 줄임말이다.
프론트엔드 테스트에서 API 요청을 실제 서버로 보내면 여러 문제가 생길 수 있다.
서버가 꺼져 있으면 테스트가 실패한다.
네트워크 상태에 따라 테스트 속도가 달라진다.
DB 데이터 상태에 따라 결과가 달라질 수 있다.
백엔드 API가 아직 완성되지 않으면 테스트하기 어렵다.
그래서 테스트에서는 실제 서버에 요청하지 않고, MSW를 사용해 API 응답을 가짜로 만들어둔다.
예를 들어 게시글 목록 API가 있다고 해보자.
GET /posts
MSW로는 다음과 같이 가짜 응답을 만들 수 있다.
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/posts', () => {
return HttpResponse.json([
{ id: 1, title: '첫 번째 게시글' },
{ id: 2, title: '두 번째 게시글' },
]);
}),
];
이렇게 설정해두면 테스트 중에 /posts 요청이 발생했을 때 실제 서버로 가지 않고, MSW가 요청을 가로채서 위 데이터를 응답해준다.
즉, MSW를 사용하면 API가 있는 기능도 안정적으로 테스트할 수 있다.
처음에는 이 세 가지가 각각 따로 느껴졌는데, 역할을 나눠보면 훨씬 이해하기 쉽다.
Vitest
→ 테스트를 실행하는 도구
React Testing Library
→ React 컴포넌트를 사용자 관점으로 테스트하는 도구
MSW
→ API 요청을 실제 서버 없이 테스트할 수 있게 가짜 응답을 제공하는 도구
예를 들어 게시글 목록 페이지를 테스트한다고 하면 흐름은 다음과 같다.
Vitest가 테스트를 실행한다.
React Testing Library가 게시글 목록 페이지를 렌더링한다.
컴포넌트 내부에서 GET /posts 요청이 발생한다.
MSW가 요청을 가로채서 가짜 게시글 데이터를 응답한다.
응답 데이터가 화면에 렌더링된다.
React Testing Library로 화면에 게시글 제목이 보이는지 확인한다.
정리하면 이런 흐름이다.
테스트 시작
↓
컴포넌트 렌더링
↓
API 요청 발생
↓
MSW가 가짜 응답 반환
↓
응답 데이터가 화면에 렌더링
↓
화면에 기대한 내용이 있는지 확인
게시글 목록 컴포넌트가 있다고 해보자.
import { useEffect, useState } from 'react';
interface Post {
id: number;
title: string;
}
const PostList = () => {
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
fetch('/posts')
.then((res) => res.json())
.then((data) => setPosts(data));
}, []);
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
};
export default PostList;
이 컴포넌트는 렌더링되자마자 /posts API를 호출하고, 응답받은 게시글을 화면에 보여준다.
테스트는 다음과 같이 작성할 수 있다.
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import PostList from './PostList';
test('게시글 목록을 불러와 화면에 보여준다', async () => {
render(<PostList />);
expect(await screen.findByText('첫 번째 게시글')).toBeInTheDocument();
expect(await screen.findByText('두 번째 게시글')).toBeInTheDocument();
});
여기서 findByText를 사용하는 이유는 게시글 데이터가 API 응답 이후에 화면에 나타나기 때문이다.
즉, 처음 렌더링되는 순간에는 게시글이 없고, 비동기 요청이 끝난 뒤 화면에 나타난다.
그래서 await screen.findByText()로 요소가 나타날 때까지 기다린다.
React Testing Library를 공부할 때 getBy, findBy, queryBy의 차이를 아는 것이 중요하다.
getBy는 지금 당장 화면에 있어야 하는 요소를 찾을 때 사용한다.
screen.getByText('로그인');
요소가 없으면 바로 에러가 발생한다.
그래서 처음부터 화면에 보여야 하는 요소를 확인할 때 적합하다.
로그인 버튼이 처음부터 보여야 한다.
제목이 바로 렌더링되어야 한다.
findBy는 비동기적으로 나중에 나타나는 요소를 찾을 때 사용한다.
await screen.findByText('검색 결과');
API 요청 후 화면에 나타나는 데이터나, 특정 사용자 행동 이후 렌더링되는 요소를 확인할 때 적합하다.
로딩 후 게시글이 나타난다.
검색 후 결과가 나타난다.
로그인 성공 후 사용자 이름이 나타난다.
queryBy는 요소가 없어야 하는지 확인할 때 사용한다.
expect(screen.queryByText('에러')).not.toBeInTheDocument();
getByText는 요소가 없으면 바로 에러를 던지기 때문에, “없는지 확인”할 때는 queryBy를 사용하는 것이 좋다.
테스트는 내부 구현보다 결과를 확인하는 것이 좋다.
예를 들어 이런 테스트는 구현 방식에 너무 의존한다.
state가 true로 바뀌었는가?
setIsOpen이 호출되었는가?
특정 className이 붙었는가?
물론 경우에 따라 필요할 수도 있지만, 프론트엔드 테스트에서는 보통 사용자가 보는 결과를 기준으로 작성하는 것이 더 좋다.
예를 들어 다음과 같은 테스트가 더 자연스럽다.
버튼을 클릭하면 모달이 열린다.
입력창에 값을 입력하면 화면에 반영된다.
API 요청 성공 시 게시글 목록이 보인다.
API 요청 실패 시 에러 메시지가 보인다.
즉, 테스트를 작성할 때는 이렇게 생각하면 된다.
사용자가 어떤 행동을 했을 때, 화면이 어떻게 바뀌어야 하는가?
예를 들면 다음과 같다.
❌ isOpen state가 true인지 확인한다.
⭕ 버튼을 클릭했을 때 모달 텍스트가 화면에 보이는지 확인한다.
❌ 내부 함수가 호출되었는지만 확인한다.
⭕ 제출 버튼을 눌렀을 때 성공 메시지가 보이는지 확인한다.
❌ 특정 className이 있는지만 확인한다.
⭕ 사용자가 볼 수 있는 텍스트나 역할을 기준으로 확인한다.
이번 주차에서 공부할 내용은 크게 다음과 같이 정리할 수 있다.
Unit Test
→ 작은 함수나 컴포넌트 하나가 잘 동작하는지 확인하는 테스트
통합 테스트
→ 여러 코드가 연결된 실제 기능 흐름이 잘 동작하는지 확인하는 테스트
Vitest
→ 테스트를 실행하는 도구
React Testing Library
→ React 컴포넌트를 사용자 관점에서 테스트하는 도구
MSW
→ API 요청을 실제 서버 없이 가짜 응답으로 테스트하게 해주는 도구
결국 프론트엔드 테스트의 목적은 단순히 코드 줄을 많이 검사하는 것이 아니다.
내가 만든 기능이 실제 사용자 입장에서 의도대로 동작하는지 확인하고, 이후에 코드를 수정하더라도 기존 기능이 깨지지 않았다는 확신을 얻기 위한 것이다.
처음에는 테스트 코드가 낯설고 어렵게 느껴지지만, 기준을 이렇게 잡으면 조금 더 이해하기 쉬워진다.
내부 구현을 테스트하기보다, 사용자가 보는 화면과 행동 흐름을 테스트한다.
이 관점으로 보면 Unit Test, 통합 테스트, Vitest, React Testing Library, MSW가 각각 왜 필요한지 조금씩 연결된다.