2026년 3월 16일

유닛 테스트와 목킹(Mocking): 견고하고 신뢰할 수 있는 코드의 주춧돌

230
유닛 테스트와 목킹(Mocking): 견고하고 신뢰할 수 있는 코드의 주춧돌

유닛 테스트와 목킹(Mocking): 견고하고 신뢰할 수 있는 코드의 주춧돌

유닛 테스트와 목킹(Mocking): 견고하고 신뢰할 수 있는 코드의 주춧돌

1. 개념 소개

1. 개념 소개

소프트웨어 개발은 복잡한 퍼즐을 맞추는 것과 같습니다. 각 조각(코드 모듈)이 제 역할을 정확히 수행해야만 전체 그림(애플리케이션)이 완성되고, 오류 없이 작동합니다. 하지만 코드의 양이 늘어나고 기능이 복잡해질수록, 한 조각의 작은 변경이 전체 시스템에 예상치 못한 문제를 일으키는 경우가 빈번해집니다. 이때, 개발자에게 강력한 무기가 되어주는 것이 바로 **유닛 테스트(Unit Test)**와 **목킹(Mocking)**입니다.

유닛 테스트는 애플리케이션을 구성하는 가장 작은 단위, 즉 개별 함수, 메서드, 클래스 등이 의도한 대로 정확하게 동작하는지 검증하는 과정입니다. 마치 공장에서 제품의 각 부품이 조립되기 전에 개별적으로 불량 여부를 검사하는 것과 같습니다. 이는 개발 초기 단계에서부터 버그를 찾아내고 수정하는 데 드는 비용과 시간을 획기적으로 줄여줍니다.

목킹은 유닛 테스트의 핵심적인 동반자입니다. 테스트하려는 코드 단위가 데이터베이스, 외부 API, 파일 시스템 등과 같은 "외부 의존성"을 가질 때, 이 의존성들을 실제 객체 대신 가짜(Mock) 객체로 대체하는 기법입니다. 왜냐하면 실제 외부 의존성을 매번 사용하면 테스트가 느려지고, 불안정해지며, 예측 불가능한 결과를 초래할 수 있기 때문입니다. 목킹을 통해 우리는 오직 테스트 대상 코드 자체의 로직에만 집중하여 독립적이고 빠르게 테스트를 수행할 수 있습니다.

탄생 배경 및 중요성

유닛 테스트의 중요성은 소프트웨어 개발의 복잡성이 증가하고, 애자일(Agile) 개발 방법론이 확산되면서 더욱 부각되었습니다. 과거에는 개발이 완료된 후 통합 테스트나 사용자 승인 테스트 단계에서 주로 버그를 발견했지만, 이 시점에 발견된 버그는 수정하는 데 막대한 시간과 비용이 소요되었습니다.

유닛 테스트는 이러한 문제점을 해결하기 위해 개발 초기에, 개발자 스스로 코드를 작성하는 동시에 테스트 코드를 작성하여 버그를 조기에 발견하고 수정하는 문화를 확산시켰습니다. 또한, 코드 리팩토링(Refactoring)이나 기능 변경 시 기존 기능이 망가지지 않았음을 빠르게 확인할 수 있는 안전망 역할을 해주어 개발자의 자신감을 높이고, 결과적으로 개발 속도를 유지하거나 향상시키는 데 크게 기여합니다.

유닛 테스트와 목킹이 중요한 이유:

  • 버그 조기 발견 및 비용 절감: 개발 단계에서 버그를 발견하면 수정 비용이 가장 적게 듭니다.
  • 코드 품질 향상 및 설계 개선: 테스트 가능한 코드를 작성하려면 자연스럽게 모듈화가 잘 되고, 응집도는 높고 결합도는 낮은 코드를 설계하게 됩니다.
  • 리팩토링 및 변경에 대한 자신감: 테스트 코드가 있다면, 기존 기능을 망가뜨릴 걱정 없이 코드를 개선하고 변경할 수 있습니다.
  • 문서화 역할: 테스트 코드는 해당 코드 단위가 어떻게 사용되어야 하는지에 대한 살아있는 문서 역할을 합니다.
  • 개발자 생산성 향상: 수동으로 기능을 일일이 확인하는 대신, 자동화된 테스트를 통해 빠르게 피드백을 얻을 수 있습니다.

2. 핵심 원리 설명

2. 핵심 원리 설명

유닛 테스트의 핵심 원리: 독립성과 반복성

유닛 테스트는 다음의 세 가지 원칙을 기반으로 합니다.

  1. 작은 단위 테스트: 테스트의 대상은 애플리케이션의 가장 작은 논리적 단위(함수, 메서드, 클래스)여야 합니다.
  2. 독립성: 각 테스트는 다른 테스트의 결과에 영향을 받지 않고, 독립적으로 실행되어야 합니다. 또한, 외부 환경(데이터베이스, 네트워크 등)의 영향을 최소화해야 합니다.
  3. 반복성: 어떤 환경에서 언제 실행하더라도 항상 동일한 결과를 도출해야 합니다.

이러한 원칙을 지키기 위해 유닛 테스트는 일반적으로 Given-When-Then 패턴을 따릅니다.

  • Given (준비): 테스트를 수행하기 위한 초기 상태(객체 생성, 데이터 설정 등)를 준비합니다.
  • When (실행): 테스트 대상 코드(함수, 메서드)를 실행합니다.
  • Then (검증): 실행 결과가 예상과 일치하는지 단언(assert)을 통해 검증합니다.

비유: 자동차 부품 공장으로 돌아가 봅시다. 우리는 엔진의 성능을 테스트하려고 합니다.

  • Given: 엔진을 테스트 스탠드에 고정하고, 연료를 공급하고, 시동을 걸 준비를 합니다. (테스트 환경 설정)
  • When: 엔진 시동 버튼을 누르고, 특정 RPM으로 가동합니다. (테스트 대상 코드 실행)
  • Then: 엔진이 예상된 RPM에 도달했는지, 소음은 정상 범위인지, 배기가스 수치는 기준치 이하인지 측정하여 확인합니다. (결과 검증)

목킹(Mocking)의 핵심 원리: 의존성 분리

유닛 테스트를 작성하다 보면, 테스트하려는 코드가 다른 객체나 외부 시스템(데이터베이스, API 서버 등)에 의존하는 경우가 많습니다. 이러한 의존성을 가진 채로 유닛 테스트를 수행하면 다음과 같은 문제가 발생합니다.

  • 느린 테스트: 실제 데이터베이스 접근이나 네트워크 요청은 시간이 오래 걸립니다.
  • 불안정한 테스트: 외부 시스템의 상태에 따라 테스트 결과가 달라질 수 있습니다. (네트워크 오류, 서버 다운 등)
  • 복잡한 설정: 테스트마다 실제 외부 시스템을 설정하고 초기화하는 것이 번거롭습니다.

이때 목킹이 등장합니다. 목킹은 테스트 대상 코드의 의존성 객체를 실제 객체와 동일한 인터페이스를 가지지만, 미리 정의된 동작이나 반환 값을 가지는 "가짜 객체"로 대체하는 기법입니다. 이 가짜 객체를 통해 우리는 실제 의존성 없이도 테스트 대상 코드가 올바르게 작동하는지 확인할 수 있습니다.

비유: 엔진 테스트 상황에서, 엔진이 연료 공급 시스템에 의존한다고 가정해 봅시다. 실제 연료 시스템을 매번 연결하고 정교하게 제어하는 것은 번거롭고 시간이 오래 걸립니다. 이때 우리는 "목(Mock) 연료 공급 시스템"을 사용합니다.

  • 이 목 연료 공급 시스템은 실제 연료 시스템처럼 보이지만, 실제로는 "연료를 요청하면 10리터씩 공급되었다고 응답한다"와 같이 미리 정해진 규칙대로만 작동합니다.
  • 이를 통해 우리는 엔진이 연료 공급 시스템과 올바르게 상호작용하는지, 그리고 예상대로 작동하는지를 실제 연료 시스템 없이도 빠르게 검증할 수 있습니다.

목킹에서 사용되는 가짜 객체들은 크게 세 가지로 분류할 수 있습니다.

  • Stub (스텁): 미리 정의된 값을 반환하도록 설정된 객체. 특정 시나리오에서 고정된 응답을 제공합니다.
  • Mock (목): 스텁의 기능에 더해, 특정 메서드가 호출되었는지, 어떤 인자와 함께 호출되었는지, 몇 번 호출되었는지 등을 검증할 수 있는 객체.
  • Spy (스파이): 실제 객체의 동작을 그대로 유지하면서, 해당 객체의 메서드 호출 정보를 기록하는 객체. 부분적으로 목킹할 때 유용합니다.

초보 개발자는 이 차이점을 명확히 알기 어렵지만, 대부분의 경우 Mock 객체 하나로 스텁과 목의 역할을 모두 수행할 수 있습니다.

3. 코드 예제 (Python)

여기서는 pytest 프레임워크와 unittest.mock 모듈을 사용하여 유닛 테스트와 목킹 예제를 보여드립니다. pytest는 파이썬에서 가장 인기 있고 강력한 테스트 프레임워크 중 하나입니다.

먼저 pytest를 설치합니다:

pip install pytest

예제 1: 간단한 유닛 테스트 (의존성 없음)

calculator.py 파일에 간단한 계산기 클래스를 작성합니다.

# calculator.py

class Calculator:
    def add(self, a, b):
        """두 숫자를 더합니다."""
        return a + b

    def subtract(self, a, b):
        """두 숫자를 뺍니다."""
        return a - b

    def multiply(self, a, b):
        """두 숫자를 곱합니다."""
        return a * b

    def divide(self, a, b):
        """두 숫자를 나눕니다. 0으로 나눌 경우 ValueError를 발생시킵니다."""
        if b == 0:
            raise ValueError("0으로 나눌 수 없습니다.")
        return a / b

이제 test_calculator.py 파일에 이 클래스를 테스트하는 코드를 작성합니다.

# test_calculator.py
import pytest
from calculator import Calculator

def test_add():
    """add 메서드가 올바르게 동작하는지 테스트합니다."""
    calc = Calculator()
    assert calc.add(1, 2) == 3
    assert calc.add(-1, 1) == 0
    assert calc.add(0, 0) == 0

def test_subtract():
    """subtract 메서드가 올바르게 동작하는지 테스트합니다."""
    calc = Calculator()
    assert calc.subtract(5, 3) == 2
    assert calc.subtract(3, 5) == -2
    assert calc.subtract(0, 0) == 0

def test_multiply():
    """multiply 메서드가 올바르게 동작하는지 테스트합니다."""
    calc = Calculator()
    assert calc.multiply(2, 3) == 6
    assert calc.multiply(-2, 3) == -6
    assert calc.multiply(0, 5) == 0

def test_divide():
    """divide 메서드가 올바르게 동작하는지 테스트합니다."""
    calc = Calculator()
    assert calc.divide(6, 3) == 2.0
    assert calc.divide(5, 2) == 2.5

def test_divide_by_zero_raises_error():
    """0으로 나눌 때 ValueError가 발생하는지 테스트합니다."""
    calc = Calculator()
    with pytest.raises(ValueError, match="0으로 나눌 수 없습니다."):
        calc.divide(10, 0)

터미널에서 pytest 명령어를 실행하면 테스트가 자동으로 발견되고 실행됩니다.

pytest

결과는 다음과 유사하게 나옵니다:

============================= test session starts ==============================
platform linux -- Python 3.x.x, pytest-x.x.x, pluggy-x.x.x
rootdir: /path/to/your/project
collected 5 items

test_calculator.py .....                                                 [100%]

============================== 5 passed in 0.0x s ==============================

예제 2: 목킹을 활용한 유닛 테스트 (외부 의존성 있음)

외부 API를 호출하여 사용자 정보를 가져오는 시나리오를 가정해 봅시다. user_service.py 파일입니다.

# user_service.py
import requests

class UserService:
    def __init__(self, api_base_url):
        self.api_base_url = api_base_url

    def get_user_info(self, user_id):
        """
        주어진 user_id로 사용자 정보를 외부 API에서 가져옵니다.
        API 응답은 {"id": ..., "name": ..., "email": ...} 형태를 가정합니다.
        """
        try:
            response = requests.get(f"{self.api_base_url}/users/{user_id}")
            response.raise_for_status()  # 200 OK가 아니면 HTTPError 발생
            return response.json()
        except requests.exceptions.RequestException as e:
            print(f"API 요청 중 오류 발생: {e}")
            return None

test_user_service.py 파일에서 이 UserService를 테스트합니다. 실제 requests 라이브러리를 통해 외부 API를 호출하는 대신, unittest.mock.patch 데코레이터를 사용하여 requests.get 메서드를 목킹합니다.

# test_user_service.py
import pytest
from unittest.mock import patch, Mock
from user_service import UserService

# patch 데코레이터를 사용하여 requests.get을 목킹합니다.
# 'requests.get'은 user_service.py에서 import 하는 requests 모듈의 get 함수 경로입니다.
@patch('user_service.requests.get')
def test_get_user_info_success(mock_get):
    """
    사용자 정보 가져오기 성공 케이스를 테스트합니다.
    requests.get이 성공적인 응답을 반환하도록 목킹합니다.
    """
    # Given: mock_get (requests.get)이 호출될 때 반환할 Mock 객체 설정
    mock_response = Mock()
    mock_response.status_code = 200
    mock_response.json.return_value = {"id": 1, "name": "Alice", "email": "[email protected]"}
    mock_get.return_value = mock_response # requests.get이 이 mock_response를 반환하도록 설정

    # When: UserService 인스턴스 생성 및 get_user_info 호출
    service = UserService("http://fakeapi.com")
    user_info = service.get_user_info(1)

    # Then:
    # 1. requests.get이 올바른 URL로 호출되었는지 검증
    mock_get.assert_called_once_with("http://fakeapi.com/users/1")
    # 2. 반환된 사용자 정보가 예상과 일치하는지 검증
    assert user_info == {"id": 1, "name": "Alice", "email": "[email protected]"}

@patch('user_service.requests.get')
def test_get_user_info_api_error(mock_get):
    """
    API 요청 중 오류가 발생했을 때 None을 반환하는지 테스트합니다.
    requests.get이 예외를 발생시키도록 목킹합니다.
    """
    # Given: mock_get (requests.get)이 호출될 때 requests.exceptions.RequestException을 발생시키도록 설정
    mock_get.side_effect = requests.exceptions.RequestException("API 요청 실패")

    # When: UserService 인스턴스 생성 및 get_user_info 호출
    service = UserService("http://fakeapi.com")
    user_info = service.get_user_info(2)

    # Then:
    # 1. requests.get이 올바른 URL로 호출되었는지 검증
    mock_get.assert_called_once_with("http://fakeapi.com/users/2")
    # 2. 반환 값이 None인지 검증
    assert user_info is None

@patch('user_service.requests.get')
def test_get_user_info_http_error(mock_get):
    """
    HTTP 에러 (예: 404 Not Found) 발생 시 None을 반환하는지 테스트합니다.
    requests.get이 HTTPError를 발생시키도록 목킹합니다.
    """
    # Given: mock_get (requests.get)이 호출될 때 반환할 Mock 객체 설정
    mock_response = Mock()
    mock_response.status_code = 404
    # raise_for_status() 메서드가 HTTPError를 발생시키도록 설정
    mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("404 Not Found")
    mock_get.return_value = mock_response

    # When: UserService 인스턴스 생성 및 get_user_info 호출
    service = UserService("http://fakeapi.com")
    user_info = service.get_user_info(3)

    # Then:
    # 1. requests.get이 올바른 URL로 호출되었는지 검증
    mock_get.assert_called_once_with("http://fakeapi.com/users/3")
    # 2. 반환 값이 None인지 검증
    assert user_info is None

pytest를 실행하면 세 가지 테스트가 모두 성공하는 것을 볼 수 있습니다. 실제 네트워크 요청 없이 UserService의 로직만 독립적으로 검증한 것입니다.

pytest
============================= test session starts ==============================
platform linux -- Python 3.x.x, pytest-x.x.x, pluggy-x.x.x
rootdir: /path/to/your/project
collected 3 items

test_user_service.py ...                                                 [100%]

============================== 3 passed in 0.0x s ==============================

4. 실무 적용 사례

유닛 테스트와 목킹은 거의 모든 종류의 소프트웨어 개발 프로젝트에서 필수적으로 활용됩니다.

  • 백엔드 API 개발: RESTful API 엔드포인트에서 비즈니스 로직을 처리하는 서비스 계층(Service Layer)의 함수들을 테스트할 때, 데이터베이스 접근이나 외부 인증 서비스 호출 등을 목킹하여 순수한 비즈니스 로직만 검증합니다.
  • 복잡한 비즈니스 로직: 세금 계산, 할인율 적용, 주문 처리 등 복잡하고 다양한 조건 분기가 있는 비즈니스 로직 함수는 모든 경우의 수를 유닛 테스트로 꼼꼼히 검증하여 버그 발생 가능성을 최소화합니다.
  • 데이터 처리 및 변환: 데이터를 파싱하고, 유효성을 검사하며, 특정 형식으로 변환하는 유틸리티 함수나 클래스는 다양한 입력값(정상, 비정상, 엣지 케이스)에 대해 유닛 테스트를 수행하여 견고성을 확보합니다.
  • 프론트엔드 컴포넌트 로직: React, Vue, Angular 등의 프레임워크를 사용하는 프론트엔드 개발에서도 UI 렌더링 로직이나 상태 관리 로직을 유닛 테스트합니다. 이때, API 호출이나 전역 스토어 접근 등을 목킹하여 컴포넌트 자체의 동작만 확인합니다.
  • 레거시 코드 리팩토링: 기존에 테스트 코드가 없던 레거시 시스템을 개선할 때, 먼저 유닛 테스트를 추가하여 현재의 동작을 파악하고, 리팩토링 과정에서 기존 기능이 손상되지 않았음을 지속적으로 검증하는 안전망으로 활용됩니다.
  • 테스트 주도 개발 (TDD): TDD는 "실패하는 테스트를 먼저 작성하고, 그 테스트를 통과시키는 최소한의 코드를 작성한 후, 코드를 리팩토링하는" 개발 방법론입니다. 유닛 테스트는 TDD의 핵심 도구이며, 코드 설계와 구현을 동시에 이끌어가는 강력한 수단이 됩니다.

5. 자주 하는 실수와 해결법

초중급 개발자들이 유닛 테스트와 목킹을 적용할 때 흔히 저지르는 실수와 그 해결책을 알아봅시다.

  1. 너무 큰 단위로 테스트:

    • 실수: 유닛 테스트인데도 여러 클래스나 외부 시스템을 포함하여 통합 테스트처럼 작성하는 경우. 이는 테스트의 독립성을 해치고, 실패 시 원인 파악을 어렵게 합니다.
    • 해결법: "유닛"의 정의를 명확히 하고, 한 번에 하나의 책임(Single Responsibility)을 가진 함수나 메서드에 집중하여 테스트합니다. 테스트 대상 코드의 의존성은 과감하게 목킹합니다.
  2. 테스트 간 의존성:

    • 실수: 특정 테스트가 이전 테스트의 결과나 전역 상태에 의존하여, 테스트 실행 순서에 따라 결과가 달라지는 경우.
    • 해결법: 각 테스트는 완전히 독립적으로 실행될 수 있도록 설계합니다. 테스트 시작 전에 필요한 모든 준비를 하고, 테스트 종료 후에는 상태를 정리(tear down)하는 과정을 포함합니다. pytest 같은 프레임워크는 기본적으로 테스트 독립성을 지향합니다.
  3. 과도한 목킹:

    • 실수: 모든 의존성을 목킹하여 실제 시스템의 동작과 너무 동떨어진 테스트를 작성하는 경우. 목킹된 객체의 행동이 실제 객체와 다르면, 테스트는 성공해도 실제 애플리케이션은 실패할 수 있습니다.
    • 해결법: 꼭 필요한 의존성만 목킹하고, 인터페이스(API)가 안정적인 외부 시스템은 실제 호출을 포함하는 통합 테스트를 병행합니다. 목킹은 유닛 테스트의 독립성을 위한 도구이지, 실제 시스템의 검증을 대체하는 것이 아님을 명심해야 합니다.
  4. 테스트 코드 작성 시간 부족:

    • 실수: 바쁜 일정 속에서 "나중에 테스트 코드를 작성하겠다"고 미루다가 결국 작성하지 못하는 경우.
    • 해결법: 테스트 코드 작성을 개발 프로세스의 필수적인 부분으로 간주하고, 개발 시간 계획에 포함해야 합니다. TDD와 같이 테스트 코드를 먼저 작성하는 습관을 들이면 자연스럽게 테스트 커버리지를 높일 수 있습니다.
  5. 잘못된 단언(Assertion):

    • 실수: 코드는 버그가 있지만, 테스트 코드가 그 버그를 제대로 잡아내지 못하도록 단언이 불충분하거나 잘못된 경우. (예: 항상 True인 조건을 단언하거나, 일부 엣지 케이스를 놓치는 경우)
    • 해결법: 다양한 입력값(정상, 경계값, 잘못된 입력, 예외 상황 등)에 대해 꼼꼼하게 테스트 케이스를 작성하고, 각 결과에 대한 단언을 명확하고 구체적으로 작성합니다. 특히 예외 처리 로직은 pytest.raises와 같은 기능을 사용하여 반드시 테스트해야 합니다.

6. 더 공부할 리소스 추천

유닛 테스트와 목킹은 개발자로서 성장하는 데 필수적인 기술입니다. 아래 리소스들을 통해 더 깊이 있는 지식을 습득하고 실력을 향상시키세요.

  • Pytest 공식 문서: https://docs.pytest.org/en/stable/
    • 파이썬 테스트 프레임워크의 표준과도 같은 pytest의 사용법을 익히는 데 가장 좋은 자료입니다.
  • unittest.mock 공식 문서: https://docs.python.org/3/library/unittest.mock.html
    • 파이썬 내장 목킹 라이브러리의 모든 기능과 사용 예시를 상세히 설명합니다. pytest-mock은 이 unittest.mockpytest와 더 쉽게 통합하는 플러그인입니다.
  • Test-Driven Development by Example (Kent Beck):
    • TDD의 창시자가 쓴 고전으로, 테스트 주도 개발 방법론의 철학과 실제 적용 방법을 배울 수 있습니다. 테스트를 통해 설계를 개선하는 통찰력을 얻을 수 있습니다.
  • Clean Code (Robert C. Martin):
    • 테스트 가능한 코드를 작성하는 방법, 좋은 함수와 클래스 설계 원칙 등 유닛 테스트와 밀접하게 관련된 "클린 코드" 원칙들을 배울 수 있습니다.
  • The Art of Unit Testing (Roy Osherove):
    • 유닛 테스트 작성에 대한 실용적인 조언과 베스트 프랙티스를 제공합니다. 테스트의 종류, 좋은 테스트의 특성, 목킹 전략 등을 심도 있게 다룹니다.
  • 온라인 강의 및 블로그: Udemy, Coursera, YouTube 등에서 "Python Unit Testing", "Pytest Tutorial", "Mocking in Python" 등으로 검색하면 다양한 실습 위주의 자료를 찾을 수 있습니다.