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/tearDown kaosundan 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 == b yazdığınızda, hata mesajında her iki değeri de gösterir
  • Plugin ekosistemi: pytest-django, pytest-cov, pytest-xdist gibi 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 == 200 yazmak 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_order değil, test_iptal_edilen_siparis_stoku_geri_yukler
  • Slow testleri işaretleyin: @pytest.mark.slow ile 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.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir