Python ile Integration Test Yazımı ve Veritabanı Testleri

Prodüksiyonda bir şey patladığında genellikle ilk suçlanan birim testleridir. “Testler geçiyor ama sistem çalışmıyor” cümlesi her sysadmin’in ve backend geliştiricinin kâbusudur. Bu noktada integration testlerin önemi ortaya çıkıyor. Birim testler tek bir fonksiyonu izole ederek test eder, integration testler ise birden fazla bileşenin birlikte doğru çalışıp çalışmadığını sorgular. Veritabanı, mesaj kuyruğu, harici API, dosya sistemi… Hepsinin bir arada düzgün davranması gerekir.

Bu yazıda pytest ekosistemi üzerine odaklanacağız. Gerçek dünyadan örnekler, gerçek hata senaryoları ve “bunu neden böyle yapıyoruz” açıklamalarıyla dolu bir rehber olacak. Teorik değil, pratik.

Integration Test Nedir, Birim Testten Farkı Ne?

Birim test yazarken dışarıyı mocklamak standart bir pratik. Veritabanı çağrısını mocklarsın, HTTP isteğini mocklarsın, işin bitsin. Ama bu yaklaşımın ciddi bir kör noktası var: mock’ların gerçek sistemi doğru temsil ettiğini varsayıyorsun.

Diyelim ki bir fonksiyon PostgreSQL’e bir kayıt ekliyor ve RETURNING id kullanıyor. Birim testinde mock bu değeri döndürüyor, her şey güzel. Ama production’da yanlış bir column constraint var ve insert başarısız oluyor. Mock bunu yakalamazdı.

Integration testlerin özü şu: gerçek bileşenleri kullan, gerçek hatalar ortaya çıksın. Test veritabanı, test mesaj kuyruğu, test API endpoint’i… Bunlar gerçek sistemlerin birebir kopyaları olmalı.

Ortam Hazırlığı: pytest ve Gerekli Araçlar

Önce temel kurulumu yapalım. Bir proje yapısı oluşturuyoruz:

pip install pytest pytest-asyncio sqlalchemy psycopg2-binary alembic factory-boy faker pytest-postgresql testcontainers

Proje dizin yapısı şöyle olsun:

myapp/
├── models/
│   ├── __init__.py
│   └── user.py
├── repositories/
│   ├── __init__.py
│   └── user_repository.py
├── services/
│   ├── __init__.py
│   └── user_service.py
├── tests/
│   ├── conftest.py
│   ├── integration/
│   │   ├── __init__.py
│   │   ├── test_user_repository.py
│   │   └── test_user_service.py
│   └── unit/
│       └── test_user_logic.py
├── pytest.ini
└── requirements-dev.txt

pytest.ini dosyasında integration testleri ayrı işaretleyelim:

[pytest]
markers =
    integration: Integration testleri, veritabanı bağlantısı gerektirir
    slow: Yavaş çalışan testler
asyncio_mode = auto
testpaths = tests

Bu marker sistemi çok işe yarıyor. CI/CD’de hızlı feedback için sadece unit testleri, gece build’de tüm testleri çalıştırabiliyorsun:

# Sadece unit testler
pytest -m "not integration"

# Sadece integration testler
pytest -m integration

# Tüm testler
pytest

conftest.py: Fixture’ların Kalbi

Integration testlerin gücü fixture’lardan geliyor. conftest.py doğru kurulmazsa test suite’iniz kaotik bir hale gelir. Benim gördüğüm en yaygın hata: her test dosyasında ayrı ayrı veritabanı bağlantısı açmak. Bu hem yavaşlatır hem de izolasyon sorunları yaratır.

# tests/conftest.py

import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from testcontainers.postgres import PostgresContainer

from myapp.models import Base
from myapp.models.user import User


@pytest.fixture(scope="session")
def postgres_container():
    """Session boyunca tek bir PostgreSQL container ayağa kaldır."""
    with PostgresContainer("postgres:15-alpine") as postgres:
        yield postgres


@pytest.fixture(scope="session")
def db_engine(postgres_container):
    """Test engine'i oluştur, tabloları kur."""
    engine = create_engine(postgres_container.get_connection_url())
    Base.metadata.create_all(engine)
    yield engine
    Base.metadata.drop_all(engine)
    engine.dispose()


@pytest.fixture(scope="function")
def db_session(db_engine):
    """Her test için temiz bir session, transaction rollback ile izolasyon."""
    connection = db_engine.connect()
    transaction = connection.begin()
    Session = sessionmaker(bind=connection)
    session = Session()

    yield session

    session.close()
    transaction.rollback()
    connection.close()

Burada kritik bir nokta var: scope="session" ile container bir kez ayağa kalkar, scope="function" ile her test kendi transaction’ını alır ve test bitince rollback yapılır. Bu sayede testler birbirini kirletmez ve her test temiz bir veritabanı üzerinde çalışır.

Testcontainers kullanmak production’a yakın bir ortam sağlar. Docker üzerinde gerçek bir PostgreSQL çalıştırırsın, SQLite gibi “neredeyse aynı ama değil” bir alternatif kullanmazsın. PostgreSQL’e özgü özellikler (JSON operatörleri, diziler, full-text search) gerçekten test edilmiş olur.

Model ve Repository Tanımları

Test yazmadan önce neyi test edeceğimizi tanımlayalım:

# myapp/models/user.py

from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()


class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, autoincrement=True)
    email = Column(String(255), unique=True, nullable=False, index=True)
    username = Column(String(100), unique=True, nullable=False)
    is_active = Column(Boolean, default=True, nullable=False)
    created_at = Column(DateTime, server_default=func.now(), nullable=False)
    updated_at = Column(DateTime, onupdate=func.now())

    def __repr__(self):
        return f"<User(id={self.id}, email={self.email})>"
# myapp/repositories/user_repository.py

from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError

from myapp.models.user import User


class UserRepository:
    def __init__(self, session: Session):
        self.session = session

    def create(self, email: str, username: str) -> User:
        user = User(email=email, username=username)
        self.session.add(user)
        try:
            self.session.flush()
        except IntegrityError as e:
            self.session.rollback()
            raise ValueError(f"Kullanıcı oluşturulamadı: {e.orig}") from e
        return user

    def get_by_email(self, email: str) -> Optional[User]:
        return self.session.query(User).filter(User.email == email).first()

    def get_active_users(self) -> List[User]:
        return self.session.query(User).filter(User.is_active == True).all()

    def deactivate(self, user_id: int) -> bool:
        user = self.session.query(User).filter(User.id == user_id).first()
        if not user:
            return False
        user.is_active = False
        self.session.flush()
        return True

    def bulk_create(self, users_data: List[dict]) -> List[User]:
        users = [User(**data) for data in users_data]
        self.session.bulk_save_objects(users, return_defaults=True)
        self.session.flush()
        return users

Repository Integration Testleri

Asıl meseleye geldik. Repository testleri yazarken şu soruyu sormak gerekiyor: “Bu test, gerçek bir veritabanı davranışını test ediyor mu?” Eğer cevap evet ise, integration testimiz doğru yoldadır.

# tests/integration/test_user_repository.py

import pytest
from myapp.repositories.user_repository import UserRepository


@pytest.mark.integration
class TestUserRepository:

    def test_kullanici_olustur_ve_geri_al(self, db_session):
        repo = UserRepository(db_session)
        
        user = repo.create(email="[email protected]", username="ali_veli")
        
        assert user.id is not None, "ID atanmış olmalı"
        assert user.email == "[email protected]"
        assert user.is_active is True

    def test_ayni_email_ile_ikinci_kullanici_olusturulamaz(self, db_session):
        repo = UserRepository(db_session)
        repo.create(email="[email protected]", username="ilk_kullanici")
        
        with pytest.raises(ValueError, match="Kullanıcı oluşturulamadı"):
            repo.create(email="[email protected]", username="ikinci_kullanici")

    def test_email_ile_kullanici_bul(self, db_session):
        repo = UserRepository(db_session)
        repo.create(email="[email protected]", username="bulunan_user")
        
        bulunan = repo.get_by_email("[email protected]")
        olmayan = repo.get_by_email("[email protected]")
        
        assert bulunan is not None
        assert bulunan.email == "[email protected]"
        assert olmayan is None

    def test_aktif_kullanicilari_listele(self, db_session):
        repo = UserRepository(db_session)
        repo.create(email="[email protected]", username="aktif_bir")
        repo.create(email="[email protected]", username="aktif_iki")
        user3 = repo.create(email="[email protected]", username="pasif_biri")
        repo.deactivate(user3.id)
        
        aktifler = repo.get_active_users()
        
        assert len(aktifler) == 2
        emailler = [u.email for u in aktifler]
        assert "[email protected]" in emailler
        assert "[email protected]" in emailler
        assert "[email protected]" not in emailler

    def test_toplu_kullanici_olustur(self, db_session):
        repo = UserRepository(db_session)
        veri = [
            {"email": f"user{i}@ornek.com", "username": f"user_{i}"}
            for i in range(50)
        ]
        
        olusturulanlar = repo.bulk_create(veri)
        
        assert len(olusturulanlar) == 50
        db_sayisi = db_session.query(
            __import__('myapp.models.user', fromlist=['User']).User
        ).count()
        assert db_sayisi == 50

Factory Boy ile Test Verisi Üretimi

Testlerde elle veri yazmak zahmetlidir ve test büyüdükçe sürdürülemez hale gelir. factory_boy bu sorunu çözer:

# tests/factories.py

import factory
from factory.alchemy import SQLAlchemyModelFactory
from faker import Faker

from myapp.models.user import User

fake = Faker("tr_TR")


class UserFactory(SQLAlchemyModelFactory):
    class Meta:
        model = User
        sqlalchemy_session_persistence = "flush"

    email = factory.LazyFunction(lambda: fake.unique.email())
    username = factory.LazyFunction(lambda: fake.unique.user_name())
    is_active = True


class InactiveUserFactory(UserFactory):
    is_active = False

Factory’leri conftest’e ekleyelim:

# tests/conftest.py'e eklenecek bölüm

from tests.factories import UserFactory, InactiveUserFactory

@pytest.fixture(autouse=True)
def set_factory_session(db_session):
    """Factory'lerin test session'ını kullanmasını sağla."""
    UserFactory._meta.sqlalchemy_session = db_session
    InactiveUserFactory._meta.sqlalchemy_session = db_session
    yield

Artık testler çok daha temiz yazılabiliyor:

# Factory kullanarak yazılmış test

@pytest.mark.integration
def test_servis_yalnizca_aktif_kullanicilara_email_gonder(db_session):
    from tests.factories import UserFactory, InactiveUserFactory
    from myapp.services.user_service import UserService
    
    aktif_kullanici = UserFactory.create_batch(3)
    pasif_kullanici = InactiveUserFactory.create_batch(2)
    
    servis = UserService(session=db_session)
    hedefler = servis.get_email_targets()
    
    hedef_emailler = [u.email for u in hedefler]
    
    for kullanici in aktif_kullanici:
        assert kullanici.email in hedef_emailler
    
    for kullanici in pasif_kullanici:
        assert kullanici.email not in hedef_emailler

Alembic Migration Testleri

Migration’ları test etmek genellikle ihmal edilen ama kritik bir alan. Özellikle ekip büyüdükçe “migration çalışıyor mu” sorusu hayati önem taşır.

# tests/integration/test_migrations.py

import pytest
from alembic.config import Config
from alembic import command
from sqlalchemy import create_engine, inspect


@pytest.mark.integration
def test_migration_basa_alinip_ilerletilebilir(postgres_container):
    """Migration'ların sıfırdan uygulanabildiğini ve geri alınabildiğini test et."""
    engine = create_engine(postgres_container.get_connection_url())
    
    alembic_cfg = Config("alembic.ini")
    alembic_cfg.set_main_option(
        "sqlalchemy.url", 
        postgres_container.get_connection_url()
    )
    
    # Tüm migration'ları uygula
    command.upgrade(alembic_cfg, "head")
    
    inspector = inspect(engine)
    tablolar = inspector.get_table_names()
    
    assert "users" in tablolar, "Users tablosu oluşturulmuş olmalı"
    
    kolonlar = {k["name"] for k in inspector.get_columns("users")}
    beklenen_kolonlar = {"id", "email", "username", "is_active", "created_at"}
    assert beklenen_kolonlar.issubset(kolonlar)
    
    # Bir önceki versiyona dön
    command.downgrade(alembic_cfg, "-1")
    
    # Tekrar en üste çık
    command.upgrade(alembic_cfg, "head")
    
    engine.dispose()

Servis Katmanı Integration Testleri

Repository testleri veritabanı etkileşimini test eder. Servis katmanı testleri ise iş mantığının birden fazla bileşenle doğru çalışıp çalışmadığını sorgular. Bazen bir HTTP isteğini de dahil etmek gerekir:

# tests/integration/test_user_service.py

import pytest
import responses
from myapp.services.user_service import UserService
from tests.factories import UserFactory


@pytest.mark.integration
class TestUserServiceIntegration:

    @responses.activate
    def test_kullanici_kaydi_bildirim_gonderir(self, db_session):
        """Kullanıcı kaydolduğunda bildirim servisine istek atıldığını doğrula."""
        responses.add(
            responses.POST,
            "https://bildirim.internal/api/notify",
            json={"status": "ok"},
            status=200
        )
        
        servis = UserService(
            session=db_session,
            notification_url="https://bildirim.internal/api/notify"
        )
        
        yeni_kullanici = servis.register_user(
            email="[email protected]",
            username="yeni_kullanici"
        )
        
        assert yeni_kullanici.id is not None
        assert len(responses.calls) == 1
        
        istek_body = responses.calls[0].request.body
        assert "[email protected]" in istek_body

    def test_toplu_deaktivasyon_tum_kullanicilari_etkiler(self, db_session):
        """Belirli kriterdeki tüm kullanıcıların deaktive edildiğini test et."""
        import factory
        hedefler = UserFactory.create_batch(5)
        diger = UserFactory.create_batch(3)
        
        hedef_idler = [u.id for u in hedefler]
        
        servis = UserService(session=db_session)
        etkilenen_sayi = servis.bulk_deactivate(user_ids=hedef_idler)
        
        assert etkilenen_sayi == 5
        
        from myapp.repositories.user_repository import UserRepository
        repo = UserRepository(db_session)
        aktifler = repo.get_active_users()
        aktif_idler = [u.id for u in aktifler]
        
        for uid in hedef_idler:
            assert uid not in aktif_idler
        
        for kullanici in diger:
            assert kullanici.id in aktif_idler

Paralel Test Çalıştırma ve Performans

Test suite büyüdükçe çalışma süresi sorun olmaya başlar. pytest-xdist ile paralel çalıştırabilirsiniz:

pip install pytest-xdist

# 4 paralel worker ile çalıştır
pytest -n 4 -m integration

# CPU sayısına göre otomatik ayarla
pytest -n auto -m integration

Ama paralel testlerde dikkatli olmak gerekiyor. Her worker kendi veritabanı bağlantısına ihtiyaç duyar. conftest.py‘deki fixture’ları buna göre düzenlemek şart:

# Paralel test için worker-aware fixture

@pytest.fixture(scope="session")
def worker_id(tmp_path_factory):
    """xdist worker ID'sini al, yoksa 'main' döndür."""
    import os
    return os.environ.get("PYTEST_XDIST_WORKER", "main")


@pytest.fixture(scope="session")
def postgres_container(worker_id):
    """Her worker için ayrı container."""
    with PostgresContainer("postgres:15-alpine") as postgres:
        yield postgres

Test Coverage ve Raporlama

Sadece test yazmak yetmez, neleri test ettiğinizi de bilmeniz gerekir:

pip install pytest-cov

# Coverage raporu ile çalıştır
pytest --cov=myapp --cov-report=html --cov-report=term-missing -m integration

# Minimum coverage threshold belirle
pytest --cov=myapp --cov-fail-under=80

# XML rapor (CI/CD için)
pytest --cov=myapp --cov-report=xml:coverage.xml

pytest.ini‘ye kalıcı coverage ayarları ekleyin:

[pytest]
markers =
    integration: Integration testleri
    slow: Yavaş testler
addopts = --cov=myapp --cov-report=term-missing
testpaths = tests
asyncio_mode = auto

CI/CD Entegrasyonu

GitLab CI örneği, integration testleri ayrı stage’de çalıştırma:

# .gitlab-ci.yml

stages:
  - test-unit
  - test-integration

unit-tests:
  stage: test-unit
  image: python:3.11-slim
  script:
    - pip install -r requirements-dev.txt
    - pytest -m "not integration" --cov=myapp --cov-report=xml
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

integration-tests:
  stage: test-integration
  image: python:3.11-slim
  services:
    - docker:dind
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - pip install -r requirements-dev.txt
    - pytest -m integration --timeout=120
  only:
    - main
    - merge_requests

Sonuç

Integration test yazmak birim testten daha zahmetlidir: ortam kurmak gerekir, daha yavaş çalışır, daha fazla bakım ister. Ama bunu yapmamak daha pahalıya patlar. “Testler geçti ama production’da patlıyor” durumunun büyük bölümü tam olarak integration testlerin eksikliğinden kaynaklanır.

Önerdiğim yaklaşım: birim testlerle iş mantığını, integration testlerle bileşen etkileşimlerini test edin. İkisine de ihtiyaç var, biri diğerinin yerini tutmuyor.

Testcontainers’ı kesinlikle deneyin. SQLite ile “integration test yazıyorum” demek kendinizi kandırmak. Gerçek PostgreSQL, gerçek Redis, gerçek RabbitMQ kullanın. Fixture scope’larını doğru ayarlayın: container session seviyesinde, transaction her test için. Factory boy kullanın, elle test verisi yazmak ölçeklenmiyor.

Ve son olarak: coverage rakamını takip edin ama ona tapmayın. Yüzde seksen coverage ile kritik kodun test edilmemiş olması, yüzde yüz coverage ile yüzeysel testlerden daha tehlikelidir. Hangi senaryoların kritik olduğuna odaklanın, ona göre test yazın.

Bir yanıt yazın

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