🔧 DevOps

Integration Test

통합 테스트

개별 모듈이나 컴포넌트들이 함께 올바르게 동작하는지 검증하는 테스트로, 유닛 테스트와 E2E 테스트 사이에서 시스템 간 상호작용을 검증합니다.

📖 상세 설명

Integration Test(통합 테스트)는 소프트웨어의 여러 구성 요소가 함께 동작할 때 발생하는 문제를 발견하기 위한 테스트입니다. 유닛 테스트가 개별 함수나 클래스의 동작을 검증한다면, 통합 테스트는 데이터베이스 연결, API 호출, 메시지 큐 통신 등 외부 시스템과의 상호작용을 검증합니다.

테스트 피라미드 개념에서 통합 테스트는 중간 계층에 위치합니다. 유닛 테스트보다는 적은 수를 작성하되, 시스템 간 계약(contract)이 올바르게 지켜지는지 확인합니다. 예를 들어, 서비스 A가 서비스 B의 API를 호출할 때 요청/응답 형식이 맞는지, 데이터베이스 트랜잭션이 의도대로 처리되는지를 검증합니다.

통합 테스트 전략에는 여러 접근법이 있습니다. Big Bang은 모든 컴포넌트를 한 번에 통합하여 테스트하는 방식이고, Top-Down은 상위 모듈부터 하위 모듈로 점진적으로 통합하며, Bottom-Up은 그 반대입니다. 현대 마이크로서비스 환경에서는 서비스별 독립적 통합 테스트와 함께 Contract Testing을 활용하는 추세입니다.

실행 환경으로는 Testcontainers를 활용해 Docker 컨테이너로 실제 데이터베이스, Redis, Kafka 등을 띄워서 테스트하거나, Localstack으로 AWS 서비스를 모킹하는 방식이 널리 사용됩니다. CI/CD 파이프라인에서는 유닛 테스트 이후 통합 테스트를 별도 스테이지로 실행하여, 실패 시 빠른 피드백을 제공합니다.

💻 코드 예제

Python + pytest + Testcontainers 통합 테스트

import pytest
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
import redis

# 실제 PostgreSQL 컨테이너를 사용한 통합 테스트
class TestUserRepository:
    """사용자 저장소 통합 테스트 - 실제 DB 연동"""

    @pytest.fixture(scope="class")
    def postgres_container(self):
        """테스트용 PostgreSQL 컨테이너 시작"""
        with PostgresContainer("postgres:15-alpine") as postgres:
            yield postgres

    @pytest.fixture(scope="class")
    def db_session(self, postgres_container):
        """데이터베이스 세션 생성"""
        engine = create_engine(postgres_container.get_connection_url())

        # 스키마 초기화
        with engine.connect() as conn:
            conn.execute(text("""
                CREATE TABLE IF NOT EXISTS users (
                    id SERIAL PRIMARY KEY,
                    email VARCHAR(255) UNIQUE NOT NULL,
                    name VARCHAR(100) NOT NULL,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """))
            conn.commit()

        Session = sessionmaker(bind=engine)
        return Session()

    def test_create_and_retrieve_user(self, db_session):
        """사용자 생성 및 조회 통합 테스트"""
        # Given: 새로운 사용자 정보
        user_email = "test@example.com"
        user_name = "테스트 사용자"

        # When: 사용자 생성
        db_session.execute(
            text("INSERT INTO users (email, name) VALUES (:email, :name)"),
            {"email": user_email, "name": user_name}
        )
        db_session.commit()

        # Then: 생성된 사용자 조회 확인
        result = db_session.execute(
            text("SELECT * FROM users WHERE email = :email"),
            {"email": user_email}
        ).fetchone()

        assert result is not None
        assert result.email == user_email
        assert result.name == user_name

    def test_duplicate_email_fails(self, db_session):
        """중복 이메일 등록 실패 테스트"""
        from sqlalchemy.exc import IntegrityError

        email = "duplicate@example.com"

        # 첫 번째 사용자 등록
        db_session.execute(
            text("INSERT INTO users (email, name) VALUES (:email, :name)"),
            {"email": email, "name": "First User"}
        )
        db_session.commit()

        # 같은 이메일로 두 번째 등록 시도
        with pytest.raises(IntegrityError):
            db_session.execute(
                text("INSERT INTO users (email, name) VALUES (:email, :name)"),
                {"email": email, "name": "Second User"}
            )
            db_session.commit()


# Redis 캐시 통합 테스트
class TestCacheService:
    """캐시 서비스 통합 테스트 - 실제 Redis 연동"""

    @pytest.fixture(scope="class")
    def redis_container(self):
        """테스트용 Redis 컨테이너 시작"""
        with RedisContainer("redis:7-alpine") as redis_cont:
            yield redis_cont

    @pytest.fixture
    def redis_client(self, redis_container):
        """Redis 클라이언트 생성"""
        client = redis.Redis(
            host=redis_container.get_container_host_ip(),
            port=redis_container.get_exposed_port(6379),
            decode_responses=True
        )
        yield client
        client.flushall()  # 테스트 후 정리

    def test_cache_set_and_get(self, redis_client):
        """캐시 저장 및 조회 테스트"""
        # Given
        cache_key = "user:123:profile"
        cache_value = '{"id": 123, "name": "Test User"}'

        # When
        redis_client.setex(cache_key, 3600, cache_value)

        # Then
        result = redis_client.get(cache_key)
        assert result == cache_value

    def test_cache_expiration(self, redis_client):
        """캐시 만료 테스트"""
        import time

        # Given: 1초 TTL 캐시
        cache_key = "temp:data"
        redis_client.setex(cache_key, 1, "temporary")

        # When: 1.5초 대기
        time.sleep(1.5)

        # Then: 캐시 만료됨
        assert redis_client.get(cache_key) is None


# API 통합 테스트 예제 (FastAPI + httpx)
import httpx
from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()

@app.get("/api/health")
def health_check():
    return {"status": "healthy"}

@app.post("/api/users")
def create_user(email: str, name: str):
    return {"id": 1, "email": email, "name": name}


class TestAPIIntegration:
    """API 엔드포인트 통합 테스트"""

    @pytest.fixture
    def client(self):
        return TestClient(app)

    def test_health_endpoint(self, client):
        """헬스체크 API 테스트"""
        response = client.get("/api/health")

        assert response.status_code == 200
        assert response.json()["status"] == "healthy"

    def test_create_user_api(self, client):
        """사용자 생성 API 통합 테스트"""
        response = client.post(
            "/api/users",
            params={"email": "new@example.com", "name": "New User"}
        )

        assert response.status_code == 200
        data = response.json()
        assert data["email"] == "new@example.com"
        assert "id" in data

🗣️ 실무에서 이렇게 말해요

QA 엔지니어 "결제 모듈 변경 후에 주문 서비스와 통합 테스트 돌렸나요? 지난번에 API 스펙 바뀌어서 배포 후 장애 났었잖아요."
백엔드 개발자 "네, Testcontainers로 실제 PostgreSQL이랑 Redis 띄워서 테스트했어요. 결제 트랜잭션 롤백 시나리오도 커버됩니다."
테크리드 "좋아요. CI에서 통합 테스트 실행 시간이 좀 길어지면 유닛 테스트 스테이지랑 분리해서 병렬로 돌립시다."
면접관 "유닛 테스트와 통합 테스트의 차이점과 각각을 언제 사용하는지 설명해주세요."
지원자 "유닛 테스트는 외부 의존성을 모킹하여 단일 함수나 클래스의 로직을 빠르게 검증하고, 통합 테스트는 실제 데이터베이스나 외부 API와의 상호작용을 검증합니다. 테스트 피라미드 원칙에 따라 유닛 테스트를 많이, 통합 테스트는 핵심 시나리오 위주로 작성해요. 저는 Testcontainers로 실제 인프라를 Docker로 띄워서 통합 테스트하는 방식을 선호합니다."
리뷰어 "이 테스트에서 외부 API 호출을 모킹하고 있는데, 실제로 API 스펙이 맞는지 확인은 어떻게 하나요?"
작성자 "별도로 Contract Test를 추가했습니다. Pact로 Consumer-Driven Contract 테스트를 작성해서 API 제공자와 소비자 간 스펙이 일치하는지 검증해요. 모킹은 유닛 테스트용이고, 통합 테스트에서는 실제 스테이징 환경 API를 호출합니다."

⚠️ 주의사항

  • 테스트 격리: 통합 테스트는 외부 리소스를 공유하므로 테스트 간 데이터 간섭이 발생할 수 있습니다. 각 테스트 전후로 데이터를 정리(setUp/tearDown)하고, 트랜잭션 롤백을 활용하세요.
  • 실행 시간 관리: 통합 테스트는 유닛 테스트보다 느립니다. CI에서 병렬 실행하거나, 변경된 모듈 관련 테스트만 실행하는 전략을 고려하세요. 전체 테스트는 nightly build에서 실행할 수 있습니다.
  • 환경 일관성: 로컬과 CI 환경에서 동일한 버전의 데이터베이스, 메시지 큐 등을 사용해야 합니다. Docker Compose나 Testcontainers로 환경을 일관되게 관리하세요.

📚 더 배우기