Django TDD 테스트
pytest, factory_boy, Django REST Framework를 사용한 Django 애플리케이션의 테스트 주도 개발.
활성화 시점
- 새로운 Django 애플리케이션 개발
- Django REST Framework API 구현
- Django 모델, 뷰, 시리얼라이저 테스트
- Django 프로젝트의 테스트 인프라 구축
Django TDD 워크플로우
레드-그린-리팩터 사이클
# 1단계: RED - 실패하는 테스트 작성
def test_user_creation():
user = User.objects.create_user(email='[email protected]', password='testpass123')
assert user.email == '[email protected]'
assert user.check_password('testpass123')
assert not user.is_staff
# 2단계: GREEN - 테스트 통과시키기
# User 모델 또는 팩토리 생성
# 3단계: REFACTOR - 테스트를 통과하는 상태에서 개선
설정
pytest 설정
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.test
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--reuse-db
--nomigrations
--cov=apps
--cov-report=html
--cov-report=term-missing
--strict-markers
markers =
slow: 느린 테스트 표시
integration: 통합 테스트 표시
테스트 설정
# config/settings/test.py
from .base import *
DEBUG = True
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
# 속도를 위해 마이그레이션 비활성화
class DisableMigrations:
def __contains__(self, item):
return True
def __getitem__(self, item):
return None
MIGRATION_MODULES = DisableMigrations()
# 더 빠른 비밀번호 해싱
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
# 이메일 백엔드
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Celery 항상 즉시 실행
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
conftest.py
# tests/conftest.py
import pytest
from django.utils import timezone
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture(autouse=True)
def timezone_settings(settings):
"""일관된 시간대 유지."""
settings.TIME_ZONE = 'UTC'
@pytest.fixture
def user(db):
"""테스트 사용자 생성."""
return User.objects.create_user(
email='[email protected]',
password='testpass123',
username='testuser'
)
@pytest.fixture
def admin_user(db):
"""관리자 사용자 생성."""
return User.objects.create_superuser(
email='[email protected]',
password='adminpass123',
username='admin'
)
@pytest.fixture
def authenticated_client(client, user):
"""인증된 클라이언트 반환."""
client.force_login(user)
return client
@pytest.fixture
def api_client():
"""DRF API 클라이언트 반환."""
from rest_framework.test import APIClient
return APIClient()
@pytest.fixture
def authenticated_api_client(api_client, user):
"""인증된 API 클라이언트 반환."""
api_client.force_authenticate(user=user)
return api_client
Factory Boy
팩토리 설정
# tests/factories.py
import factory
from factory import fuzzy
from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from apps.products.models import Product, Category
User = get_user_model()
class UserFactory(factory.django.DjangoModelFactory):
"""User 모델 팩토리."""
class Meta:
model = User
email = factory.Sequence(lambda n: f"user{n}@example.com")
username = factory.Sequence(lambda n: f"user{n}")
password = factory.PostGenerationMethodCall('set_password', 'testpass123')
first_name = factory.Faker('first_name')
last_name = factory.Faker('last_name')
is_active = True
class CategoryFactory(factory.django.DjangoModelFactory):
"""Category 모델 팩토리."""
class Meta:
model = Category
name = factory.Faker('word')
slug = factory.LazyAttribute(lambda obj: obj.name.lower())
description = factory.Faker('text')
class ProductFactory(factory.django.DjangoModelFactory):
"""Product 모델 팩토리."""
class Meta:
model = Product
name = factory.Faker('sentence', nb_words=3)
slug = factory.LazyAttribute(lambda obj: obj.name.lower().replace(' ', '-'))
description = factory.Faker('text')
price = fuzzy.FuzzyDecimal(10.00, 1000.00, 2)
stock = fuzzy.FuzzyInteger(0, 100)
is_active = True
category = factory.SubFactory(CategoryFactory)
created_by = factory.SubFactory(UserFactory)
@factory.post_generation
def tags(self, create, extracted, **kwargs):
"""제품에 태그 추가."""
if not create:
return
if extracted:
for tag in extracted:
self.tags.add(tag)
팩토리 사용
# tests/test_models.py
import pytest
from tests.factories import ProductFactory, UserFactory
def test_product_creation():
"""팩토리를 사용한 제품 생성 테스트."""
product = ProductFactory(price=100.00, stock=50)
assert product.price == 100.00
assert product.stock == 50
assert product.is_active is True
def test_product_with_tags():
"""태그가 있는 제품 테스트."""
tags = [TagFactory(name='electronics'), TagFactory(name='new')]
product = ProductFactory(tags=tags)
assert product.tags.count() == 2
def test_multiple_products():
"""여러 제품 생성 테스트."""
products = ProductFactory.create_batch(10)
assert len(products) == 10
모델 테스트
모델 테스트
# tests/test_models.py
import pytest
from django.core.exceptions import ValidationError
from tests.factories import UserFactory, ProductFactory
class TestUserModel:
"""User 모델 테스트."""
def test_create_user(self, db):
"""일반 사용자 생성 테스트."""
user = UserFactory(email='[email protected]')
assert user.email == '[email protected]'
assert user.check_password('testpass123')
assert not user.is_staff
assert not user.is_superuser
def test_create_superuser(self, db):
"""슈퍼유저 생성 테스트."""
user = UserFactory(
email='[email protected]',
is_staff=True,
is_superuser=True
)
assert user.is_staff
assert user.is_superuser
def test_user_str(self, db):
"""사용자 문자열 표현 테스트."""
user = UserFactory(email='[email protected]')
assert str(user) == '[email protected]'
class TestProductModel:
"""Product 모델 테스트."""
def test_product_creation(self, db):
"""제품 생성 테스트."""
product = ProductFactory()
assert product.id is not None
assert product.is_active is True
assert product.created_at is not None
def test_product_slug_generation(self, db):
"""자동 슬러그 생성 테스트."""
product = ProductFactory(name='Test Product')
assert product.slug == 'test-product'
def test_product_price_validation(self, db):
"""가격 음수 방지 테스트."""
product = ProductFactory(price=-10)
with pytest.raises(ValidationError):
product.full_clean()
def test_product_manager_active(self, db):
"""활성 매니저 메서드 테스트."""
ProductFactory.create_batch(5, is_active=True)
ProductFactory.create_batch(3, is_active=False)
active_count = Product.objects.active().count()
assert active_count == 5
def test_product_stock_management(self, db):
"""재고 관리 테스트."""
product = ProductFactory(stock=10)
product.reduce_stock(5)
product.refresh_from_db()
assert product.stock == 5
with pytest.raises(ValueError):
product.reduce_stock(10) # 재고 부족
뷰 테스트
Django 뷰 테스트
# tests/test_views.py
import pytest
from django.urls import reverse
from tests.factories import ProductFactory, UserFactory
class TestProductViews:
"""제품 뷰 테스트."""
def test_product_list(self, client, db):
"""제품 목록 뷰 테스트."""
ProductFactory.create_batch(10)
response = client.get(reverse('products:list'))
assert response.status_code == 200
assert len(response.context['products']) == 10
def test_product_detail(self, client, db):
"""제품 상세 뷰 테스트."""
product = ProductFactory()
response = client.get(reverse('products:detail', kwargs={'slug': product.slug}))
assert response.status_code == 200
assert response.context['product'] == product
def test_product_create_requires_login(self, client, db):
"""제품 생성 시 로그인 필요 테스트."""
response = client.get(reverse('products:create'))
assert response.status_code == 302
assert response.url.startswith('/accounts/login/')
def test_product_create_authenticated(self, authenticated_client, db):
"""인증된 사용자의 제품 생성 테스트."""
response = authenticated_client.get(reverse('products:create'))
assert response.status_code == 200
def test_product_create_post(self, authenticated_client, db, category):
"""POST로 제품 생성 테스트."""
data = {
'name': 'Test Product',
'description': 'A test product',
'price': '99.99',
'stock': 10,
'category': category.id,
}
response = authenticated_client.post(reverse('products:create'), data)
assert response.status_code == 302
assert Product.objects.filter(name='Test Product').exists()
DRF API 테스트
시리얼라이저 테스트
# tests/test_serializers.py
import pytest
from rest_framework.exceptions import ValidationError
from apps.products.serializers import ProductSerializer
from tests.factories import ProductFactory
class TestProductSerializer:
"""ProductSerializer 테스트."""
def test_serialize_product(self, db):
"""제품 직렬화 테스트."""
product = ProductFactory()
serializer = ProductSerializer(product)
data = serializer.data
assert data['id'] == product.id
assert data['name'] == product.name
assert data['price'] == str(product.price)
def test_deserialize_product(self, db):
"""제품 데이터 역직렬화 테스트."""
data = {
'name': 'Test Product',
'description': 'Test description',
'price': '99.99',
'stock': 10,
'category': 1,
}
serializer = ProductSerializer(data=data)
assert serializer.is_valid()
product = serializer.save()
assert product.name == 'Test Product'
assert float(product.price) == 99.99
def test_price_validation(self, db):
"""가격 검증 테스트."""
data = {
'name': 'Test Product',
'price': '-10.00',
'stock': 10,
}
serializer = ProductSerializer(data=data)
assert not serializer.is_valid()
assert 'price' in serializer.errors
def test_stock_validation(self, db):
"""재고 음수 방지 테스트."""
data = {
'name': 'Test Product',
'price': '99.99',
'stock': -5,
}
serializer = ProductSerializer(data=data)
assert not serializer.is_valid()
assert 'stock' in serializer.errors
API ViewSet 테스트
# tests/test_api.py
import pytest
from rest_framework.test import APIClient
from rest_framework import status
from django.urls import reverse
from tests.factories import ProductFactory, UserFactory
class TestProductAPI:
"""Product API 엔드포인트 테스트."""
@pytest.fixture
def api_client(self):
"""API 클라이언트 반환."""
return APIClient()
def test_list_products(self, api_client, db):
"""제품 목록 조회 테스트."""
ProductFactory.create_batch(10)
url = reverse('api:product-list')
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 10
def test_retrieve_product(self, api_client, db):
"""단일 제품 조회 테스트."""
product = ProductFactory()
url = reverse('api:product-detail', kwargs={'pk': product.id})
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data['id'] == product.id
def test_create_product_unauthorized(self, api_client, db):
"""인증 없이 제품 생성 테스트."""
url = reverse('api:product-list')
data = {'name': 'Test Product', 'price': '99.99'}
response = api_client.post(url, data)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_create_product_authorized(self, authenticated_api_client, db):
"""인증된 사용자의 제품 생성 테스트."""
url = reverse('api:product-list')
data = {
'name': 'Test Product',
'description': 'Test',
'price': '99.99',
'stock': 10,
}
response = authenticated_api_client.post(url, data)
assert response.status_code == status.HTTP_201_CREATED
assert response.data['name'] == 'Test Product'
def test_update_product(self, authenticated_api_client, db):
"""제품 업데이트 테스트."""
product = ProductFactory(created_by=authenticated_api_client.user)
url = reverse('api:product-detail', kwargs={'pk': product.id})
data = {'name': 'Updated Product'}
response = authenticated_api_client.patch(url, data)
assert response.status_code == status.HTTP_200_OK
assert response.data['name'] == 'Updated Product'
def test_delete_product(self, authenticated_api_client, db):
"""제품 삭제 테스트."""
product = ProductFactory(created_by=authenticated_api_client.user)
url = reverse('api:product-detail', kwargs={'pk': product.id})
response = authenticated_api_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Product.objects.filter(id=product.id).exists()
def test_list_products_pagination(self, api_client, db):
"""제품 목록 페이지네이션 테스트."""
ProductFactory.create_batch(25)
url = reverse('api:product-list')
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 25
assert len(response.data['results']) == 20 # page_size
def test_filter_products_by_category(self, api_client, db):
"""카테고리별 제품 필터링 테스트."""
category = CategoryFactory()
ProductFactory.create_batch(5, category=category)
ProductFactory.create_batch(3) # 다른 카테고리
url = reverse('api:product-list')
response = api_client.get(url, {'category': category.id})
assert response.status_code == status.HTTP_200_OK
assert len(response.data['results']) == 5
def test_search_products(self, api_client, db):
"""제품 검색 테스트."""
ProductFactory(name='Laptop Pro')
ProductFactory(name='Tablet')
url = reverse('api:product-list')
response = api_client.get(url, {'search': 'Laptop'})
assert response.status_code == status.HTTP_200_OK
assert len(response.data['results']) == 1
assert response.data['results'][0]['name'] == 'Laptop Pro'
모의 객체(Mocking)
unittest.mock 사용
# tests/test_services.py
import pytest
from unittest.mock import patch, MagicMock
from apps.payments.services import PaymentService
class TestPaymentService:
"""PaymentService 테스트."""
@patch('apps.payments.services.stripe.Charge.create')
def test_process_payment_success(self, mock_charge, db):
"""성공적인 결제 처리 테스트."""
mock_charge.return_value = MagicMock(
id='ch_123',
status='succeeded',
amount=1000
)
result = PaymentService.process_payment(
user=UserFactory(),
amount=1000,
token='tok_visa'
)
assert result['status'] == 'succeeded'
assert result['charge_id'] == 'ch_123'
mock_charge.assert_called_once()
@patch('apps.payments.services.stripe.Charge.create')
def test_process_payment_failure(self, mock_charge, db):
"""실패한 결제 처리 테스트."""
from stripe.error import CardError
mock_charge.side_effect = CardError(
message='카드가 거부되었습니다',
param='number',
code='card_declined'
)
result = PaymentService.process_payment(
user=UserFactory(),
amount=1000,
token='tok_visa_declined'
)
assert result['status'] == 'failed'
assert '카드가 거부되었습니다' in result['error']
@patch('apps.payments.services.send_email')
def test_send_payment_confirmation(self, mock_send_email, db):
"""결제 확인 이메일 전송 테스트."""
user = UserFactory()
PaymentService.send_confirmation(user, amount=1000)
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args
assert user.email in call_args[0] # 수신자 확인
커버리지
pytest-cov 사용
# 전체 테스트 실행 및 커버리지 보고서 생성
pytest --cov=apps --cov-report=html --cov-report=term-missing
# 특정 앱만 테스트
pytest apps/products/tests/ --cov=apps.products
# 느린 테스트 제외
pytest -m "not slow"
# 실패 시 중단
pytest -x
# 마지막 실패 재실행
pytest --lf
커스텀 마커
# conftest.py 또는 tests/plugins.py에 정의
import pytest
def pytest_configure(config):
config.addinivalue_line(
"markers",
"slow: 느려서 자주 실행되지 않아야 하는 테스트"
)
config.addinivalue_line(
"markers",
"integration: 외부 서비스에 의존하는 통합 테스트"
)
config.addinivalue_line(
"markers",
"smoke: 배포 전 기본 기능을 확인하는 스모크 테스트"
)
마커 사용
import pytest
@pytest.mark.slow
def test_heavy_report_generation():
"""리소스를 많이 사용하는 보고서 생성 테스트."""
...
@pytest.mark.integration
def test_external_api_call():
"""외부 API 호출을 포함하는 테스트."""
...
@pytest.mark.smoke
def test_homepage_loads():
"""홈페이지가 정상 로드되는지 확인하는 스모크 테스트."""
...
유용한 커맨드
# 가상환경 생성 및 활성화
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
# 의존성 설치
pip install django pytest pytest-django factory-boy pytest-cov
# 마이그레이션 생성
python manage.py makemigrations
# 마이그레이션 적용
python manage.py migrate
# 테스트 데이터베이스 생성
python manage.py test --keepdb
# 특정 테스트 클래스 또는 메서드 실행
pytest tests/test_models.py::TestProductModel::test_product_creation -v
최고의 관행
- 항상 먼저 실패하는 테스트를 작성하세요: Red-Green-Refactor 사이클을 따르세요.
- 팩토리를 사용하세요: factory_boy로 테스트 데이터를 깔끔하게 관리하세요.
- 모델 로직을 테스트하세요: 커스텀 메서드, 속성, 매니저를 확인하세요.
- API 엔드포인트를 테스트하세요: 인증, 권한, 응답 형식을 검증하세요.
- 모의 객체를 현명하게 사용하세요: 외부 서비스는 모의 처리하고 내부 로직은 집중적으로 테스트하세요.
- 테스트를 빠르게 유지하세요: SQLite 메모리 데이터베이스와 마이그레이션 비활성화를 사용하세요.
- 커버리지를 추적하세요: 하지만 100%를 목표로 하지 말고 중요한 경로에 집중하세요.
- 테스트를 문서화하세요: 각 테스트가 무엇을 검증하는지 명확하게 설명하세요.