통합 테스트 작성
상품 목록의 클라이언트 상태와 상호작용 경계를 Jest로 검증하고, 실제 Server Component 연결은 E2E로 확인하는 기준을 익힙니다.
소프트웨어 테스팅은 단위 테스트만으로는 충분하지 않습니다.
애플리케이션의 여러 부분이 함께 작동할 때 발생하는 문제를 발견하고 시스템 전체의 흐름을 검증하기 위해 통합 테스트(Integration Testing)가 필수적입니다.
통합 테스트는 개별적으로 잘 작동하는 단위들이 모여 올바르게 연동되는지를 확인하는 데 중점을 둡니다.
이 절에서는 통합 테스트의 개념과 중요성, Next.js 프로젝트에서 통합 테스트를 작성하는 방법, 특히 App Router 환경에서의 테스트 전략, 그리고 @testing-library/react와 Jest를 활용하여 실제 사용자 흐름을 모방한 테스트를 작성하는 방법에 대해 상세히 알아보겠습니다.
먼저 통합 테스트가 단위 테스트와 달리 어떤 연결 경계를 확인하는지 정리합니다.
통합 테스트란 무엇이며 왜 중요한가요?
통합 테스트는 애플리케이션의 여러 모듈, 컴포넌트, 또는 서비스가 결합되어 함께 작동할 때 발생하는 상호작용 문제를 검증하는 테스트 방식입니다.
단위 테스트가 개별 코드 조각의 정확성을 보장한다면, 통합 테스트는 이 조각들이 연결되어 더 큰 기능 단위를 형성할 때의 올바른 동작을 확인합니다.
통합 테스트의 중요성- 모듈 간 상호작용 검증: 서로 다른 컴포넌트나 서비스 간의 데이터 흐름, 이벤트 전달, API 호출 등이 올바르게 이루어지는지 확인합니다.
- 엔드-투-엔드(End-to-End) 시나리오 반영: 사용자 인터페이스(UI)를 통해 입력이 주어지고, 백엔드와의 통신을 포함하여 전체 시스템이 예상대로 반응하는지 테스트할 수 있습니다.
- 시스템 전반의 안정성 확보: 단위 테스트로는 발견하기 어려운 시스템 레벨의 버그(예: 데이터 타입 불일치, API 응답 형식 문제)를 발견합니다.
- 배포 전 최종 검증: 실제 운영 환경과 유사한 조건에서 애플리케이션의 핵심 기능을 검증함으로써, 배포 후 발생할 수 있는 치명적인 문제를 예방합니다.
Next.js App Router에서의 통합 테스트 전략
Next.js App Router는 Server Components, Client Components, Data Fetching 등 다양한 개념을 도입하면서 통합 테스트 전략에도 변화가 필요합니다.
- 클라이언트 컴포넌트 중심의 통합 테스트:
@testing-library/react를 사용하여 사용자 상호작용이 발생하는 클라이언트 컴포넌트와 그 자식 컴포넌트들의 통합을 테스트합니다. 이는 사용자가 보는 화면과 직접적으로 상호작용하는 부분을 검증하는 데 효과적입니다. - 모킹 활용: API 호출, 데이터베이스 접근 등 외부 의존성은 실제 백엔드를 호출하기보다는 모킹(Mocking)하여 테스트의 독립성과 속도를 확보합니다. Mock Service Worker (MSW) 같은 라이브러리는 네트워크 요청 레벨에서 모킹을 수행하여 실제와 유사한 환경을 제공합니다.
- Server Components의 제한: 현재 Jest는
asyncServer Component를 지원하지 않습니다. 서버의 데이터 함수는 단위 테스트하고, 클라이언트 경계는 React Testing Library로 검증하며, 둘이 합쳐진 실제 페이지는 E2E 테스트로 확인합니다. - 데이터 페칭 통합 테스트:
fetch요청을 모킹하여 컴포넌트가 데이터를 가져와 렌더링하는 과정을 테스트합니다.
통합 테스트 작성 예시
다음 다이어그램은 상품 검색 예시에서 통합 테스트가 어떤 사용자 흐름을 따라가는지 보여줍니다.
간단한 상품 목록 및 검색 기능을 가진 페이지를 통합 테스트하는 시나리오를 구성해 보겠습니다.
시나리오상품 목록 페이지에 접속한다.
상품 목록이 로드되어 화면에 표시되는 것을 확인한다.
검색창에 검색어를 입력한다.
검색 버튼을 클릭한다.
검색어에 해당하는 상품만 필터링되어 표시되는 것을 확인한다.
필요한 컴포넌트 및 API 모듈
먼저 테스트할 컴포넌트와 모킹할 API 함수를 정의합니다.
export interface Product {
id: string;
name: string;
price: number;
}"use client";
import React, { useState } from 'react';
import type { Product } from '@/types/product';
interface ProductListProps {
initialProducts: Product[];
fetchProductsAction: (query?: string) => Promise<Product[]>;
}
export default function ProductList({ initialProducts, fetchProductsAction }: ProductListProps) {
const [products, setProducts] = useState<Product[]>(initialProducts);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const handleSearch = async () => {
setLoading(true);
try {
const fetchedProducts = await fetchProductsAction(searchQuery);
setProducts(fetchedProducts);
} catch (error) {
console.error("Failed to fetch products:", error);
setProducts([]); // 에러 시 빈 배열 또는 에러 메시지
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2 style={{ marginBottom: '20px' }}>상품 목록</h2>
<div style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}>
<input
type="text"
placeholder="상품 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ flexGrow: 1, padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
<button
onClick={handleSearch}
disabled={loading}
style={{ padding: '8px 15px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
>
{loading ? '검색 중...' : '검색'}
</button>
</div>
{products.length === 0 && !loading && (
<p style={{ textAlign: 'center', color: '#888' }}>상품이 없습니다.</p>
)}
<ul>
{products.map(product => (
<li key={product.id} style={{ marginBottom: '10px', padding: '10px', border: '1px solid #eee', borderRadius: '4px' }}>
{product.name} - {product.price.toLocaleString()}원
</li>
))}
</ul>
</div>
);
}"use server";
import type { Product } from '@/types/product';
// 이 함수는 테스트에서 모킹될 것입니다.
export async function getProductsApi(query?: string): Promise<Product[]> {
// 실제 서버의 데이터베이스나 외부 API 호출 로직
console.log(`API 호출: 상품 조회, 쿼리: ${query || '없음'}`);
return new Promise(resolve => {
setTimeout(() => {
const allProducts = [
{ id: 'p1', name: '노트북', price: 1500000 },
{ id: 'p2', name: '마우스', price: 30000 },
{ id: 'p3', name: '키보드', price: 80000 },
{ id: 'p4', name: '모니터', price: 300000 },
];
if (query) {
resolve(allProducts.filter(p => p.name.includes(query) || p.id.includes(query)));
} else {
resolve(allProducts);
}
}, 500); // 네트워크 지연 시뮬레이션
});
}// 이 페이지는 ProductList 컴포넌트를 렌더링하고 초기 데이터를 전달합니다.
// Server Component에서 API를 호출하고 Client Component로 props를 전달하는 일반적인 패턴입니다.
import { getProductsApi } from '@/lib/api'; // 서버에서 사용할 API
import ProductList from '@/components/ProductList'; // 클라이언트 컴포넌트
export default async function ProductsPage() {
const initialProducts = await getProductsApi(); // 서버에서 초기 데이터 페칭
return (
<div style={{ maxWidth: '900px', margin: '40px auto', padding: '20px', border: '1px solid #28a745', borderRadius: '10px', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }}>
<h1 style={{ color: '#28a745', textAlign: 'center', marginBottom: '30px' }}>모든 상품</h1>
{/* 명시적으로 선언한 Server Function만 Client Component에 전달할 수 있습니다. */}
<ProductList initialProducts={initialProducts} fetchProductsAction={getProductsApi} />
</div>
);
}일반 함수는 Server Component에서 Client Component의 props로 전달할 수 없습니다.
이 예제의 getProductsApi는 파일 상단의 "use server"로 명시한 Server Function이므로 클라이언트에서 호출 가능한 참조로 전달됩니다.
클라이언트 경계 테스트 작성
현재 Jest는 async Server Component를 직접 지원하지 않습니다.
따라서 ProductsPage 전체 흐름은 다음 절의 Cypress E2E로 확인하고, 여기서는 Jest와 @testing-library/react로 상호작용 경계인 ProductList를 테스트합니다.
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import ProductList from '../src/components/ProductList';
const initialProducts = [
{ id: 'p1', name: '노트북', price: 1500000 },
{ id: 'p2', name: '마우스', price: 30000 },
];
describe('ProductList', () => {
test('초기 상품 목록이 로드되고 렌더링되어야 합니다.', async () => {
const fetchProductsAction = jest.fn();
render(<ProductList initialProducts={initialProducts} fetchProductsAction={fetchProductsAction} />);
// "상품 목록" 제목이 있는지 확인
expect(screen.getByRole('heading', { name: '상품 목록' })).toBeInTheDocument();
// 초기 상품들이 로드될 때까지 기다립니다.
await waitFor(() => {
expect(screen.getByText('노트북 - 1,500,000원')).toBeInTheDocument();
expect(screen.getByText('마우스 - 30,000원')).toBeInTheDocument();
});
expect(fetchProductsAction).not.toHaveBeenCalled();
});
test('검색 기능이 올바르게 작동해야 합니다.', async () => {
const fetchProductsAction = jest.fn().mockResolvedValueOnce([
{ id: 'p3', name: '키보드', price: 80000 },
]);
render(<ProductList initialProducts={initialProducts} fetchProductsAction={fetchProductsAction} />);
// 검색 입력 필드 찾기 (placeholder 텍스트로)
const searchInput = screen.getByPlaceholderText('상품 검색...');
fireEvent.change(searchInput, { target: { value: '키보드' } }); // 검색어 입력
// 검색 버튼 찾기 (텍스트로)
const searchButton = screen.getByRole('button', { name: '검색' });
fireEvent.click(searchButton); // 검색 버튼 클릭
// "검색 중..." 텍스트가 나타나는지 확인
expect(screen.getByRole('button', { name: '검색 중...' })).toBeDisabled();
// 검색 결과가 로드될 때까지 기다립니다.
await waitFor(() => {
expect(screen.getByText('키보드 - 80,000원')).toBeInTheDocument();
});
// 이전에 있던 상품들이 사라졌는지 확인 (필터링 검증)
expect(screen.queryByText('노트북 - 1,500,000원')).not.toBeInTheDocument();
expect(screen.queryByText('마우스 - 30,000원')).not.toBeInTheDocument();
expect(fetchProductsAction).toHaveBeenCalledTimes(1);
expect(fetchProductsAction).toHaveBeenCalledWith('키보드');
});
test('검색 결과가 없을 때 "상품이 없습니다." 메시지를 표시해야 합니다.', async () => {
const fetchProductsAction = jest.fn().mockResolvedValueOnce([]);
render(<ProductList initialProducts={initialProducts} fetchProductsAction={fetchProductsAction} />);
const searchInput = screen.getByPlaceholderText('상품 검색...');
fireEvent.change(searchInput, { target: { value: '존재하지않는상품' } });
const searchButton = screen.getByRole('button', { name: '검색' });
fireEvent.click(searchButton);
await waitFor(() => {
expect(screen.getByText('상품이 없습니다.')).toBeInTheDocument();
});
expect(screen.queryByText('노트북 - 1,500,000원')).not.toBeInTheDocument();
});
});render(<ProductList ... />): Jest가 지원하는 클라이언트 경계만 직접 렌더링하고, 서버에서 내려올 초기 데이터와 검색 함수를 props 대역으로 주입합니다.
jest.fn(): Server Function의 네트워크 실행을 흉내 내는 비동기 Mock을 테스트마다 새로 만들어 호출 인자와 UI 결과를 함께 검증합니다.
mockResolvedValue, mockResolvedValueOnce: Mock 함수가 비동기적으로 특정 값을 반환하도록 설정합니다.
mockResolvedValueOnce는 한 번만 적용됩니다.
screen.getByRole, screen.getByPlaceholderText, screen.getByText: @testing-library/react의 쿼리를 사용하여 사용자가 실제로 보는 방식으로 DOM 요소를 찾습니다.
fireEvent.change, fireEvent.click: 사용자 상호작용(입력, 클릭 등)을 시뮬레이션합니다.
await waitFor(() => { ... }): 비동기 작업(데이터 로딩, UI 업데이트)이 완료될 때까지 기다립니다.
expect 문이 성공할 때까지 콜백 함수를 주기적으로 재실행합니다.
이는 네트워크 요청이나 useEffect 내의 비동기 로직이 완료될 때까지 기다리는 데 필수적입니다.
toBeInTheDocument(), toBeDisabled(): @testing-library/jest-dom에서 제공하는 매처로, DOM 요소의 특정 상태를 검증합니다.
통합 테스트 작성 시 고려사항 및 팁
- 진정한 통합에 초점: 단위 테스트에서 이미 검증된 개별 로직을 다시 테스트하기보다는, 여러 모듈이 상호작용하는 지점(예: 컴포넌트 간의
props전달, API 호출 후 데이터 처리, 사용자 입력에 따른 UI 변화)에 초점을 맞춥니다. - 실제 사용자 시나리오 모방: 사용자가 애플리케이션을 사용하는 방식과 유사하게 테스트를 작성합니다. 이 버튼을 클릭하면 무엇이 일어나야 하는가? 와 같은 질문에 답하는 방식으로 테스트를 설계합니다.
- Mock Service Worker (MSW) 활용: 백엔드 API를 모킹해야 할 경우, MSW는 네트워크 레벨에서 요청을 가로채서 Mock 응답을 반환하므로, 실제 백엔드가 없는 환경에서도 통합 테스트를 작성하고 개발할 수 있게 해줍니다. 이는 Jest의 Mock 함수보다 더 강력하고 현실적인 모킹 환경을 제공합니다.
- 테스트 환경 일관성: 개발, 테스트, 운영 환경 간에 설정(환경 변수, API 엔드포인트)의 일관성을 유지하여 테스트 통과 후 실제 배포 시 문제가 발생하지 않도록 합니다.
- 테스트 피라미드: 일반적으로 단위 테스트의 개수가 가장 많고, 통합 테스트, 그리고 엔드-투-엔드 테스트(Cypress, Playwright 등) 순으로 개수가 줄어드는 '테스트 피라미드' 전략을 따르는 것이 좋습니다. 단위 테스트는 빠르고 격리되어 있으며, 통합 테스트는 여러 부분을 연결하고, E2E 테스트는 전체 시스템을 검증합니다.
- 리팩터링 시의 안정성: 통합 테스트는 코드 리팩터링 시에도 높은 수준의 안정성을 제공합니다. 내부 구현이 변경되어도 사용자 흐름이 동일하다면 테스트가 여전히 통과되어야 합니다.
다음 다이어그램은 MSW를 사용할 때 실제 사용자 흐름과 네트워크 모킹 경계를 어떻게 연결하는지 정리한 것입니다.
다음 다이어그램은 통합 테스트에서 검증할 경계를 고르는 기준입니다.
통합 테스트는 Next.js 애플리케이션의 복잡성을 관리하고, 여러 모듈이 예상대로 함께 작동하는지 확인하는 데 필수적인 부분입니다.
적절한 전략과 도구를 사용하여 견고하고 신뢰할 수 있는 애플리케이션을 구축하세요.
아래 다이어그램은 컴포넌트 렌더링, API 모듈, 사용자 이벤트가 통합 테스트에서 함께 확인되는 지점을 보여줍니다.