3편에서 Vitest로 함수와 컴포넌트를 개별적으로 테스트하는 방법을 배웠다. 하지만 현실의 기능은 단독으로 동작하지 않는다. 컴포넌트가 API를 호출하고, 응답을 받아서 화면을 그리고, 에러가 나면 에러 메시지를 보여준다. 이런 연결 지점에서 버그가 가장 많이 발생한다.
통합 테스트는 여러 모듈이 합쳐졌을 때 의도한 대로 동작하는지를 확인한다. 이번 편에서는 MSW(Mock Service Worker)를 활용해서 API 응답을 가짜로 만들고, 컴포넌트가 그 응답을 잘 처리하는지 검증하는 방법을 다룬다.
단위 테스트와 뭐가 다른가
차이를 명확히 이해하기 위해 로그인 기능을 예로 들어보자.
단위 테스트에서는 이메일 유효성 검사 함수가 올바른 이메일은 true를, 잘못된 이메일은 false를 반환하는지 확인한다. 함수 하나의 동작만 본다.
통합 테스트에서는 사용자가 이메일과 비밀번호를 입력하고 로그인 버튼을 누르면, API가 호출되고, 성공 시 환영 메시지가 표시되는 전체 흐름을 확인한다. 컴포넌트, API 호출, 상태 변경, 화면 업데이트가 모두 맞물려 동작하는지를 보는 것이다.
핵심적인 차이는 “외부 의존성과의 연결”이다. 단위 테스트는 외부를 차단하고 내부만 본다. 통합 테스트는 외부와의 연결까지 포함해서 본다. 다만 실제 서버를 띄우는 것이 아니라, 가짜 응답을 만들어서 테스트한다. 이때 쓰는 도구가 MSW다.
MSW란
MSW(Mock Service Worker)는 네트워크 요청을 가로채서 가짜 응답을 반환하는 도구다. 실제 서버가 없어도 API 호출이 포함된 코드를 테스트할 수 있다.
기존의 API 모킹 방식은 fetch나 axios 자체를 덮어쓰는 방식이었다. 이 방식은 실제 코드의 동작과 거리가 있고, 모킹 설정도 복잡했다. MSW는 네트워크 레벨에서 요청을 가로채기 때문에, 코드 입장에서는 진짜 API를 호출하는 것과 차이가 없다. fetch를 쓰든 axios를 쓰든 상관없이 동작한다.
설치 및 설정
npm install -D msw
먼저 API 핸들러를 정의한다. 어떤 요청에 어떤 응답을 줄지를 설정하는 파일이다.
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// GET /api/user 요청에 대한 가짜 응답
http.get('/api/user', () => {
return HttpResponse.json({
id: 1,
name: '홍길동',
email: 'hong@example.com',
});
}),
// POST /api/login 요청에 대한 가짜 응답
http.post('/api/login', async ({ request }) => {
const body = await request.json() as { email: string; password: string };
if (body.email === 'test@test.com' && body.password === '1234') {
return HttpResponse.json({ success: true, token: 'fake-token' });
}
return HttpResponse.json(
{ success: false, message: '이메일 또는 비밀번호가 틀렸습니다' },
{ status: 401 },
);
}),
];
이 핸들러를 읽어보면 구조가 직관적이다. http.get('/api/user', ...)는 “GET /api/user 요청이 오면 이 응답을 줘라”라는 의미다. 실제 API의 응답 형태와 동일하게 만들면 된다.
다음으로 테스트용 서버를 설정한다.
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
Vitest 설정 파일에서 이 서버를 연결한다.
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { server } from './src/mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
beforeAll에서 서버를 시작하고, afterEach에서 핸들러를 초기화하고, afterAll에서 서버를 종료한다. 테스트 간에 상태가 오염되지 않도록 매 테스트 후 핸들러를 리셋하는 것이 중요하다.
실전: 유저 프로필 컴포넌트 테스트
API에서 유저 정보를 가져와 보여주는 컴포넌트를 만들고 테스트해보자.
// src/components/UserProfile.tsx
'use client';
import { useEffect, useState } from 'react';
interface User {
id: number;
name: string;
email: string;
}
export default function UserProfile() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch('/api/user')
.then((res) => {
if (!res.ok) throw new Error('유저 정보를 불러올 수 없습니다');
return res.json();
})
.then((data) => setUser(data))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <p>로딩 중...</p>;
if (error) return <p>{error}</p>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
이 컴포넌트의 통합 테스트를 작성한다.
// src/components/UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import UserProfile from './UserProfile';
describe('UserProfile', () => {
test('유저 정보를 불러와서 표시한다', async () => {
render(<UserProfile />);
// 처음에는 로딩 상태
expect(screen.getByText('로딩 중...')).toBeInTheDocument();
// API 응답 후 유저 정보가 표시됨
expect(await screen.findByText('홍길동')).toBeInTheDocument();
expect(screen.getByText('hong@example.com')).toBeInTheDocument();
});
test('API 에러 시 에러 메시지를 표시한다', async () => {
// 이 테스트에서만 핸들러를 덮어써서 에러 응답을 반환
server.use(
http.get('/api/user', () => {
return HttpResponse.json(null, { status: 500 });
}),
);
render(<UserProfile />);
expect(
await screen.findByText('유저 정보를 불러올 수 없습니다'),
).toBeInTheDocument();
});
});
여기서 주목할 부분이 몇 가지 있다.
findByText는 getByText와 달리 비동기로 동작한다. API 응답이 올 때까지 기다렸다가 해당 텍스트가 나타나면 통과한다. API 호출이 포함된 테스트에서는 findByText를 써야 한다.
두 번째 테스트에서 server.use()로 핸들러를 덮어쓴 것도 중요하다. 기본 핸들러는 성공 응답을 주지만, 에러 케이스를 테스트하고 싶을 때는 해당 테스트 안에서만 핸들러를 바꿀 수 있다. afterEach에서 resetHandlers()를 호출하기 때문에 다른 테스트에는 영향을 주지 않는다.
실전: 로그인 폼 테스트
조금 더 복잡한 예시로, 사용자 입력과 API 호출이 결합된 로그인 폼을 테스트해보자.
// src/components/LoginForm.tsx
'use client';
import { useState } from 'react';
export default function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState('');
const handleSubmit = async () => {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (data.success) {
setMessage('로그인 성공');
} else {
setMessage(data.message);
}
};
return (
<div>
<label>
이메일
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<label>
비밀번호
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button onClick={handleSubmit}>로그인</button>
{message && <p>{message}</p>}
</div>
);
}
// src/components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
test('올바른 정보로 로그인하면 성공 메시지가 표시된다', async () => {
render(<LoginForm />);
await userEvent.type(screen.getByLabelText('이메일'), 'test@test.com');
await userEvent.type(screen.getByLabelText('비밀번호'), '1234');
await userEvent.click(screen.getByRole('button', { name: '로그인' }));
expect(await screen.findByText('로그인 성공')).toBeInTheDocument();
});
test('틀린 정보로 로그인하면 에러 메시지가 표시된다', async () => {
render(<LoginForm />);
await userEvent.type(screen.getByLabelText('이메일'), 'wrong@test.com');
await userEvent.type(screen.getByLabelText('비밀번호'), 'wrong');
await userEvent.click(screen.getByRole('button', { name: '로그인' }));
expect(
await screen.findByText('이메일 또는 비밀번호가 틀렸습니다'),
).toBeInTheDocument();
});
});
이 테스트가 검증하는 것은 단순히 컴포넌트의 렌더링이 아니다. 사용자 입력 → API 호출 → 응답 처리 → 화면 업데이트라는 전체 흐름이 하나로 맞물려 동작하는지를 확인하고 있다. 이것이 통합 테스트의 핵심이다.
Next.js Route Handler 테스트
프론트엔드 컴포넌트뿐 아니라 Next.js의 API Route Handler도 테스트할 수 있다. Route Handler는 서버에서 실행되는 함수이므로 MSW 없이 직접 호출하면 된다.
// src/app/api/products/route.ts
import { NextResponse } from 'next/server';
const products = [
{ id: 1, name: '키보드', price: 50000 },
{ id: 2, name: '마우스', price: 30000 },
];
export async function GET() {
return NextResponse.json(products);
}
export async function POST(request: Request) {
const body = await request.json();
if (!body.name || !body.price) {
return NextResponse.json(
{ error: '이름과 가격은 필수입니다' },
{ status: 400 },
);
}
const newProduct = { id: products.length + 1, ...body };
products.push(newProduct);
return NextResponse.json(newProduct, { status: 201 });
}
// src/app/api/products/route.test.ts
import { GET, POST } from './route';
describe('/api/products', () => {
test('GET: 상품 목록을 반환한다', async () => {
const res = await GET();
const data = await res.json();
expect(res.status).toBe(200);
expect(data).toHaveLength(2);
expect(data[0].name).toBe('키보드');
});
test('POST: 새 상품을 추가한다', async () => {
const req = new Request('http://localhost/api/products', {
method: 'POST',
body: JSON.stringify({ name: '모니터', price: 300000 }),
});
const res = await POST(req);
const data = await res.json();
expect(res.status).toBe(201);
expect(data.name).toBe('모니터');
});
test('POST: 필수 필드가 없으면 400을 반환한다', async () => {
const req = new Request('http://localhost/api/products', {
method: 'POST',
body: JSON.stringify({ name: '모니터' }),
});
const res = await POST(req);
expect(res.status).toBe(400);
});
});
Route Handler는 결국 Request를 받아서 Response를 반환하는 함수이므로, 직접 호출해서 반환값을 검증하면 된다. 프론트엔드와 백엔드를 모두 Next.js로 작성하고 있다면, 이 방식으로 API 로직도 테스트 커버리지에 포함시킬 수 있다.
핸들러 관리 팁
프로젝트가 커지면 핸들러도 많아진다. 도메인별로 파일을 분리하면 관리가 편해진다.
src/mocks/
handlers/
user.ts ← 유저 관련 API
product.ts ← 상품 관련 API
auth.ts ← 인증 관련 API
index.ts ← 전체 핸들러를 합침
server.ts
// src/mocks/handlers/index.ts
import { userHandlers } from './user';
import { productHandlers } from './product';
import { authHandlers } from './auth';
export const handlers = [
...userHandlers,
...productHandlers,
...authHandlers,
];
핸들러를 잘 만들어두면 테스트뿐 아니라 개발 중에도 활용할 수 있다. 백엔드 API가 아직 준비되지 않았을 때, MSW를 브라우저 모드로 띄워서 프론트엔드 개발을 먼저 진행하는 것도 가능하다.
통합 테스트의 한계
통합 테스트는 모듈 간의 연결을 검증하지만, 여전히 가짜 환경에서 실행된다. 브라우저의 라우팅, 실제 네트워크 지연, 쿠키와 세션 처리, 다양한 브라우저 호환성 같은 것들은 통합 테스트로는 확인하기 어렵다.
“실제 사용자가 실제 브라우저에서 겪는 경험”을 테스트하려면 E2E 테스트가 필요하다.
다음 편 예고
통합 테스트로 컴포넌트와 API의 연동을 검증하는 방법을 배웠다. 다음 편에서는 Playwright를 사용해서 실제 브라우저를 띄우고, 사용자 시나리오 전체를 처음부터 끝까지 테스트하는 E2E 테스트를 다룬다.