파이썬에서의 단위 테스트: 예제를 포함한 완전 가이드
pytest를 사용한 Python 테스트, TDD, 모킹 및 커버리지
단위 테스트는 프로젝트가 발전하면서도 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
커버리지 최고 실천
- 80% 이상의 커버리지 목표 - 중요한 코드 경로에 대해
- 100%에 집착하지 말고 - 의미 있는 테스트에 집중
- 경계 사례 테스트 - 단순한 경로만 테스트하지 않음
- 보일러플레이트를 커버리지 보고서에서 제외
- 커버리지를 가이드로 사용 - 목표로 사용하지 않음
테스트 조직 및 프로젝트 구조
권장 구조
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
유용한 링크 및 자료
- pytest 문서
- unittest 문서
- pytest-cov 플러그인
- pytest-mock 플러그인
- unittest.mock 문서
- 예제로 배우는 테스트 주도 개발 - Kent Beck
- pytest로 Python 테스트 - Brian Okken
관련 자료
Python 기초 및 최고 실천
- Python 체크리스트 - Python 구문, 데이터 구조 및 일반 패턴에 대한 포괄적 참조
Python 특정 사용 사례 테스트
- Ollama와 Python 통합 - LLM 통합 및 비동기 AI 서비스 호출에 대한 테스트 전략
- Python으로 HTML을 Markdown으로 변환 - 웹 크롤링 및 콘텐츠 변환 라이브러리에 대한 테스트 접근법
- Python으로 PDF 생성 - PDF 생성 및 파일 출력에 대한 테스트 전략
서버리스 및 클라우드 테스트
- Python과 Terraform을 사용한 듀얼 모드 AWS Lambda - Lambda 함수, SQS 소비자 및 API Gateway 통합에 대한 테스트
단위 테스트는 Python 개발자에게 필수적인 기술입니다. unittest 또는 pytest를 선택하든 간에, 테스트를 일관되게 작성하고 유지보수 가능하게 하며 개발 워크플로에 통합하는 것이 핵심입니다. 간단한 테스트부터 시작하여, 모킹 및 고정 장치와 같은 고급 기술을 점차 채택하고 커버리지 도구를 사용하여 테스트되지 않은 코드를 식별하세요.