파이썬에서의 단위 테스트: 예제를 포함한 완전 가이드

pytest를 사용한 Python 테스트, TDD, 모킹 및 커버리지

Page content

단위 테스트는 프로젝트가 발전하면서도 Python 코드가 올바르게 작동하고 계속 작동하도록 보장합니다. 이 포괄적인 가이드는 Python의 단위 테스트에 대해 알아야 할 모든 내용을 다룹니다. 기본 개념부터 고급 기술까지.

Python 단위 테스트

단위 테스트가 중요한 이유

단위 테스트는 Python 개발자에게 다음과 같은 많은 이점을 제공합니다:

  • 조기 버그 탐지: 버그가 프로덕션에 도달하기 전에 발견
  • 코드 품질: 모듈화되고 테스트 가능한 코드를 작성하도록 강제
  • 리팩토링 신뢰도: 테스트가 회귀를 탐지하도록 하여 안전하게 변경 가능
  • 문서화: 테스트는 코드가 어떻게 작동해야 하는지를 실행 가능한 문서로 제공
  • 빠른 개발: 자동화된 테스트는 수동 테스트보다 빠름
  • 더 나은 설계: 테스트 가능한 코드를 작성하면 더 나은 아키텍처를 이끌어냄

단위 테스트의 기본 원리 이해

단위 테스트란 무엇인가?

단위 테스트는 애플리케이션의 가장 작은 테스트 가능한 부분(보통 함수나 메서드)을 고립시켜 검증합니다. 다음과 같은 특성을 가져야 합니다:

  • 빠름: 밀리초 단위로 실행
  • 고립성: 다른 테스트나 외부 시스템과 독립
  • 반복 가능: 매번 동일한 결과를 생성
  • 자체 검증: 수동 검사 없이 명확하게 통과 또는 실패
  • 시기적절함: 코드 작성 전이나 함께 작성

테스트 피라미드

건강한 테스트 스위트는 테스트 피라미드를 따릅니다:

           /\
          /  \     E2E 테스트 (소수)
         /____\
        /      \   통합 테스트 (일부)
       /_____ ___\
      /          \ 단위 테스트 (많음)
     /_____ __ ___\

단위 테스트는 기초를 형성합니다 - 수량이 많고 빠르며 빠른 피드백을 제공합니다.

Python 테스트 프레임워크 비교

unittest: 내장 프레임워크

Python 표준 라이브러리에는 JUnit에서 영감을 받은 unittest가 포함되어 있습니다:

import unittest

class TestCalculator(unittest.TestCase):
    def setUp(self):
        """각 테스트 전에 실행"""
        self.calc = Calculator()
    
    def tearDown(self):
        """각 테스트 후에 실행"""
        self.calc = None
    
    def test_addition(self):
        result = self.calc.add(2, 3)
        self.assertEqual(result, 5)
    
    def test_division_by_zero(self):
        with self.assertRaises(ZeroDivisionError):
            self.calc.divide(10, 0)

if __name__ == '__main__':
    unittest.main()

장점:

  • 내장되어 있어 설치 필요 없음
  • xUnit 프레임워크에 익숙한 개발자에게 적합
  • 기업 친화적이고 잘 알려져 있음

단점:

  • 보일러플레이트가 있는 복잡한 구문
  • 테스트 조직화를 위해 클래스 필요
  • 고정 장치 관리가 덜 유연

pytest: 현대적인 선택

pytest는 가장 인기 있는 제3자 테스트 프레임워크입니다:

import pytest

def test_addition():
    calc = Calculator()
    assert calc.add(2, 3) == 5

def test_division_by_zero():
    calc = Calculator()
    with pytest.raises(ZeroDivisionError):
        calc.divide(10, 0)

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
])
def test_addition_parametrized(a, b, expected):
    calc = Calculator()
    assert calc.add(a, b) == expected

장점:

  • 간단하고 파이썬적인 구문
  • 강력한 고정 장치 시스템
  • 훌륭한 플러그인 생태계
  • 더 나은 오류 보고
  • 파라메트라이즈된 테스트가 내장됨

단점:

  • 설치 필요
  • 다른 언어에서 온 개발자에게 익숙하지 않음

설치:

pip install pytest pytest-cov pytest-mock

첫 번째 단위 테스트 작성

Test-Driven Development(TDD)를 사용하여 간단한 예제를 처음부터 작성해 보겠습니다. Python에 익숙하지 않거나 구문 및 언어 기능에 대한 빠른 참조가 필요하다면, 우리의 Python 체크리스트를 확인하여 Python 기초에 대한 포괄적인 개요를 살펴보세요.

예제: 문자열 유틸리티 함수

단계 1: 먼저 테스트를 작성 (빨강)

test_string_utils.py 생성:

import pytest
from string_utils import reverse_string, is_palindrome, count_vowels

def test_reverse_string():
    assert reverse_string("hello") == "olleh"
    assert reverse_string("") == ""
    assert reverse_string("a") == "a"

def test_is_palindrome():
    assert is_palindrome("racecar") == True
    assert is_palindrome("hello") == False
    assert is_palindrome("") == True
    assert is_palindrome("A man a plan a canal Panama") == True

def test_count_vowels():
    assert count_vowels("hello") == 2
    assert count_vowels("HELLO") == 2
    assert count_vowels("xyz") == 0
    assert count_vowels("") == 0

단계 2: 테스트를 통과하기 위한 최소한의 코드 작성 (초록)

string_utils.py 생성:

def reverse_string(s: str) -> str:
    """문자열을 뒤집습니다."""
    return s[::-1]

def is_palindrome(s: str) -> bool:
    """문자가 회문인지 확인합니다 (대소문자와 공백 무시)"""
    cleaned = ''.join(s.lower().split())
    return cleaned == cleaned[::-1]

def count_vowels(s: str) -> int:
    """문자열의 모음을 세줍니다."""
    return sum(1 for char in s.lower() if char in 'aeiou')

단계 3: 테스트 실행

pytest test_string_utils.py -v

출력:

test_string_utils.py::test_reverse_string PASSED
test_string_utils.py::test_is_palindrome PASSED
test_string_utils.py::test_count_vowels PASSED

고급 테스트 기술

고정 장치를 사용한 테스트 설정

고정 장치는 재사용 가능한 테스트 설정 및 정리 제공:

import pytest
from database import Database

@pytest.fixture
def db():
    """테스트용 데이터베이스 생성"""
    database = Database(":memory:")
    database.create_tables()
    yield database  # 테스트에 제공
    database.close()  # 테스트 후 정리

@pytest.fixture
def sample_users(db):
    """데이터베이스에 샘플 사용자 추가"""
    db.add_user("Alice", "alice@example.com")
    db.add_user("Bob", "bob@example.com")
    return db

def test_get_user(sample_users):
    user = sample_users.get_user_by_email("alice@example.com")
    assert user.name == "Alice"

def test_user_count(sample_users):
    assert sample_users.count_users() == 2

고정 장치 범위

범위를 통해 고정 장치의 수명을 제어:

@pytest.fixture(scope="function")  # 기본값: 각 테스트마다 실행
def func_fixture():
    return create_resource()

@pytest.fixture(scope="class")  # 테스트 클래스당 한 번 실행
def class_fixture():
    return create_resource()

@pytest.fixture(scope="module")  # 모듈당 한 번 실행
def module_fixture():
    return create_resource()

@pytest.fixture(scope="session")  # 테스트 세션당 한 번 실행
def session_fixture():
    return create_expensive_resource()

외부 의존성 모킹

외부 의존성을 분리하기 위해 모킹 사용:

from unittest.mock import Mock, patch, MagicMock
import requests

class WeatherService:
    def get_temperature(self, city):
        response = requests.get(f"https://api.weather.com/{city}")
        return response.json()["temp"]

# 테스트 시 모킹
def test_get_temperature():
    service = WeatherService()
    
    # requests.get 함수 모킹
    with patch('requests.get') as mock_get:
        # 모킹된 응답 구성
        mock_response = Mock()
        mock_response.json.return_value = {"temp": 72}
        mock_get.return_value = mock_response
        
        # 테스트
        temp = service.get_temperature("Boston")
        assert temp == 72
        
        # 호출 확인
        mock_get.assert_called_once_with("https://api.weather.com/Boston")

pytest-mock 플러그인 사용

pytest-mock은 더 깔끔한 구문을 제공합니다:

def test_get_temperature(mocker):
    service = WeatherService()
    
    # pytest-mock을 사용한 모킹
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"temp": 72}
    mocker.patch('requests.get', return_value=mock_response)
    
    temp = service.get_temperature("Boston")
    assert temp == 72

파라메트라이즈된 테스트

다양한 시나리오를 효율적으로 테스트:

import pytest

@pytest.mark.parametrize("input,expected", [
    ("", True),
    ("a", True),
    ("ab", False),
    ("aba", True),
    ("racecar", True),
    ("hello", False),
])
def test_is_palindrome_parametrized(input, expected):
    assert is_palindrome(input) == expected

@pytest.mark.parametrize("number,is_even", [
    (0, True),
    (1, False),
    (2, True),
    (-1, False),
    (-2, True),
])
def test_is_even(number, is_even):
    assert (number % 2 == 0) == is_even

예외 및 오류 처리 테스트

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("0으로 나눌 수 없습니다")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="0으로 나눌 수 없습니다"):
        divide(10, 0)

def test_divide_by_zero_with_message():
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)
    assert "zero" in str(exc_info.value).lower()

# 예외가 발생하지 않는 경우 테스트
def test_divide_success():
    result = divide(10, 2)
    assert result == 5.0

비동기 코드 테스트

비동기 코드 테스트는 현대 Python 애플리케이션에서 필수적이며, 특히 API, 데이터베이스, AI 서비스와 작업할 때 중요합니다. 비동기 함수를 테스트하는 방법은 다음과 같습니다:

import pytest
import asyncio

async def fetch_data(url):
    """비동기 함수로 데이터 가져오기"""
    await asyncio.sleep(0.1)  # API 호출 시뮬레이션
    return {"status": "success"}

@pytest.mark.asyncio
async def test_fetch_data():
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "success"

@pytest.mark.asyncio
async def test_fetch_data_with_mock(mocker):
    # 비동기 함수 모킹
    mock_fetch = mocker.AsyncMock(return_value={"status": "mocked"})
    mocker.patch('module.fetch_data', mock_fetch)
    
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "mocked"

AI 서비스와 통합하는 실제 예제를 보려면 우리의 Ollama와 Python 통합 가이드를 참조하세요. 이 가이드는 LLM 상호작용을 위한 테스트 전략을 포함합니다.

코드 커버리지

테스트된 코드의 비율을 측정:

pytest-cov 사용

# 테스트 실행 시 커버리지 측정
pytest --cov=myproject tests/

# HTML 보고서 생성
pytest --cov=myproject --cov-report=html tests/

# 누락된 줄 보기
pytest --cov=myproject --cov-report=term-missing tests/

커버리지 구성

.coveragerc 생성:

[run]
source = myproject
omit =
    */tests/*
    */venv/*
    */__pycache__/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:
    if TYPE_CHECKING:
    @abstractmethod

커버리지 최고 실천

  1. 80% 이상의 커버리지 목표 - 중요한 코드 경로에 대해
  2. 100%에 집착하지 말고 - 의미 있는 테스트에 집중
  3. 경계 사례 테스트 - 단순한 경로만 테스트하지 않음
  4. 보일러플레이트를 커버리지 보고서에서 제외
  5. 커버리지를 가이드로 사용 - 목표로 사용하지 않음

테스트 조직 및 프로젝트 구조

권장 구조

myproject/
├── myproject/
│   ├── __init__.py
│   ├── module1.py
│   ├── module2.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py          # 공유 고정 장치
│   ├── test_module1.py
│   ├── test_module2.py
│   ├── test_utils.py
│   └── integration/
│       ├── __init__.py
│       └── test_integration.py
├── pytest.ini
├── requirements.txt
└── requirements-dev.txt

공유 고정 장치를 위한 conftest.py

# tests/conftest.py
import pytest
from myproject.database import Database

@pytest.fixture(scope="session")
def test_db():
    """세션 전체에 걸친 테스트 데이터베이스"""
    db = Database(":memory:")
    db.create_schema()
    yield db
    db.close()

@pytest.fixture
def clean_db(test_db):
    """각 테스트에 대한 깨끗한 데이터베이스"""
    test_db.clear_all_tables()
    return test_db

pytest.ini 구성

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
    -v
    --strict-markers
    --cov=myproject
    --cov-report=term-missing
    --cov-report=html
markers =
    slow: 느린 테스트로 표시
    integration: 통합 테스트로 표시
    unit: 단위 테스트로 표시

단위 테스트 최고 실천

1. AAA 패턴 따르기

Arrange-Act-Assert는 테스트를 명확하게 만듭니다:

def test_user_creation():
    # Arrange
    username = "john_doe"
    email = "john@example.com"
    
    # Act
    user = User(username, email)
    
    # Assert
    assert user.username == username
    assert user.email == email
    assert user.is_active == True

2. 하나의 어설션당 테스트 (지침, 규칙 아님)

# 좋음: 집중된 테스트
def test_user_username():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"

def test_user_email():
    user = User("john_doe", "john@example.com")
    assert user.email == "john@example.com"

# 또한 허용됨: 관련 어설션
def test_user_creation():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"
    assert user.email == "john@example.com"
    assert isinstance(user.created_at, datetime)

3. 설명적인 테스트 이름 사용

# 나쁨
def test_user():
    pass

# 좋음
def test_user_creation_with_valid_data():
    pass

def test_user_creation_fails_with_invalid_email():
    pass

def test_user_password_is_hashed_after_setting():
    pass

4. 경계 및 경계 사례 테스트

def test_age_validation():
    # 유효한 경우
    assert validate_age(0) == True
    assert validate_age(18) == True
    assert validate_age(120) == True
    
    # 경계 사례
    assert validate_age(-1) == False
    assert validate_age(121) == False
    
    # 경계 사례
    with pytest.raises(TypeError):
        validate_age("18")
    with pytest.raises(TypeError):
        validate_age(None)

5. 테스트의 독립성 유지

# 나쁨: 테스트가 순서에 의존
counter = 0

def test_increment():
    global counter
    counter += 1
    assert counter == 1

def test_increment_again():  # 혼자 실행 시 실패
    global counter
    counter += 1
    assert counter == 2

# 좋음: 테스트가 독립적
def test_increment():
    counter = Counter()
    counter.increment()
    assert counter.value == 1

def test_increment_multiple_times():
    counter = Counter()
    counter.increment()
    counter.increment()
    assert counter.value == 2

6. 구현 세부 사항 테스트하지 않기

# 나쁨: 구현 테스트
def test_sort_uses_quicksort():
    sorter = Sorter()
    assert sorter.algorithm == "quicksort"

# 좋음: 행동 테스트
def test_sort_returns_sorted_list():
    sorter = Sorter()
    result = sorter.sort([3, 1, 2])
    assert result == [1, 2, 3]

7. 실제 세계 사용 사례 테스트

데이터를 처리하거나 변환하는 라이브러리를 테스트할 때 실제 세계 시나리오에 집중하세요. 예를 들어, 웹 크롤링이나 콘텐츠 변환을 작업하는 경우, 우리의 Python으로 HTML을 Markdown으로 변환 가이드를 참조하세요. 이 가이드는 다양한 변환 라이브러리에 대한 테스트 전략 및 벤치마크 비교를 포함합니다.

CI/CD 통합

GitHub Actions 예제

# .github/workflows/tests.yml
name: 테스트

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.9, 3.10, 3.11, 3.12]
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Python ${{ matrix.python-version }} 설정
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: 의존성 설치
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt        
    
    - name: 테스트 실행
      run: |
        pytest --cov=myproject --cov-report=xml        
    
    - name: 커버리지 업로드
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

서버리스 함수 테스트

AWS Lambda 함수나 서버리스 애플리케이션을 테스트할 때 단위 테스트와 함께 통합 테스트 전략을 고려하세요. 우리의 Python과 Terraform을 사용한 듀얼 모드 AWS Lambda 가이드는 서버리스 Python 애플리케이션에 대한 테스트 접근법을 다룹니다. 이 가이드는 Lambda 핸들러, SQS 소비자 및 API Gateway 통합을 테스트하는 방법을 포함합니다.

테스트 최고 실천 체크리스트

  • 코드 작성 전 또는 함께 테스트 작성 (TDD)
  • 테스트를 빠르게 유지 (< 1초당 테스트)
  • 테스트를 독립적이고 고립시킴
  • 설명적인 테스트 이름 사용
  • AAA 패턴 따름 (Arrange-Act-Assert)
  • 경계 및 오류 조건 테스트
  • 외부 의존성 모킹
  • 80% 이상의 코드 커버리지 목표
  • CI/CD 파이프라인에서 테스트 실행
  • 테스트 정기적으로 검토 및 리팩토링
  • 복잡한 테스트 시나리오 문서화
  • 공통 설정을 위한 고정 장치 사용
  • 유사한 테스트 파라메트라이즈
  • 테스트를 간단하고 가독성 있게 유지

일반적인 테스트 패턴

클래스 테스트

class TestUser:
    @pytest.fixture
    def user(self):
        return User("john_doe", "john@example.com")
    
    def test_username(self, user):
        assert user.username == "john_doe"
    
    def test_email(self, user):
        assert user.email == "john@example.com"
    
    def test_full_name(self, user):
        user.first_name = "John"
        user.last_name = "Doe"
        assert user.full_name() == "John Doe"

임시 파일 사용 테스트

import pytest
from pathlib import Path

@pytest.fixture
def temp_file(tmp_path):
    """임시 파일 생성"""
    file_path = tmp_path / "test_file.txt"
    file_path.write_text("test content")
    return file_path

def test_read_file(temp_file):
    content = temp_file.read_text()
    assert content == "test content"

파일 생성 테스트

파일을 생성하는 코드(예: PDF, 이미지, 문서)를 테스트할 때 임시 디렉토리 사용 및 파일 속성 확인:

@pytest.fixture
def temp_output_dir(tmp_path):
    """임시 출력 디렉토리 제공"""
    output_dir = tmp_path / "output"
    output_dir.mkdir()
    return output_dir

def test_pdf_generation(temp_output_dir):
    pdf_path = temp_output_dir / "output.pdf"
    generate_pdf(pdf_path, content="Test")
    
    assert pdf_path.exists()
    assert pdf_path.stat().st_size > 0

PDF 생성 테스트에 대한 포괄적인 예제는 우리의 Python으로 PDF 생성 가이드를 참조하세요. 이 가이드는 다양한 PDF 라이브러리에 대한 테스트 전략을 다룹니다.

모킹 사용 테스트

def test_environment_variable(monkeypatch):
    monkeypatch.setenv("API_KEY", "test_key_123")
    assert os.getenv("API_KEY") == "test_key_123"

def test_module_attribute(monkeypatch):
    monkeypatch.setattr("module.CONSTANT", 42)
    assert module.CONSTANT == 42

유용한 링크 및 자료

관련 자료

Python 기초 및 최고 실천

Python 특정 사용 사례 테스트

서버리스 및 클라우드 테스트


단위 테스트는 Python 개발자에게 필수적인 기술입니다. unittest 또는 pytest를 선택하든 간에, 테스트를 일관되게 작성하고 유지보수 가능하게 하며 개발 워크플로에 통합하는 것이 핵심입니다. 간단한 테스트부터 시작하여, 모킹 및 고정 장치와 같은 고급 기술을 점차 채택하고 커버리지 도구를 사용하여 테스트되지 않은 코드를 식별하세요.