icon
14장 : 테스팅

통합 테스트 작성

소프트웨어 테스팅은 단위 테스트만으로는 충분하지 않습니다. 애플리케이션의 여러 부분이 함께 작동할 때 발생하는 문제를 발견하고 시스템 전체의 흐름을 검증하기 위해 통합 테스트(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 요청을 모킹하여 컴포넌트가 데이터를 가져와 렌더링하는 과정을 테스트합니다.

통합 테스트 작성 예시

간단한 상품 목록 및 검색 기능을 가진 페이지를 통합 테스트하는 시나리오를 구성해 보겠습니다.

시나리오

  1. 상품 목록 페이지에 접속한다.
  2. 상품 목록이 로드되어 화면에 표시되는 것을 확인한다.
  3. 검색창에 검색어를 입력한다.
  4. 검색 버튼을 클릭한다.
  5. 검색어에 해당하는 상품만 필터링되어 표시되는 것을 확인한다.

필요한 컴포넌트 및 API 모듈

먼저 테스트할 컴포넌트와 모킹할 API 함수를 정의합니다.

components/ProductList.tsx
// 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
// 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
// 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
// __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();
  });
});

설명

  1. render(await ProductsPage()): ProductsPage가 Server Component이므로 async로 데이터를 페칭하여 렌더링합니다. render 함수에 await를 사용하여 모든 비동기 작업이 완료된 후 컴포넌트가 DOM에 렌더링되도록 합니다.
  2. jest.spyOn(api, 'getProductsApi'): lib/api.ts 모듈의 getProductsApi 함수를 스파이(spy)하여, 이 함수가 호출될 때 실제 구현 대신 우리가 정의한 Mock 구현이 사용되도록 합니다.
  3. mockResolvedValue, mockResolvedValueOnce: Mock 함수가 비동기적으로 특정 값을 반환하도록 설정합니다. mockResolvedValueOnce는 한 번만 적용됩니다.
  4. screen.getByRole, screen.getByPlaceholderText, screen.getByText: @testing-library/react의 쿼리를 사용하여 사용자가 실제로 보는 방식으로 DOM 요소를 찾습니다.
  5. fireEvent.change, fireEvent.click: 사용자 상호작용(입력, 클릭 등)을 시뮬레이션합니다.
  6. await waitFor(() => { ... }): 비동기 작업(데이터 로딩, UI 업데이트)이 완료될 때까지 기다립니다. expect 문이 성공할 때까지 콜백 함수를 주기적으로 재실행합니다. 이는 네트워크 요청이나 useEffect 내의 비동기 로직이 완료될 때까지 기다리는 데 필수적입니다.
  7. 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 애플리케이션의 복잡성을 관리하고, 여러 모듈이 예상대로 함께 작동하는지 확인하는 데 필수적인 부분입니다. 적절한 전략과 도구를 사용하여 견고하고 신뢰할 수 있는 애플리케이션을 구축하세요.