Django Uygulamaları için pytest ile Test Yazımı
Prodüksiyona aldığınız o Django uygulaması çalışıyor, her şey güzel görünüyor. Sonra bir gün ekipten biri masum görünen bir refaktör yapıyor, hiçbir şey patlamıyor, testler… eh, zaten test yoktu. Üç gün sonra müşteri arıyor: “Siparişler neden kayboldu?” İşte tam bu noktada pytest’i ciddiye almaya başlıyoruz.
Django projeleri için test yazmak, teorik olarak hep yapılması gereken ama pratikte sıklıkla ertelenen bir şey. Bu yazıda size unittest’in üstüne neden pytest tercih ettiğimi, Django’yu pytest ile nasıl doğru konfigüre edeceğinizi ve gerçek dünya senaryolarında nasıl anlamlı testler yazacağınızı aktaracağım. Sıfırdan kurulumdan başlayıp fixture yönetimi, mock kullanımı ve CI entegrasyonuna kadar gideceğiz.
Neden pytest, Neden unittest Değil?
Django kendi test altyapısını unittest üzerine inşa etmiş. python manage.py test komutunu çalıştırdığınızda arkada unittest.TestCase döngüsü dönüyor. Bu çalışır, evet. Ama pytest’in sunduğu şeylerle karşılaştırınca yetersiz kalıyor.
pytest’in öne çıkan avantajları şunlar:
- Fixture sistemi:
setUp/tearDownkaosundan kurtulursunuz, bağımlılıkları temiz injection ile yönetirsiniz - Parametrize testler: Aynı test mantığını farklı input’larla çalıştırmak tek satır
- Daha okunaklı assertion hatası:
assert a == byazdığınızda, hata mesajında her iki değeri de gösterir - Plugin ekosistemi:
pytest-django,pytest-cov,pytest-xdistgibi araçlar hayatı kolaylaştırıyor - Daha az boilerplate: Class yazmak zorunda değilsiniz, düz fonksiyonlar yeterli
Kurulum ve Konfigürasyon
Önce gerekli paketleri yükleyelim:
pip install pytest pytest-django pytest-cov factory-boy faker
Projenizin kök dizininde pytest.ini veya pyproject.toml içinde konfigürasyonu tanımlamanız gerekiyor. Ben pyproject.toml tercih ediyorum çünkü her şey tek yerde toplanıyor:
# pyproject.toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "myproject.settings.test"
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"--reuse-db",
"--strict-markers",
"-v",
]
markers = [
"slow: Bu testler yavaş çalışır, CI'da ayrı aşamada koşulur",
"integration: Dış servis bağlantısı gerektiren testler",
"unit: Izole birim testleri",
]
Test için ayrı bir settings dosyası kullanmak kritik. Prodüksiyon veritabanına bağlanmak ya da gerçek e-posta göndermek istemezsiniz:
# myproject/settings/test.py
from .base import *
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
PASSWORD_HASHERS satırı küçük ama önemli bir optimizasyon. Test ortamında bcrypt’in yüksek işlem yükü gerekmiyor, MD5 ile testler çok daha hızlı çalışıyor.
Dizin Yapısı
Testleri nereye koyacağınız konusunda iki yaklaşım var. Uygulama klasörünün içine tests/ dizini açmak ya da proje kökünde merkezi bir tests/ klasörü kullanmak. Ben uygulama içi yapıyı tercih ediyorum çünkü kodu ve testini yan yana görmek bakımı kolaylaştırıyor:
myapp/
├── models.py
├── views.py
├── serializers.py
├── services.py
└── tests/
├── __init__.py
├── conftest.py
├── test_models.py
├── test_views.py
├── test_services.py
└── factories.py
conftest.py pytest’e özel bir dosya. Bu dosyadaki fixture’lar aynı dizindeki ve alt dizinlerdeki tüm testler tarafından kullanılabiliyor, import etmenize gerek yok.
Factory Boy ile Test Verisi Yönetimi
Testlerde en çok zaman kaybettiren şeylerden biri veritabanına test verisi oluşturmak. User.objects.create(...) satırlarını her test fonksiyonuna yazmak hem yorucu hem de kırılgan. Factory Boy bu sorunu çözüyor.
# myapp/tests/factories.py
import factory
from factory.django import DjangoModelFactory
from django.contrib.auth import get_user_model
from myapp.models import Order, Product, Customer
User = get_user_model()
class UserFactory(DjangoModelFactory):
class Meta:
model = User
username = factory.Sequence(lambda n: f"kullanici_{n}")
email = factory.LazyAttribute(lambda obj: f"{obj.username}@test.com")
password = factory.PostGenerationMethodCall("set_password", "test1234")
is_active = True
class CustomerFactory(DjangoModelFactory):
class Meta:
model = Customer
user = factory.SubFactory(UserFactory)
phone = factory.Faker("phone_number", locale="tr_TR")
address = factory.Faker("address", locale="tr_TR")
class ProductFactory(DjangoModelFactory):
class Meta:
model = Product
name = factory.Faker("word", locale="tr_TR")
price = factory.Faker(
"pydecimal", left_digits=3, right_digits=2, positive=True
)
stock = factory.Faker("random_int", min=0, max=100)
is_active = True
class OrderFactory(DjangoModelFactory):
class Meta:
model = Order
customer = factory.SubFactory(CustomerFactory)
status = "pending"
total_amount = factory.Faker(
"pydecimal", left_digits=4, right_digits=2, positive=True
)
conftest.py ile Fixture Yönetimi
# myapp/tests/conftest.py
import pytest
from rest_framework.test import APIClient
from myapp.tests.factories import UserFactory, CustomerFactory, ProductFactory
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def user(db):
return UserFactory()
@pytest.fixture
def admin_user(db):
return UserFactory(is_staff=True, is_superuser=True)
@pytest.fixture
def authenticated_client(api_client, user):
api_client.force_authenticate(user=user)
return api_client
@pytest.fixture
def admin_client(api_client, admin_user):
api_client.force_authenticate(user=admin_user)
return api_client
@pytest.fixture
def customer(db, user):
return CustomerFactory(user=user)
@pytest.fixture
def active_products(db):
return ProductFactory.create_batch(5, is_active=True)
@pytest.fixture
def out_of_stock_product(db):
return ProductFactory(stock=0, is_active=True)
db fixture’ı pytest-django‘nun sağladığı özel bir fixture. Veritabanı erişimi gerektiren testlerde bunu kullanmazsanız pytest-django sizi uyarıyor. Bu kasıtlı bir tasarım kararı: veritabanına dokunan testleri açıkça işaretlemenizi zorluyor.
Model Testleri
Model testleri unit test kategorisinde değerlendirilebilir ama Django modelleri veritabanıyla konuştuğu için db fixture’ı gerekiyor:
# myapp/tests/test_models.py
import pytest
from decimal import Decimal
from myapp.tests.factories import OrderFactory, ProductFactory
@pytest.mark.django_db
class TestOrderModel:
def test_siparis_toplam_tutari_hesaplar(self):
order = OrderFactory(total_amount=Decimal("250.00"))
assert order.total_amount == Decimal("250.00")
def test_stok_disi_urun_satin_alinamaz(self):
product = ProductFactory(stock=0)
assert product.is_purchasable() is False
def test_stok_varsa_urun_satin_alinabilir(self):
product = ProductFactory(stock=10)
assert product.is_purchasable() is True
def test_siparis_iptal_edildiginde_stok_geri_yuklenir(self):
product = ProductFactory(stock=5)
order = OrderFactory(status="confirmed")
order.items.create(product=product, quantity=2, unit_price=product.price)
initial_stock = product.stock
order.cancel()
product.refresh_from_db()
assert product.stock == initial_stock + 2
@pytest.mark.django_db
def test_musteri_aktif_siparislerini_getirir():
from myapp.tests.factories import CustomerFactory, OrderFactory
customer = CustomerFactory()
OrderFactory(customer=customer, status="confirmed")
OrderFactory(customer=customer, status="confirmed")
OrderFactory(customer=customer, status="cancelled")
active_orders = customer.get_active_orders()
assert active_orders.count() == 2
View ve API Testleri
DRF kullanan bir projede API endpoint’lerini test etmek kritik. APIClient ile hem authentication hem de response’u kontrol edebilirsiniz:
# myapp/tests/test_views.py
import pytest
from django.urls import reverse
from rest_framework import status
from myapp.tests.factories import OrderFactory, ProductFactory
@pytest.mark.django_db
class TestOrderAPI:
def test_anonim_kullanici_siparis_listesine_erisemez(self, api_client):
url = reverse("order-list")
response = api_client.get(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_kullanici_kendi_siparislerini_gorebilir(
self, authenticated_client, user, customer
):
orders = OrderFactory.create_batch(3, customer=customer)
url = reverse("order-list")
response = authenticated_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 3
def test_kullanici_baskasinin_siparisini_goremiyor(
self, authenticated_client, db
):
from myapp.tests.factories import CustomerFactory
diger_musteri = CustomerFactory()
OrderFactory(customer=diger_musteri)
url = reverse("order-list")
response = authenticated_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 0
def test_gecersiz_urun_ile_siparis_olusturulamaz(
self, authenticated_client
):
url = reverse("order-list")
payload = {
"items": [
{"product_id": 99999, "quantity": 1}
]
}
response = authenticated_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_stok_disi_urun_siparise_eklenemez(authenticated_client, out_of_stock_product):
url = reverse("order-list")
payload = {
"items": [
{"product_id": out_of_stock_product.id, "quantity": 1}
]
}
response = authenticated_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "stok" in response.data["items"][0]["non_field_errors"][0].lower()
Service Layer Testleri ve Mock Kullanımı
Dış servislere bağımlı kodları test etmek için unittest.mock veya pytest-mock kullanabilirsiniz. Ödeme entegrasyonu gibi şeylerde gerçek API çağrısı yapmamalısınız:
# myapp/tests/test_services.py
import pytest
from unittest.mock import patch, MagicMock
from decimal import Decimal
from myapp.services.payment import PaymentService
from myapp.services.order import OrderService
from myapp.tests.factories import OrderFactory, CustomerFactory
@pytest.mark.django_db
class TestPaymentService:
@patch("myapp.services.payment.iyzipay.Payment.create")
def test_basarili_odeme_siparisi_tamamlar(self, mock_payment, customer):
mock_response = MagicMock()
mock_response.status = "success"
mock_response.paymentId = "PAY_12345"
mock_payment.return_value = mock_response
order = OrderFactory(customer=customer, status="pending")
service = PaymentService()
result = service.process_payment(
order=order,
card_token="tok_test_visa",
amount=order.total_amount
)
assert result.success is True
order.refresh_from_db()
assert order.status == "confirmed"
@patch("myapp.services.payment.iyzipay.Payment.create")
def test_basarisiz_odeme_siparisi_iptal_eder(self, mock_payment, customer):
mock_response = MagicMock()
mock_response.status = "failure"
mock_response.errorMessage = "Yetersiz bakiye"
mock_payment.return_value = mock_response
order = OrderFactory(customer=customer, status="pending")
service = PaymentService()
result = service.process_payment(
order=order,
card_token="tok_test_declined",
amount=order.total_amount
)
assert result.success is False
assert "Yetersiz bakiye" in result.error_message
order.refresh_from_db()
assert order.status == "payment_failed"
@patch("myapp.services.notification.send_email_task.delay")
@patch("myapp.services.payment.iyzipay.Payment.create")
def test_basarili_odeme_sonrasi_email_gonderilir(
self, mock_payment, mock_email, customer
):
mock_payment.return_value = MagicMock(status="success", paymentId="PAY_99")
order = OrderFactory(customer=customer, status="pending")
service = PaymentService()
service.process_payment(order=order, card_token="tok_test", amount=order.total_amount)
mock_email.assert_called_once_with(
template="order_confirmed",
recipient=customer.user.email,
context={"order_id": order.id}
)
Parametrize ile Çoklu Senaryo Testleri
Aynı mantığı farklı input’larla test etmek istediğinizde @pytest.mark.parametrize kurtarıcı oluyor:
# myapp/tests/test_validators.py
import pytest
from myapp.validators import validate_turkish_phone, validate_tc_kimlik
@pytest.mark.parametrize("phone,expected_valid", [
("05321234567", True),
("5321234567", True),
("+905321234567", True),
("0532 123 45 67", True),
("532123456", False), # Kısa numara
("05321234", False), # Çok kısa
("1234567890", False), # 0 ile başlamıyor
("abcdefghijk", False), # Harf içeriyor
("", False), # Boş
])
def test_turkce_telefon_dogrulamasi(phone, expected_valid):
if expected_valid:
assert validate_turkish_phone(phone) is True
else:
with pytest.raises(ValueError):
validate_turkish_phone(phone)
@pytest.mark.parametrize("tc,expected", [
("10000000146", True), # Geçerli TC
("00000000000", False), # Sıfırla başlıyor
("12345678901", False), # Algoritma tutmuyor
("1234567890", False), # 10 hane
("123456789012", False), # 12 hane
])
def test_tc_kimlik_dogrulamasi(tc, expected):
result = validate_tc_kimlik(tc)
assert result == expected
Coverage Raporu ve CI Entegrasyonu
Testleri yazdınız, şimdi ne kadar kodu kapsadığınızı ölçelim:
# Coverage ile testleri çalıştır
pytest --cov=myapp --cov-report=html --cov-report=term-missing
# Sadece belirli markerları çalıştır
pytest -m "unit" -v
# Belirli bir dosyayı çalıştır
pytest myapp/tests/test_services.py -v
# Başarısız testi verbose modda çalıştır
pytest -x --tb=long
# Paralel çalıştır (pytest-xdist)
pytest -n auto
GitHub Actions için basit bir workflow:
# .github/workflows/test.yml
name: Django Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Python kur
uses: actions/setup-python@v4
with:
python-version: "3.11"
cache: "pip"
- name: Bağımlılıkları yükle
run: |
pip install -r requirements/test.txt
- name: Testleri çalıştır
env:
DATABASE_URL: postgresql://testuser:testpass@localhost/testdb
DJANGO_SETTINGS_MODULE: myproject.settings.test
run: |
pytest --cov=myapp --cov-report=xml -v
- name: Coverage yükle
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
Sık Yapılan Hatalar
Pratik deneyimden çıkan birkaç nokta:
- Her test bağımsız olmalı: Testler arasında state taşımayın. Bir testte oluşturduğunuz objeye başka bir testte güvenmek kırılgan yapı demek
- Gerçek şeyleri test edin:
assert response.status_code == 200yazmak güzel, ama response body’de dönen veri de doğru mu? - Mock’u aşırı kullanmayın: Her şeyi mock’ladığınızda aslında neyi test ettiğinizi kaybedebilirsiniz
- Test isimlerini açıklayıcı yazın:
test_orderdeğil,test_iptal_edilen_siparis_stoku_geri_yukler - Slow testleri işaretleyin:
@pytest.mark.slowile ayırın, PR bazlı CI’da sadece hızlı testleri çalıştırın
Sonuç
pytest ve pytest-django ikilisi, Django projelerinde test yazmayı hem keyifli hem de sürdürülebilir kılıyor. Factory Boy ile test verisi yönetimi, fixture sistemi ile bağımlılık injection ve parametrize testler bir araya gelince kapsamlı bir test altyapısı kurabiliyorsunuz.
Eğer sıfırdan başlıyorsanız, şu sırayı öneririm: Önce konfigürasyonu ve conftest.py‘yi kurun, ardından mevcut en kritik servis fonksiyonunuzun testini yazın. Mükemmel coverage hedeflemek yerine, prod’da en çok acı çektiğiniz yerleri önce test edin. Coverage zamanla artar; ama ilk test olmadan hiçbir zaman başlamış olmazsınız.
Prodüksiyona çıkmadan önce testlerin yeşil olduğunu görmek, o “acaba bir şeyi kırdım mı?” hissinden sizi kurtarır. Ve bir gün refaktör yapan ekip arkadaşınız testleri kırdığında, problemi o müşteri aramasından değil CI ekranından öğrenirsiniz.
