| name | django-tdd |
| description | pytest-django를 이용한 Django 테스트 전략, TDD 방법론, factory_boy, 모킹(mocking), 커버리지 측정 및 Django REST Framework API 테스트 가이드입니다. |
| origin | ECC |
TDD를 이용한 Django 테스트
pytest, factory_boy 및 Django REST Framework를 사용하여 Django 애플리케이션을 위한 테스트 주도 개발(TDD)을 수행합니다.
활성화 시점
- 새로운 Django 애플리케이션을 작성할 때
- Django REST Framework(DRF) API를 구현할 때
- Django 모델, 뷰 및 시리얼라이저를 테스트할 때
- Django 프로젝트를 위한 테스트 인프라를 설정할 때
Django를 위한 TDD 워크플로
Red-Green-Refactor 사이클
def test_user_creation():
user = User.objects.create_user(email='test@example.com', password='testpass123')
assert user.email == 'test@example.com'
assert user.check_password('testpass123')
assert not user.is_staff
설정
pytest 구성
[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: 통합 테스트 표시
테스트용 설정(Settings)
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_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
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='test@example.com',
password='testpass123',
username='testuser'
)
@pytest.fixture
def admin_user(db):
"""관리자 사용자 생성."""
return User.objects.create_superuser(
email='admin@example.com',
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 ():
api_client.force_authenticate(user=user)
api_client
Factory Boy
팩토리 설정
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( obj: obj.name.lower().replace(, ))
description = factory.Faker()
price = fuzzy.FuzzyDecimal(, , )
stock = fuzzy.FuzzyInteger(, )
is_active =
category = factory.SubFactory(CategoryFactory)
created_by = factory.SubFactory(UserFactory)
():
create:
extracted:
tag extracted:
.tags.add(tag)
팩토리 사용법
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
모델 테스트
모델 테스트 예시
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='test@example.com')
assert user.email == 'test@example.com'
assert user.check_password('testpass123')
assert not user.is_staff
assert not user.is_superuser
def test_create_superuser(self, db):
"""관리자 사용자 생성 테스트."""
user = UserFactory(
email='admin@example.com',
is_staff=True,
is_superuser=True
)
assert user.is_staff
assert user.is_superuser
def test_user_str(self, db):
"""사용자 문자열 표현 테스트."""
user = UserFactory(email='test@example.com')
assert str(user) == 'test@example.com'
class TestProductModel:
"""Product 모델 테스트."""
def test_product_creation(self, db):
product = ProductFactory()
product.
product.is_active
product.created_at
():
product = ProductFactory(name=)
product.slug ==
():
product = ProductFactory(price=-)
pytest.raises(ValidationError):
product.full_clean()
():
ProductFactory.create_batch(, is_active=)
ProductFactory.create_batch(, is_active=)
active_count = Product.objects.active().count()
active_count ==
():
product = ProductFactory(stock=)
product.reduce_stock()
product.refresh_from_db()
product.stock ==
pytest.raises(ValueError):
product.reduce_stock()
뷰 테스트
Django 뷰 테스트
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())
response.status_code ==
():
data = {
: ,
: ,
: ,
: ,
: category.,
}
response = authenticated_client.post(reverse(), data)
response.status_code ==
Product.objects.(name=).exists()
DRF API 테스트
시리얼라이저 테스트
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 ():
data = {
: ,
: ,
: ,
}
serializer = ProductSerializer(data=data)
serializer.is_valid()
serializer.errors
():
data = {
: ,
: ,
: -,
}
serializer = ProductSerializer(data=data)
serializer.is_valid()
serializer.errors
API 뷰셋(ViewSet) 테스트
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:
"""제품 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()
data = {: , : }
response = api_client.post(url, data)
response.status_code == status.HTTP_401_UNAUTHORIZED
():
url = reverse()
data = {
: ,
: ,
: ,
: ,
}
response = authenticated_api_client.post(url, data)
response.status_code == status.HTTP_201_CREATED
response.data[] ==
():
product = ProductFactory(created_by=authenticated_api_client.user)
url = reverse(, kwargs={: product.})
data = {: }
response = authenticated_api_client.patch(url, data)
response.status_code == status.HTTP_200_OK
response.data[] ==
():
product = ProductFactory(created_by=authenticated_api_client.user)
url = reverse(, kwargs={: product.})
response = authenticated_api_client.delete(url)
response.status_code == status.HTTP_204_NO_CONTENT
():
ProductFactory(price=)
ProductFactory(price=)
url = reverse()
response = api_client.get(url, {: })
response.status_code == status.HTTP_200_OK
response.data[] ==
():
ProductFactory(name=)
ProductFactory(name=)
url = reverse()
response = api_client.get(url, {: })
response.status_code == status.HTTP_200_OK
response.data[] ==
모킹(Mocking) 및 패칭(Patching)
외부 서비스 모킹
from unittest.mock import patch, Mock
import pytest
class TestPaymentView:
"""모킹된 결제 게이트웨이를 이용한 결제 뷰 테스트."""
@patch('apps.payments.services.stripe')
def test_successful_payment(self, mock_stripe, client, user, product):
"""모킹된 Stripe을 이용한 결제 성공 테스트."""
mock_stripe.Charge.create.return_value = {
'id': 'ch_123',
'status': 'succeeded',
'amount': 9999,
}
client.force_login(user)
response = client.post(reverse('payments:process'), {
'product_id': product.id,
'token': 'tok_visa',
})
assert response.status_code == 302
mock_stripe.Charge.create.assert_called_once()
@patch('apps.payments.services.stripe')
def test_failed_payment(self, mock_stripe, client, user, product):
"""결제 실패 테스트."""
mock_stripe.Charge.create.side_effect = Exception('Card declined')
client.force_login(user)
response = client.post(reverse('payments:process'), {
'product_id': product.id,
'token': 'tok_visa',
})
assert response.status_code ==
response.url
이메일 발송 모킹
from django.core import mail
from django.test import override_settings
@override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')
def test_order_confirmation_email(db, order):
"""주문 확인 이메일 테스트."""
order.send_confirmation_email()
assert len(mail.outbox) == 1
assert order.user.email in mail.outbox[0].to
assert 'Order Confirmation' in mail.outbox[0].subject
통합 테스트(Integration Testing)
전체 흐름 테스트
import pytest
from django.urls import reverse
from tests.factories import UserFactory, ProductFactory
class TestCheckoutFlow:
"""전체 주문 흐름 테스트."""
def test_guest_to_purchase_flow(self, client, db):
"""비회원에서 구매까지의 전체 흐름 테스트."""
response = client.post(reverse('users:register'), {
'email': 'test@example.com',
'password': 'testpass123',
'password_confirm': 'testpass123',
})
assert response.status_code == 302
response = client.post(reverse('users:login'), {
'email': 'test@example.com',
'password': 'testpass123',
})
assert response.status_code == 302
product = ProductFactory(price=100)
response = client.get(reverse('products:detail', kwargs={'slug': product.slug}))
assert response.status_code == 200
response = client.post(reverse('cart:add'), {
'product_id': product.id,
'quantity': 1,
})
response.status_code ==
response = client.get(reverse())
response.status_code ==
product.name response.content.decode()
patch() mock_payment:
mock_payment.return_value =
response = client.post(reverse())
response.status_code ==
Order.objects.(user__email=).exists()
테스트 모범 사례
권장 사항 (DO)
- 팩토리 사용: 객체를 수동으로 생성하는 대신 팩토리를 사용하세요.
- 하나의 테스트에 하나의 단언(Assertion): 테스트의 집중도를 유지하세요.
- 설명적인 테스트 이름:
test_user_cannot_delete_others_post와 같이 작성하세요.
- 에지 케이스 테스트: 빈 입력, None 값, 경계 조건 등을 테스트하세요.
- 외부 서비스 모킹: 외부 API에 의존하지 마세요.
- Fixture 활용: 코드 중복을 제거하세요.
- 권한 테스트: 인가 로직이 제대로 작동하는지 확인하세요.
- 테스트 속도 유지:
--reuse-db 및 --nomigrations 옵션을 사용하세요.
금지 사항 (DON'T)
- Django 내부 로직 테스트 금지: Django 프레임워크 자체의 기능은 신뢰하세요.
- 서드파티 코드 테스트 금지: 외부 라이브러리의 기능은 신뢰하세요.
- 실패하는 테스트 방치 금지: 모든 테스트는 항상 통과해야 합니다.
- 테스트 간 의존성 생성 금지: 테스트는 어떤 순서로 실행되어도 상관없어야 합니다.
- 과도한 모킹 자제: 외부 의존성에 대해서만 모킹하세요.
- 프라이빗 메서드 테스트 금지: 공개 인터페이스를 통해 테스트하세요.
- 프로덕션 데이터베이스 사용 금지: 항상 테스트 전용 데이터베이스를 사용하세요.
커버리지(Coverage)
커버리지 설정
pytest --cov=apps --cov-report=html --cov-report=term-missing
open htmlcov/index.html
커버리지 목표
| 컴포넌트 | 목표 커버리지 |
|---|
| Models | 90%+ |
| Serializers | 85%+ |
| Views | 80%+ |
| Services | 90%+ |
| Utilities | 80%+ |
| Overall | 80%+ |
빠른 참조
| 패턴 | 용도 |
|---|
@pytest.mark.django_db | 데이터베이스 접근 활성화 |
client | Django 테스트 클라이언트 |
api_client | DRF API 클라이언트 |
factory.create_batch(n) | 여러 객체 생성 |
patch('module.function') | 외부 의존성 모킹 |
override_settings | 일시적으로 설정(settings) 변경 |
force_authenticate() | 테스트에서 인증 절차 우회 |
assertRedirects | 리다이렉트 여부 확인 |
assertTemplateUsed | 사용된 템플릿 확인 |
mail.outbox | 발송된 이메일 확인 |
기억하세요: 테스트는 곧 문서입니다. 좋은 테스트는 코드가 어떻게 작동해야 하는지 설명해 줍니다. 단순하고 읽기 쉬우며 유지 관리가 용이한 테스트를 작성하세요.