통합 테스트 작성
상품 목록의 클라이언트 상태와 상호작용 경계를 Jest로 검증하고, 실제 Server Component 연결은 E2E로 확인하는 기준을 익힙니다.
소프트웨어 테스트는 단위 테스트만으로는 충분하지 않습니다. 애플리케이션의 여러 부분이 함께 동작할 때 생기는 문제를 찾고 전체 흐름을 검증하려면 통합 테스트(integration testing)가 필요합니다. 통합 테스트는 하나하나는 잘 동작하는 단위들이 모였을 때 올바르게 연동되는지를 확인하는 데 중점을 둡니다. 이 글에서는 통합 테스트의 개념과 중요성, App Router 환경의 테스트 전략, 그리고 @testing-library/react와 Jest로 실제 사용자 흐름을 흉내 내는 테스트를 작성하는 방법을 알아봅니다.
통합 테스트란 무엇이며 왜 중요한가요?
통합 테스트는 애플리케이션의 여러 모듈, 컴포넌트, 서비스가 결합되어 함께 동작할 때 생기는 상호작용 문제를 검증하는 테스트 방식입니다. 단위 테스트가 개별 코드 조각의 특정 동작을 확인한다면, 통합 테스트는 그 조각들이 이어져 더 큰 기능을 이룰 때 올바르게 동작하는지를 확인합니다.
통합 테스트는 서로 다른 컴포넌트나 서비스 사이의 데이터 흐름, 이벤트 전달, API 호출이 올바르게 이루어지는지 검증합니다. 넓게 잡으면 사용자 인터페이스(UI)로 입력을 주고 백엔드와의 통신까지 포함해 시스템 전체가 예상대로 반응하는지 확인할 수도 있습니다. 데이터 타입이 맞지 않거나 API 응답 형식이 어긋나는 것처럼 단위 테스트로는 찾기 어려운 시스템 수준의 버그를 찾고, 실제 운영 환경과 비슷한 조건에서 핵심 기능을 검증해 배포 뒤의 치명적인 문제를 예방합니다.
Next.js App Router에서의 통합 테스트 전략
App Router는 서버 컴포넌트, 클라이언트 컴포넌트, 데이터 페칭 같은 여러 개념을 도입했으므로 통합 테스트 전략도 그에 맞춰야 합니다. 먼저 @testing-library/react로 사용자 상호작용이 일어나는 클라이언트 컴포넌트와 그 자식 컴포넌트들의 통합을 테스트합니다. 사용자가 보는 화면과 직접 상호작용하는 부분을 검증하는 데 효과적입니다.
API 호출이나 데이터베이스 접근 같은 외부 의존성은 실제 백엔드를 부르기보다 모킹해 테스트의 독립성과 속도를 확보합니다. Mock Service Worker(MSW) 같은 라이브러리는 네트워크 요청 수준에서 모킹해 실제와 비슷한 환경을 제공합니다. fetch 요청을 모킹하면 컴포넌트가 데이터를 가져와 렌더링하는 과정도 테스트할 수 있습니다.
서버 컴포넌트에는 제한이 있습니다. 지금의 Jest는 async 서버 컴포넌트를 지원하지 않으므로, 서버의 데이터 함수는 단위 테스트하고, 클라이언트 컴포넌트는 React Testing Library로 검증하며, 둘이 합쳐진 실제 페이지는 E2E 테스트로 확인합니다.
통합 테스트 작성 예시
간단한 상품 목록과 검색 기능이 있는 페이지를 통합 테스트하는 시나리오를 만들어 보겠습니다. 사용자는 상품 목록 페이지에 접속해 상품 목록이 표시되는 것을 확인하고, 검색창에 검색어를 입력한 뒤 검색 버튼을 누릅니다. 그러면 검색어에 해당하는 상품만 걸러져 표시되어야 합니다.
필요한 컴포넌트 및 API 모듈
먼저 테스트할 컴포넌트와 모킹할 API 함수를 정의합니다. 상품 타입은 다음과 같습니다.
export interface Product {
id: string;
name: string;
price: number;
}ProductList는 초기 상품 배열과 검색 함수를 props로 받는 클라이언트 컴포넌트입니다. 검색 버튼을 누르면 로딩 상태를 켜고 검색 함수를 부른 뒤, 결과로 목록을 바꿉니다.
"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>
);
}ProductList는 검색 결과를 직접 계산하지 않고 props로 받은 fetchProductsAction에 맡깁니다. 그래서 테스트에서는 이 자리에 jest.fn()을 넘겨, 서버 없이도 원하는 결과를 돌려주게 만들 수 있습니다. 실제 앱에서 이 자리에 들어갈 검색 함수는 파일 맨 위에 "use server"를 둔 서버 함수(Server Function)이며, 고정된 배열과 모의 지연을 씁니다. 검색어가 있으면 이름이나 id에 그 글자가 들어간 상품만 남기고, 없으면 네 상품을 모두 돌려줍니다.
"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에 넘깁니다. 아래 페이지는 같은 getProductsApi를 두 가지로 씁니다. 렌더링 중에는 서버에서 직접 await getProductsApi()를 불러 네 상품을 초기 목록으로 받고, 같은 함수를 fetchProductsAction prop으로도 넘겨 검색 버튼을 누를 때 서버에서 다시 실행되게 합니다.
// 이 페이지는 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>
);
}일반 함수는 서버 컴포넌트에서 클라이언트 컴포넌트의 props로 넘길 수 없습니다. 이 예제의 getProductsApi는 파일 맨 위의 "use server"로 명시한 서버 함수이므로, 클라이언트에서 호출할 수 있는 참조로 전달됩니다.
클라이언트 컴포넌트 테스트 작성
지금의 Jest는 async 서버 컴포넌트를 직접 지원하지 않으므로, 여기서는 Jest와 React Testing Library로 ProductList를 테스트합니다. ProductsPage 전체의 흐름에는 별도의 E2E 시나리오가 필요합니다. 다음 글은 로그인 화면 예제로 E2E 작성법을 설명하며, 상품 페이지의 E2E는 구현하지 않습니다.
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();
});
});이 테스트는 Jest가 지원하는 클라이언트 컴포넌트만 render(<ProductList ... />)로 직접 렌더링하고, 서버에서 내려올 초기 데이터와 검색 함수를 props 대역으로 넣습니다. jest.fn()은 서버 함수의 네트워크 실행을 흉내 내는 비동기 모의 함수를 테스트마다 새로 만들어, 호출 인자와 UI 결과를 함께 검증하게 합니다. mockResolvedValue와 mockResolvedValueOnce는 모의 함수가 비동기로 특정 값을 돌려주게 설정하며, mockResolvedValueOnce는 한 번만 적용됩니다.
screen.getByRole, screen.getByPlaceholderText, screen.getByText는 사용자가 실제로 보는 방식으로 DOM 요소를 찾는 @testing-library/react의 쿼리이고, fireEvent.change와 fireEvent.click은 입력이나 클릭 같은 사용자 상호작용을 흉내 냅니다. await waitFor(() => { ... })는 데이터 로딩이나 UI 갱신 같은 비동기 작업이 끝날 때까지 기다리며, 콜백 안의 expect가 성공할 때까지 콜백을 주기적으로 다시 실행합니다. 이 예제에서는 대역 Promise 뒤의 UI 갱신을 기다리며, 요소 하나가 나타나기를 기다릴 때는 findBy 쿼리를 쓸 수도 있습니다. toBeInTheDocument()와 toBeDisabled()는 @testing-library/jest-dom이 제공하는 매처로, DOM 요소의 특정 상태를 검증합니다.
상품 검색 예제의 테스트 경계
ProductList의 초기 목록, 검색 입력, action 대역을 기다리는 pending 상태, 응답 목록 또는 빈 목록으로 교체되는 실제 UI 상태를 추적할 수 있습니다. 서버 함수·DB·async ProductsPage까지 실행한 테스트로 확장하지 않습니다.
NEXT · 14.2
상품 검색 테스트는 ProductList의 클라이언트 경계를 확인합니다
초기 상품 배열과 fetchProductsAction 대역을 props로 전달하고 입력·클릭·로딩·목록 변경을 확인합니다. async ProductsPage와 Server Function 전송은 연결하지 않습니다.
테스트가 실행하는 Client UI와 action 대역
초기 props는 initialProducts이며 검색 action은 fetchProductsAction 대역입니다. 초기 렌더는 action을 호출하지 않습니다. 대역 reject도 catch에서 빈 배열이 되어 같은 빈 결과 문구를 보여 주며 별도 오류 UI는 없습니다. pending UI·호출 인자·횟수·응답 목록을 Client 경계에서 검사하며 서버 페이지·전송·DB를 실행한 시험으로 확대하지 않습니다.
원래 설명과 상세 기준
| 경계 | 예제 코드에서 확인할 범위 |
|---|---|
| 초기 목록 | 테스트가 initialProducts prop에 배열을 전달합니다. 서버 페이지를 실행하지 않습니다. |
| 검색 요청 | 입력과 클릭은 실제 ProductList에 전달하고, fetchProductsAction prop은 jest.fn() 대역으로 바꿉니다. |
| 검색 결과 | 대역 반환값으로 목록·빈 결과·로딩 UI와 호출 인자를 확인합니다. |
| 전체 페이지 | async ProductsPage 렌더링과 Server Function 통신은 이 Jest 테스트 범위 밖입니다. |
- 초기 목록
- 예제 코드에서 확인할 범위: 테스트가 initialProducts prop에 배열을 전달합니다. 서버 페이지를 실행하지 않습니다.
- 검색 요청
- 예제 코드에서 확인할 범위: 입력과 클릭은 실제 ProductList에 전달하고, fetchProductsAction prop은 jest.fn() 대역으로 바꿉니다.
- 검색 결과
- 예제 코드에서 확인할 범위: 대역 반환값으로 목록·빈 결과·로딩 UI와 호출 인자를 확인합니다.
- 전체 페이지
- 예제 코드에서 확인할 범위: async ProductsPage 렌더링과 Server Function 통신은 이 Jest 테스트 범위 밖입니다.
초기 렌더는 검색 함수를 호출하지 않고, 검색 후에는 대역의 반환값으로 목록을 교체합니다. 빈 결과 메시지와 호출 횟수·키보드 인자를 확인합니다. 예제의 별도 서버 함수는 고정 목록과 모의 지연을 사용하며 이 Jest 테스트가 실행하는 대상은 아닙니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 초기 목록 렌더 initialProducts 배열로 실제 ProductList를 렌더합니다. | 목록: initialProducts · action 초기 호출: 없음 · 서버 page: 미실행 | Client UI의 출발 목록이 표시됩니다. |
| 입력과 검색 click 사용자 입력·click이 실제 컴포넌트에 전달되고 jest.fn action 대역을 호출합니다. | 입력: 검색어 · 호출: action 대역 · 경계: Client 테스트 | 지역 입력이 검색 요청으로 넘어갑니다. |
| pending 표시 대역 반환을 기다리는 동안 loading UI를 확인합니다. | UI: loading · 결과: 대기 · Server Function 전송: 없음 | 표시가 요청 대기 상태로 바뀝니다. |
| 응답 목록 적용 대역 반환값으로 목록을 교체하거나 빈 결과 메시지를 표시합니다. | 목록: 대역 결과 · 빈 결과: 메시지 · 검증: 호출 인자·횟수 | Client 출력이 검색 결과로 교체됩니다. |
상품 검색 예제의 테스트 경계
ProductList의 초기 목록, 검색 입력, action 대역을 기다리는 pending 상태, 응답 목록 또는 빈 목록으로 교체되는 실제 UI 상태를 추적할 수 있습니다. 서버 함수·DB·async ProductsPage까지 실행한 테스트로 확장하지 않습니다.
NEXT · 14.2
상품 검색 테스트는 ProductList의 클라이언트 경계를 확인합니다
초기 상품 배열과 fetchProductsAction 대역을 props로 전달하고 입력·클릭·로딩·목록 변경을 확인합니다. async ProductsPage와 Server Function 전송은 연결하지 않습니다.
테스트가 실행하는 Client UI와 action 대역
초기 props는 initialProducts이며 검색 action은 fetchProductsAction 대역입니다. 초기 렌더는 action을 호출하지 않습니다. 대역 reject도 catch에서 빈 배열이 되어 같은 빈 결과 문구를 보여 주며 별도 오류 UI는 없습니다. pending UI·호출 인자·횟수·응답 목록을 Client 경계에서 검사하며 서버 페이지·전송·DB를 실행한 시험으로 확대하지 않습니다.
원래 설명과 상세 기준
| 경계 | 예제 코드에서 확인할 범위 |
|---|---|
| 초기 목록 | 테스트가 initialProducts prop에 배열을 전달합니다. 서버 페이지를 실행하지 않습니다. |
| 검색 요청 | 입력과 클릭은 실제 ProductList에 전달하고, fetchProductsAction prop은 jest.fn() 대역으로 바꿉니다. |
| 검색 결과 | 대역 반환값으로 목록·빈 결과·로딩 UI와 호출 인자를 확인합니다. |
| 전체 페이지 | async ProductsPage 렌더링과 Server Function 통신은 이 Jest 테스트 범위 밖입니다. |
- 초기 목록
- 예제 코드에서 확인할 범위: 테스트가 initialProducts prop에 배열을 전달합니다. 서버 페이지를 실행하지 않습니다.
- 검색 요청
- 예제 코드에서 확인할 범위: 입력과 클릭은 실제 ProductList에 전달하고, fetchProductsAction prop은 jest.fn() 대역으로 바꿉니다.
- 검색 결과
- 예제 코드에서 확인할 범위: 대역 반환값으로 목록·빈 결과·로딩 UI와 호출 인자를 확인합니다.
- 전체 페이지
- 예제 코드에서 확인할 범위: async ProductsPage 렌더링과 Server Function 통신은 이 Jest 테스트 범위 밖입니다.
초기 렌더는 검색 함수를 호출하지 않고, 검색 후에는 대역의 반환값으로 목록을 교체합니다. 빈 결과 메시지와 호출 횟수·키보드 인자를 확인합니다. 예제의 별도 서버 함수는 고정 목록과 모의 지연을 사용하며 이 Jest 테스트가 실행하는 대상은 아닙니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 초기 목록 렌더 initialProducts 배열로 실제 ProductList를 렌더합니다. | 목록: initialProducts · action 초기 호출: 없음 · 서버 page: 미실행 | Client UI의 출발 목록이 표시됩니다. |
| 입력과 검색 click 사용자 입력·click이 실제 컴포넌트에 전달되고 jest.fn action 대역을 호출합니다. | 입력: 검색어 · 호출: action 대역 · 경계: Client 테스트 | 지역 입력이 검색 요청으로 넘어갑니다. |
| pending 표시 대역 반환을 기다리는 동안 loading UI를 확인합니다. | UI: loading · 결과: 대기 · Server Function 전송: 없음 | 표시가 요청 대기 상태로 바뀝니다. |
| 응답 목록 적용 대역 반환값으로 목록을 교체하거나 빈 결과 메시지를 표시합니다. | 목록: 대역 결과 · 빈 결과: 메시지 · 검증: 호출 인자·횟수 | Client 출력이 검색 결과로 교체됩니다. |
위 그림은 상품 검색 테스트가 ProductList 클라이언트 컴포넌트의 범위를 확인한다는 점을 보여 줍니다. Jest 테스트는 initialProducts 배열을 props로 넘겨 실제 ProductList를 렌더링하고, 입력과 클릭을 실제 컴포넌트에 전달합니다. 검색 action인 fetchProductsAction은 jest.fn() 대역으로 바꾸며, 대역의 결과로 목록을 바꾸거나 빈 값이면 "상품이 없습니다." 메시지를 보여 줍니다. 서버 페이지와 서버 함수, DB는 이 테스트의 범위 밖입니다. 그림의 단계도 초기 목록 렌더링, 입력과 검색 클릭, 대기 표시, 응답 목록 적용의 순서입니다.
그림의 설명처럼 초기 렌더링은 검색 함수를 호출하지 않으며, 대역이 거부되어도 catch에서 빈 배열이 되어 같은 빈 결과 문구를 보여 주고 별도의 오류 UI는 없습니다. 대기 UI, 호출 인자와 횟수, 응답 목록은 클라이언트 컴포넌트에서 검사할 뿐이므로, 서버 페이지나 전송, DB를 실행한 시험으로 넓혀 해석하지 않습니다.
통합 테스트 작성 시 고려사항 및 팁
통합 테스트는 실제로 연결되는 지점에 초점을 맞춥니다. 단위 테스트에서 이미 검증한 개별 로직을 다시 테스트하기보다, 컴포넌트 사이의 props 전달, API 호출 뒤의 데이터 처리, 사용자 입력에 따른 UI 변화처럼 여러 모듈이 상호작용하는 지점을 검사합니다. 테스트는 사용자가 애플리케이션을 쓰는 방식과 비슷하게, "이 버튼을 누르면 무엇이 일어나야 하는가?" 같은 질문에 답하도록 설계합니다.
백엔드 API를 모킹해야 한다면 MSW가 네트워크 수준에서 요청을 가로채 모의 응답을 돌려주므로, 실제 백엔드가 없는 환경에서도 통합 테스트를 작성할 수 있습니다. 이는 함수 prop 대역과는 다른 수준의 대역이므로, 실제 네트워크 요청 경로를 테스트할 때 고릅니다. 개발, 테스트, 운영 환경 사이에 환경 변수나 API 엔드포인트 같은 설정을 일관되게 유지해, 테스트를 통과한 뒤 배포에서 문제가 생기지 않도록 합니다.
일반적으로 단위 테스트가 가장 많고, 통합 테스트, Cypress나 Playwright 같은 E2E 테스트 순서로 개수가 줄어드는 테스트 피라미드(test pyramid) 전략을 따르는 것이 좋습니다. 단위 테스트는 빠르고 격리되어 있고, 통합 테스트는 여러 부분을 잇고, E2E 테스트는 전체 시스템을 검증합니다. 통합 테스트는 리팩터링할 때도 안정성을 주므로, 내부 구현이 바뀌어도 사용자 흐름이 같다면 테스트는 여전히 통과해야 합니다.
초기 prop과 검색 함수 대역을 쓴 이 테스트만으로 async 서버 컴포넌트, HTTP 전송, DB 연동이 검증되었다고 판단하지 않습니다. 예제의 API 모듈도 고정 배열과 지연을 쓰므로 외부 서버 호출과 구분하며, 범위를 넓힐 때는 실제로 연결한 의존성을 명시합니다.
정리
- 통합 테스트는 각각 잘 동작하는 단위들이 연결되었을 때 데이터 흐름과 상호작용이 올바른지 검증합니다.
- App Router에서는 클라이언트 컴포넌트를 React Testing Library로 테스트하고,
async서버 컴포넌트를 포함한 실제 페이지는 E2E로 확인합니다. - 서버에서 내려올 초기 데이터와 서버 함수는 props 대역으로 넣어, 입력, 클릭, 로딩, 목록 변경을 검사합니다.
waitFor와findBy쿼리는 비동기 UI 갱신을 기다리며, 모의 함수로 호출 인자와 횟수를 함께 검증합니다.- 함수 대역을 쓴 테스트가 HTTP 전송이나 DB 연동까지 검증하지는 않으므로, 실제로 연결한 의존성을 분명히 적습니다.
연습 문제
1. 대역의 실패도 추적해 보기
초기 목록은 노트북과 마우스 두 개입니다. 검색 함수 대역을 끝나지 않는 Promise로 두고 검색을 누른 직후에는 목록과 버튼이 어떻게 보일까요? 그 Promise를 거부시키면 마지막 UI는 무엇이며, 성공한 빈 검색과 화면만으로 구별할 수 있을까요?
해설 보기
검색을 시작하면 loading만 true로 바뀌므로, 기존 목록은 그대로 남고 버튼은 "검색 중..."으로 바뀌어 비활성화됩니다. 거부되면 catch가 목록을 빈 배열로 바꾸고 finally가 로딩을 끝내, "상품이 없습니다." 문구가 나타납니다. 이 UI에는 별도의 오류 상태가 없으므로 성공한 빈 결과와 최종 문구가 같습니다.
예제의 세 테스트에는 이 거부 경로가 없습니다. 추가할 때는 호출 인자, 대기 중인 버튼, 끝난 뒤의 빈 목록을 함께 확인하세요. 사용자가 실패와 빈 결과를 구별해야 한다면 오류 상태와 안내 문구를 먼저 설계하고 그 기대값을 테스트합니다. 버튼의 로딩 표시가 원격 DB를 검증했다는 뜻은 아닙니다.
2. 같은 검색을 두 번 누를 때
검색 함수 대역에 mockResolvedValueOnce로 키보드 하나만 돌려주게 설정한 뒤, 같은 테스트에서 검색 버튼을 두 번 눌렀습니다. 두 번째 검색의 결과는 무엇일까요?
해설 보기
mockResolvedValueOnce는 한 번만 적용되므로, 두 번째 호출에서는 기본 구현이 없는 jest.fn()이 undefined를 돌려줍니다. 그러면 setProducts(undefined)가 되어 products.length를 읽을 때 오류가 날 수 있습니다. 같은 결과를 계속 돌려주려면 mockResolvedValue를 쓰고, 호출마다 다른 결과를 원한다면 mockResolvedValueOnce를 필요한 횟수만큼 이어서 설정합니다.
3. 상품 페이지 전체를 Jest로 렌더링하기
render(await ProductsPage())처럼 async 서버 컴포넌트인 ProductsPage를 Jest로 직접 렌더링해 전체 흐름을 테스트하려고 합니다. 이 방식에는 어떤 문제가 있으며, 어떻게 나누어 검증하는 편이 좋을까요?
해설 보기
Jest와 React Testing Library는 async 서버 컴포넌트의 렌더링을 지원하지 않으며, 서버 함수의 전송이나 Next.js의 서버 렌더링 과정도 Jest 환경에는 없습니다. 그래서 일부 결과가 나오더라도 실제 페이지와 같은 조건에서 검증했다고 보기 어렵습니다.
getProductsApi 같은 서버의 데이터 함수는 단위 테스트로, ProductList는 이 글처럼 props 대역을 넣은 컴포넌트 테스트로 나누어 검증합니다. 서버 컴포넌트와 클라이언트 컴포넌트가 합쳐진 실제 페이지의 흐름은 다음 글에서 다루는 E2E 테스트로 확인합니다.
React Testing Library의 쿼리와 비동기 도구는 Testing Library 공식 문서에서 확인할 수 있습니다.
다음 글 E2E 테스트 구현에서는 Cypress로 실제 브라우저에서 사용자 흐름 전체를 검증하는 E2E 테스트를 구현합니다.