통합 테스트 작성
소프트웨어 테스팅은 단위 테스트만으로는 충분하지 않습니다. 애플리케이션의 여러 부분이 함께 작동할 때 발생하는 문제를 발견하고 시스템 전체의 흐름을 검증하기 위해 통합 테스트(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의 제한: Server Components는 서버에서 렌더링되므로
@testing-library/react
와 같은 클라이언트 렌더링 기반의 테스트 라이브러리로는 직접적인 UI 상호작용 테스트가 어렵습니다. Server Components에서 가져오는 데이터는generateMetadata
나 서버 액션과 같은 함수에서 단위 테스트하고, 그 데이터가 클라이언트 컴포넌트로 올바르게 전달되는지는 클라이언트 컴포넌트의 통합 테스트에서 간접적으로 검증합니다. - 데이터 페칭 통합 테스트:
fetch
요청을 모킹하여 컴포넌트가 데이터를 가져와 렌더링하는 과정을 테스트합니다.
통합 테스트 작성 예시
간단한 상품 목록 및 검색 기능을 가진 페이지를 통합 테스트하는 시나리오를 구성해 보겠습니다.
시나리오
- 상품 목록 페이지에 접속한다.
- 상품 목록이 로드되어 화면에 표시되는 것을 확인한다.
- 검색창에 검색어를 입력한다.
- 검색 버튼을 클릭한다.
- 검색어에 해당하는 상품만 필터링되어 표시되는 것을 확인한다.
필요한 컴포넌트 및 API 모듈
먼저 테스트할 컴포넌트와 모킹할 API 함수를 정의합니다.
// components/ProductList.tsx
"use client";
import React, { useState, useEffect } from 'react';
interface Product {
id: string;
name: string;
price: number;
}
interface ProductListProps {
initialProducts: Product[];
fetchProducts: (query?: string) => Promise<Product[]>;
}
export default function ProductList({ initialProducts, fetchProducts }: 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 fetchProducts(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>
);
}
// lib/api.ts (실제 API 호출을 모방할 함수)
// 이 함수는 테스트에서 모킹될 것입니다.
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); // 네트워크 지연 시뮬레이션
});
}
// app/products/page.tsx (상품 목록 페이지 - Server Component)
// 이 페이지는 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>
{/* ProductList 컴포넌트에 초기 데이터와 API 함수를 props로 전달 */}
<ProductList initialProducts={initialProducts} fetchProducts={getProductsApi} />
</div>
);
}
통합 테스트 파일 작성
Jest와 @testing-library/react
를 사용하여 ProductsPage
의 사용자 흐름을 테스트합니다.
// __tests__/products-integration.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import ProductsPage from '../app/products/page'; // 테스트할 페이지 컴포넌트 임포트
import * as api from '../lib/api'; // API 모듈 임포트 (모킹을 위해)
// API 함수를 Mocking
// Jest.spyOn을 사용하여 실제 모듈의 함수를 감시하고 Mocking합니다.
// 이렇게 하면 해당 함수가 호출될 때 Mock 구현이 사용됩니다.
const mockGetProductsApi = jest.spyOn(api, 'getProductsApi');
describe('ProductsPage 통합 테스트', () => {
// 각 테스트 전에 Mocking을 초기화하고, Mock 구현을 정의합니다.
beforeEach(() => {
// 초기 로드 시 반환할 상품 목록
mockGetProductsApi.mockResolvedValue([
{ id: 'p1', name: '노트북', price: 1500000 },
{ id: 'p2', name: '마우스', price: 30000 },
]);
});
// 각 테스트 후에 Mocking을 복원합니다.
afterEach(() => {
jest.restoreAllMocks(); // 모든 Mocking을 원래 구현으로 복원
});
test('초기 상품 목록이 로드되고 렌더링되어야 합니다.', async () => {
render(await ProductsPage()); // Server Component 렌더링
// "상품 목록" 제목이 있는지 확인
expect(screen.getByRole('heading', { name: '상품 목록' })).toBeInTheDocument();
// 초기 상품들이 로드될 때까지 기다립니다.
await waitFor(() => {
expect(screen.getByText('노트북 - 1,500,000원')).toBeInTheDocument();
expect(screen.getByText('마우스 - 30,000원')).toBeInTheDocument();
});
// API가 초기 로드를 위해 호출되었는지 확인
expect(mockGetProductsApi).toHaveBeenCalledTimes(1);
expect(mockGetProductsApi).toHaveBeenCalledWith(undefined); // 초기 호출은 쿼리 없음
});
test('검색 기능이 올바르게 작동해야 합니다.', async () => {
render(await ProductsPage()); // 페이지 렌더링
// 초기 상품이 로드될 때까지 기다림 (첫 번째 waitFor와 동일)
await waitFor(() => {
expect(screen.getByText('노트북 - 1,500,000원')).toBeInTheDocument();
});
// 검색 API의 Mock 구현을 업데이트하여 검색 시 다른 결과 반환
mockGetProductsApi.mockResolvedValueOnce([
{ id: 'p3', name: '키보드', price: 80000 },
]);
// 검색 입력 필드 찾기 (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();
// API가 검색 쿼리와 함께 다시 호출되었는지 확인
expect(mockGetProductsApi).toHaveBeenCalledTimes(2); // 초기 호출 + 검색 호출
expect(mockGetProductsApi).toHaveBeenCalledWith('키보드');
});
test('검색 결과가 없을 때 "상품이 없습니다." 메시지를 표시해야 합니다.', async () => {
render(await ProductsPage());
await waitFor(() => {
expect(screen.getByText('노트북 - 1,500,000원')).toBeInTheDocument();
});
// 검색 API의 Mock 구현을 업데이트하여 빈 배열 반환
mockGetProductsApi.mockResolvedValueOnce([]);
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(await ProductsPage())
:ProductsPage
가 Server Component이므로async
로 데이터를 페칭하여 렌더링합니다.render
함수에await
를 사용하여 모든 비동기 작업이 완료된 후 컴포넌트가 DOM에 렌더링되도록 합니다.jest.spyOn(api, 'getProductsApi')
:lib/api.ts
모듈의getProductsApi
함수를 스파이(spy)하여, 이 함수가 호출될 때 실제 구현 대신 우리가 정의한 Mock 구현이 사용되도록 합니다.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 테스트는 전체 시스템을 검증합니다.
- 리팩터링 시의 안정성: 통합 테스트는 코드 리팩터링 시에도 높은 수준의 안정성을 제공합니다. 내부 구현이 변경되어도 사용자 흐름이 동일하다면 테스트가 여전히 통과되어야 합니다.
통합 테스트는 Next.js 애플리케이션의 복잡성을 관리하고, 여러 모듈이 예상대로 함께 작동하는지 확인하는 데 필수적인 부분입니다. 적절한 전략과 도구를 사용하여 견고하고 신뢰할 수 있는 애플리케이션을 구축하세요.