Principal Seviye Python Test Rehberi

16 Mart 2026 · netologist · 25 dakika, 5147 kelime ·

Unit Test, Integration Test ve Testcontainers — Derinlemesine İnceleme


İçindekiler

  1. Test Felsefesi
  2. Test Piramidi ve Test Kupası
  3. Unit Test Temelleri
  4. pytest: Fiili Standart
  5. Fixture’lar Derinlemesine
  6. Parametrizasyon
  7. Test Çiftleri: Mock, Stub, Fake, Spy
  8. Property-Based Testing
  9. Integration Test
  10. Python için Testcontainers
  11. Test Mimarisi ve Proje Yapısı
  12. Coverage, Mutation Testing ve Test Kalitesi
  13. CI/CD Entegrasyonu
  14. Anti-Pattern’ler ve Yaygın Tuzaklar
  15. Principal Seviye Test Paketleri için Kontrol Listesi

1. Test Felsefesi

pytest veya unittest‘e dokunmadan önce, neden test yazdığımızı içselleştirmek gerekir. Principal seviye bir mühendis, bir coverage rozetini tatmin etmek için test yazmaz — testler şu amaçlarla var olur:

Temel Gerilim

Yazdığınız her test aynı zamanda bir yükümlülüktür: bakımı yapılmalı, flaky (kararsız) olabilir, CI’ı yavaşlatabilir ve yanlış bir güven duygusu verebilir. Test zanaatı “daha fazla test yaz” değildir — doğru seviyede, doğru izolasyonla, doğru maliyetle doğru testleri yazmaktır.

Bu, test piramidi fikriyle formalize edilir.


2. Test Piramidi ve Test Kupası

        /\
       /  \        E2E (az sayıda, yavaş, pahalı, yüksek güven)
      /----\
     /      \      Integration (orta miktarda, orta hız)
    /--------\
   /          \    Unit (çok sayıda, hızlı, ucuz, izole)
  /------------\

Kent C. Dodds’un Test Kupası (Testing Trophy) fikri, özellikle API’ler ve veritabanları gibi zengin entegrasyon yüzeylerine sahip modern sistemler için faydalı bir rafinasyondur:

      /‾‾‾‾‾‾‾‾‾‾‾‾\
     |     E2E      |
      \____________/
     /‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
    |   Integration    |   <- paranın karşılığını en iyi veren katman
     \________________/
     /‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
    |       Unit        |
     \________________/
            |
          Statik Analiz
       (mypy, ruff, flake8)

Kupa yaklaşımı, çoğu iş uygulaması için integration testlerin en iyi güven/maliyet oranını verdiğini savunur, çünkü gerçek hataların çoğu bileşenler arasındaki sınırlarda yaşar (serialization, SQL sorguları, network çağrıları) — iki sayıyı toplayan saf bir fonksiyonun içinde değil.

Principal seviye çıkarım: dogmatik bir şekilde 70/20/10 oranını kovalamayın. Bunun yerine her test için şunu sorun: “Bu hata sınıfını yakalayacak ve bana refactor için güven verecek en ucuz test hangisidir?”


3. Unit Test Temelleri

3.1 Bir Unit Testi Unit Test Yapan Nedir

Bir unit test şunları sağlamalıdır:

  1. Hızlı — milisaniyenin altından birkaç milisaniyeye kadar. 5.000 unit testten oluşan bir paket saniyeler içinde çalışmalı.
  2. İzole — network yok, disk I/O yok, gerçek veritabanı yok, sleep() yok. Sadece test edilen kod ve saf bellek içi işbirlikçilere bağlıdır.
  3. Deterministik — her seferinde, her makinede, herhangi bir sırada aynı girdi, aynı çıktı.
  4. Bağımsız — test sırası asla önemli olmamalıdır. Test A, test B’nin bağımlı olduğu bir durum bırakmamalıdır.

Eğer bir “unit test” gerçek bir Postgres örneğine erişiyorsa, bu bir unit test değildir — ne kadar küçük olursa olsun bir integration testtir.

3.2 AAA Deseni (Arrange–Act–Assert / Hazırla–Uygula–Doğrula)

def test_discount_applied_for_premium_customer():
    # Arrange (Hazırla)
    customer = Customer(tier="premium")
    cart = Cart(items=[Item(price=100)])

    # Act (Uygula)
    total = calculate_total(cart, customer)

    # Assert (Doğrula)
    assert total == 90  # %10 indirim

Bu üç bölümü görsel olarak ayrı tutun — boş satırlar veya yorumlarla bile olsa. Kurulum ve doğrulamayı iç içe geçiren bir test okunması ve debug edilmesi daha zordur.

3.3 Test Başına Tek Davranış

Bir unit test, birden fazla assert ifadesi gerektirse bile tek bir mantıksal davranışı doğrulamalıdır (örneğin, aynı dönen nesnenin birden fazla özelliğini doğrulamak sorun değil; bir testte iki ilgisiz davranışı doğrulamak sorun).

# KÖTÜ: iki ilgisiz davranışı test ediyor
def test_user_creation_and_deletion():
    user = create_user("alice")
    assert user.id is not None
    delete_user(user.id)
    assert get_user(user.id) is None

# İYİ: ayrılmış
def test_create_user_assigns_id():
    user = create_user("alice")
    assert user.id is not None

def test_delete_user_removes_from_store():
    user = create_user("alice")
    delete_user(user.id)
    assert get_user(user.id) is None

3.4 İsimlendirme Kuralları

Bir kural benimseyin ve zorunlu kılın. Güçlü bir örnek:

test_<test_edilen_birim>_<koşul>_<beklenen_sonuç>
def test_calculate_total_with_expired_coupon_raises_value_error(): ...
def test_parse_config_missing_required_key_returns_none(): ...

Bu isimlendirme, başarısız bir testin sadece adının (CI loglarında gösterildiği gibi) dosyayı açmadan neyin bozulduğunu size söylemesini sağlar.


4. pytest: Fiili Standart

unittest (standart kütüphane) sınıf tabanlıdır, XUnit tarzıdır, ayrıntılıdır ve Java tadındadır. pytest ise düz assert, fixture’lar ve geniş bir plugin ekosistemi sayesinde topluluk standardıdır. Principal seviye bir mühendis her ikisini de bilmelidir — çünkü pytest, unittest tarzı testleri değiştirmeden çalıştırabilir — ancak yeni kod için pytest deyimlerini varsayılan olarak kullanmalıdır.

4.1 İçgözlemli Düz Assertion’lar

def test_addition():
    assert 2 + 2 == 4

self.assertEqual yok. pytest, import zamanında assert ifadesini yeniden yazar (AST rewriting yoluyla) ve zengin başarısızlık farkları (diff) verir:

E       assert 5 == 4
E        +  where 5 = <function add at 0x...>(2, 3)

4.2 Exception (İstisna) Testi

import pytest

def test_division_by_zero_raises():
    with pytest.raises(ZeroDivisionError):
        1 / 0

def test_custom_error_message():
    with pytest.raises(ValueError, match=r"invalid literal"):
        int("not-a-number")

Exception tipi birden fazla hata koşulu için kullanıldığında, çıplak pytest.raises(ValueError) yerine regex ile match= kullanmayı tercih edin — aksi takdirde yanlış bir ValueError’ın fırlatıldığını doğruluyor olabilirsiniz.

4.3 Uyarılar (Warnings)

def test_deprecated_function_warns():
    with pytest.deprecated_call():
        old_function()

def test_specific_warning():
    with pytest.warns(UserWarning, match="will be removed"):
        risky_call()

4.4 Testleri İşaretleme ve Seçme

import pytest

@pytest.mark.slow
def test_expensive_computation():
    ...

@pytest.mark.skip(reason="henüz implemente edilmedi")
def test_future_feature():
    ...

@pytest.mark.skipif(sys.platform == "win32", reason="Sadece POSIX")
def test_file_permissions():
    ...

@pytest.mark.xfail(reason="bilinen hata, bkz. JIRA-123")
def test_known_broken_behavior():
    ...

Uyarıları önlemek ve pytest -m slow komutunu etkinleştirmek için özel marker’ları pyproject.toml‘da kayıt altına alın:

[tool.pytest.ini_options]
markers = [
    "slow: yavaş testleri işaretler (-m \"not slow\" ile hariç tutulabilir)",
    "integration: harici servis gerektiren integration testlerini işaretler",
]

Ardından CI’da hızlı geri bildirim döngüleri çalıştırabilirsiniz:

pytest -m "not slow and not integration"   # sadece hızlı unit testler, pre-commit
pytest -m "integration"                     # integration paketi, ayrı bir CI aşaması

4.5 Konfigürasyon

pyproject.toml, modern, tek gerçek kaynaktır:

[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-ra -q --strict-markers --strict-config"
testpaths = ["tests"]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
filterwarnings = [
    "error",
    "ignore::DeprecationWarning:some_third_party_lib",
]

5. Fixture’lar Derinlemesine

Fixture’lar, pytest’in dependency-injection (bağımlılık enjeksiyonu) mekanizmasıdır ve scope/lifecycle kurallarını anlamak, orta seviye kullanımı ileri seviyeden ayırır.

5.1 Temel Fixture

import pytest

@pytest.fixture
def sample_user():
    return User(name="Alice", email="[email protected]")

def test_user_email_domain(sample_user):
    assert sample_user.email.endswith("@example.com")

pytest, sample_user‘ı testin parametre listesiyle isim eşleştirmesi yaparak çözer — bu “sihirli” ama deterministiktir.

5.2 Fixture Scope (Kapsam)

@pytest.fixture(scope="function")   # varsayılan: her test için yeni bir örnek
@pytest.fixture(scope="class")      # bir sınıftaki tüm testler arasında paylaşılır
@pytest.fixture(scope="module")     # bir dosyadaki tüm testler arasında paylaşılır
@pytest.fixture(scope="package")    # bir paket genelinde paylaşılır
@pytest.fixture(scope="session")    # TÜM test çalıştırması boyunca paylaşılır

Temel kural: doğruluk için varsayılan olarak function scope kullanın (paylaşılan mutable durum yok = sıraya bağlı hata yok). Scope’u yalnızca pahalı, salt-okunur veya doğal olarak paylaşılan kaynaklar için session‘a genişletin — örneğin bir Testcontainers Postgres örneği başlatmak, büyük bir fixture veri setini derlemek veya bir ML modelini belleğe yüklemek.

5.3 yield ile Setup/Teardown

@pytest.fixture
def db_connection():
    conn = create_connection()
    yield conn          # <- test burada çalışır
    conn.close()         # <- teardown, test başarısız olsa bile çalışır

yield‘den sonraki kod, test bir exception fırlatsa bile teardown sırasında çalışır, çünkü pytest fixture yürütmesini temizliği garanti edecek şekilde sarar — bu, dosya handle’ları, DB bağlantıları ve geçici dizinler gibi kaynaklar için kritiktir.

5.4 Fixture Kompozisyonu (Fixture’lara bağımlı fixture’lar)

@pytest.fixture
def db_connection():
    conn = create_connection()
    yield conn
    conn.close()

@pytest.fixture
def user_repository(db_connection):
    return UserRepository(db_connection)

@pytest.fixture
def sample_user(user_repository):
    user = user_repository.create(name="Alice")
    yield user
    user_repository.delete(user.id)

Bu bir bağımlılık grafiği oluşturur, tembel (lazy) olarak çözülür ve scope başına önbelleğe alınır. Bu, xUnit setUp/tearDown‘dan gerçekten daha güçlüdür, çünkü fixture’lar birleştirilebilir ve test dosyaları arasında bağımsız olarak yeniden kullanılabilir.

5.5 autouse Fixture’lar

@pytest.fixture(autouse=True)
def reset_global_state():
    yield
    GlobalRegistry.clear()

Ölçülü kullanın. autouse fixture’lar, açıkça talep edilmeden kendi scope’larındaki her test için çalışır — invariantları zorunlu kılmak için güçlüdür (örn. “hiçbir test global durumu kirli bırakamaz”), ancak test davranışını çağrı noktasında daha az görünür/açık hale getirirler. Aşırı kullanım, bir testin üç dosya ötedeki bir autouse fixture yüzünden başarısız olduğu “uzaktan ürkütücü etki” durumuna yol açar.

5.6 conftest.py: Import Olmadan Fixture Paylaşımı

tests/
├── conftest.py          # tests/ ve altındaki TÜM testler için görünür fixture'lar
├── unit/
│   ├── conftest.py       # sadece tests/unit/ için görünür fixture'lar
│   └── test_pricing.py
└── integration/
    ├── conftest.py
    └── test_repository.py

pytest, conftest.py dosyalarını otomatik olarak keşfeder — import gerekmez. Bu, çapraz kesen fixture’lar (DB bağlantıları, test client’ları, factory’ler) için doğru yerdir. Sadece unit fixture’ları tests/unit/conftest.py‘de, sadece integration fixture’ları (örn. Testcontainers) tests/integration/conftest.py‘de tutun, böylece unit test çalıştırmaları Docker’a bağımlı kodu asla import bile etmez.

5.7 Fixture Sonlandırma Sırası ve request

@pytest.fixture
def temp_dir(request):
    d = Path(tempfile.mkdtemp())
    def cleanup():
        shutil.rmtree(d)
    request.addfinalizer(cleanup)
    return d

request.addfinalizer, koşullu olarak birden fazla finalizer kaydetmeniz gerektiğinde veya request.node (test öğesi), request.param (parametrize/indirect fixture’larda) veya request.config‘e (CLI seçenekleri) erişmeniz gerektiğinde yield teardown’a bir alternatiftir.


6. Parametrizasyon

6.1 Temel Parametrize

import pytest

@pytest.mark.parametrize("input_value,expected", [
    (0, "zero"),
    (1, "one"),
    (-1, "negative"),
    (100, "positive"),
])
def test_classify_number(input_value, expected):
    assert classify_number(input_value) == expected

Bu, 4 bağımsız test senaryosu oluşturur — her biri test raporlarında ayrı ayrı gösterilir (test_classify_number[0-zero] vb.), böylece tek bir başarısızlık diğer üçünü gizlemez.

6.2 Okunabilirlik için Parametrize ID’leri

@pytest.mark.parametrize(
    "raw,expected",
    [
        pytest.param("", None, id="empty_string"),
        pytest.param("  ", None, id="whitespace_only"),
        pytest.param("42", 42, id="valid_integer"),
        pytest.param("abc", None, id="non_numeric"),
    ],
)
def test_parse_optional_int(raw, expected):
    assert parse_optional_int(raw) == expected

Açık id= değerleri, pytest’in otomatik oluşturduğu ID’lerden (karmaşık nesneler için okunmaz olabilir) daha iyidir ve kendi kendini belgeleyen CI çıktısı verir.

6.3 Parametrize Yığınlama (Kartezyen Çarpım)

@pytest.mark.parametrize("currency", ["USD", "EUR", "GBP"])
@pytest.mark.parametrize("tier", ["free", "premium"])
def test_pricing_matrix(tier, currency):
    price = get_price(tier=tier, currency=currency)
    assert price > 0

İki parametrize dekoratörünü yığınlamak, tam Kartezyen çarpımı üretir (2 × 3 = 6 senaryo) — güçlüdür ama kombinatoryal olarak patlayabilir. Yalnızca bazı kombinasyonlar geçerliyse, yığınlama yerine açık tuple’larla pytest.mark.parametrize kullanın.

6.4 Fixture Parametrizasyonu (indirect=True)

@pytest.fixture
def api_client(request):
    version = request.param
    return APIClient(version=version)

@pytest.mark.parametrize("api_client", ["v1", "v2"], indirect=True)
def test_endpoint_across_versions(api_client):
    assert api_client.get("/health").status_code == 200

indirect=True, parametrize değerini doğrudan test fonksiyonuna değil, (request.param olarak) fixture üzerinden yönlendirir — parametrenin ham bir değer değil kurulum mantığı gerektirdiği durumlarda kullanışlıdır.


7. Test Çiftleri: Mock, Stub, Fake, Spy

Gerard Meszaros’un (xUnit Test Patterns kitabından) taksonomisi kesindir ve içselleştirilmeye değer — “mock” terimi genellikle bunların hepsi için gelişigüzel kullanılır:

ÇiftAmaçÖrnek
DummyGeçirilir ama hiç kullanılmaz, sadece bir parametreyi doldururKullanılmayan bir logger argümanı olarak geçirilen None
StubÇağrılara hazır cevaplar döndürürHer zaman {"status": "success"} döndüren bir ödeme ağ geçidi stub’ı
SpyNasıl çağrıldığını sonraki doğrulama için kaydedersend_email‘in belirli bir argümanla tam olarak bir kez çağrıldığını doğrulamak
MockBeklentilerle önceden programlanmış; beklentiler karşılanmazsa test başarısız olurBir etkileşimin belirli bir sırada gerçekleştiğini doğrulamak
FakeÇalışan, basitleştirilmiş bir implementasyonPostgres yerine geçen bellek içi bir SQLite DB; bellek içi dict tabanlı bir repository

7.1 unittest.mock Temelleri

from unittest.mock import Mock, MagicMock, patch

def test_notify_calls_email_service():
    email_service = Mock()
    notifier = Notifier(email_service=email_service)

    notifier.notify(user_id=1, message="Hello")

    email_service.send.assert_called_once_with(user_id=1, message="Hello")

7.2 patch — Gerçek Bağımlılıkların Değiştirilmesi

from unittest.mock import patch

@patch("myapp.services.payment.requests.post")
def test_charge_card_success(mock_post):
    mock_post.return_value.status_code = 200
    mock_post.return_value.json.return_value = {"status": "approved"}

    result = charge_card(card_number="4111111111111111", amount=100)

    assert result.approved is True
    mock_post.assert_called_once()

Kritik kural: patch’i tanımlandığı yerde değil, kullanıldığı yerde uygulayın. Eğer payment.py şunu yapıyorsa from requests import post, requests.post‘u değil myapp.services.payment.post‘u patch’lemeniz gerekir — çünkü isim bağlama import zamanında hedef modülün namespace’inde gerçekleşir.

7.3 Context Manager Olarak patch

def test_current_time_dependent_logic():
    with patch("myapp.services.clock.datetime") as mock_datetime:
        mock_datetime.now.return_value = datetime(2024, 1, 1)
        result = is_new_year(datetime_provider=mock_datetime)
        assert result is True

Patch’e testin sadece bir kısmı için ihtiyacınız olduğunda veya birçok patch’i yığınlamak dekoratör imzasını okunmaz hale getirdiğinde, dekoratörler yerine context manager’ları tercih edin.

7.4 autospec — Arayüz Kaymasını Önlemek

from unittest.mock import patch

@patch("myapp.services.payment.PaymentGateway", autospec=True)
def test_gateway_called_correctly(mock_gateway_cls):
    mock_gateway_cls.return_value.charge.assert_not_called()

autospec=True olmadan, bir Mock seve seve mock.chrage(...) (bir yazım hatası) veya yanlış sayıda argümanı kabul eder — gerçek kod çökse bile test geçer. autospec=True (veya create_autospec), gerçek nesnenin imzasını içgözlemler ve eşleşmeyen çağrılarda TypeError fırlatır, gerçek sınıf değiştiğinde ama test çifti değişmediğinde arayüz kaymasını yakalar.

Principal seviye kural: en basit mock dışında her şey için her zaman autospec=True tercih edin.

7.5 Side Effect’ler

mock_repo = Mock()
mock_repo.get.side_effect = [User(id=1), User(id=2), KeyError("not found")]

# Ya da dinamik davranış için çağrılabilir bir side_effect
def fake_get(user_id):
    if user_id not in db:
        raise KeyError(user_id)
    return db[user_id]

mock_repo.get.side_effect = fake_get

7.6 Mock Yerine Fake — Bir Tasarım Tercihi

Fake’ler, mimari sınırlarda genellikle mock’lara tercih edilir, çünkü:

class InMemoryUserRepository:
    def __init__(self):
        self._users: dict[int, User] = {}
        self._next_id = 1

    def save(self, user: User) -> User:
        user.id = self._next_id
        self._users[user.id] = user
        self._next_id += 1
        return user

    def get(self, user_id: int) -> User | None:
        return self._users.get(user_id)

def test_user_service_creates_user():
    repo = InMemoryUserRepository()
    service = UserService(repo)

    user = service.register("[email protected]")

    assert repo.get(user.id).email == "[email protected]"

Bu test, gözlemlenebilir sözleşme korunduğu sürece UserService.register‘ın dahili yapısının yeniden yazılmasına dayanıklıdır — bu, iyi bir testin ayırt edici özelliğidir.

7.7 Mock’ların Doğru Araç Olduğu Durumlar

Etkileşimin kendisi test edilen davranış olduğunda katı mock/spy’lar kullanın:


8. Property-Based Testing

Örnek tabanlı testler (assert add(2, 3) == 5) sadece düşündüğünüz örnekleri doğrular. Hypothesis, hayal etmediğiniz uç durumları bulmak için yüzlerce düşmanca girdi üretir.

from hypothesis import given, strategies as st

@given(st.integers(), st.integers())
def test_addition_is_commutative(a, b):
    assert add(a, b) == add(b, a)

@given(st.lists(st.integers()))
def test_sorted_list_is_ordered(lst):
    result = my_sort(lst)
    assert all(result[i] <= result[i+1] for i in range(len(result) - 1))
    assert sorted(result) == sorted(lst)  # aynı elemanlar, sadece sıralı değil

Hypothesis, başarısız örnekleri otomatik olarak küçültür (shrink) ve en küçük yeniden üretilebilir senaryoyu bulur — eğer [93, -4, 0, 17, -4] başarısız olursa, minimal başarısız girdiyi rapor eder, örn. [0, -1], bu da önemli miktarda debug süresinden tasarruf sağlar.

Property-based testing’i şunlar için kullanın:


9. Integration Test

9.1 Tanım ve Kapsam

Bir integration test, iki veya daha fazla bileşenin birlikte doğru çalıştığını doğrular, özellikle tam olarak kontrol etmediğiniz bir sınır boyunca: bir veritabanı, bir önbellek, bir mesaj aracısı, üçüncü taraf bir HTTP API veya dosya sistemi.

Unit testlerden temel fark: integration testlerin daha yavaş olmasına, gerçek altyapıya dokunmasına ve paylaşılan, pahalı kuruluma sahip olmasına izin verilir (beklenir) — ancak yine de deterministik ve test çalıştırmaları arasında izole olmalıdırlar.

9.2 Tarihsel Sorun: Veritabanını Mock’lamak

Yaygın bir anti-pattern, “integration” testlerinde veritabanını/ORM’yi tamamen mock’lamaktır:

# Bu GERÇEK bir integration test DEĞİLDİR — SQL doğruluğu hakkında hiçbir şey test etmez
@patch("myapp.repository.db.session")
def test_get_user(mock_session):
    mock_session.query.return_value.filter.return_value.first.return_value = User(id=1)
    ...

Bu tür bir test yanlış bir güven verir: gerçek SQL sorgunuz hatalıysa, migration’ınız bozuksa veya ORM eşlemenizde bir yazım hatası varsa bile geçer. Gerçek hatalar SQL’de, connection pooling’de, transaction sınırlarında, JSON’a/dan serialization’da meydana gelir — tam olarak bu testin çalıştırmadığı kısımlarda.

9.3 Tarihsel Alternatifler (ve Sorunları)

  1. Postgres yerine bellek içi SQLite — hızlıdır, ancak SQLite’ın SQL diyalekti, tip sistemi ve kısıtlama uygulaması Postgres’ten farklıdır (örn. gerçek JSONB yok, farklı ON CONFLICT semantiği, window fonksiyon farklılıkları, daha zayıf tip kontrolü). Testler yerel olarak geçer, production’da başarısız olur.
  2. Paylaşılan, uzun ömürlü bir test veritabanı — test çalıştırmaları arasında durum sızıntısı, birbirine giren paralel CI işleri ve her yere uygulanmayan şema değişikliklerinden kaynaklanan “benim makinemde çalışıyor” kaymaları nedeniyle kararsızdır.
  3. Testleri çalıştırmadan önce manuel olarak başlatılan Docker Compose — çalışır, ancak test yaşam döngüsüyle entegre değildir: container’ın hazır olduğunu garanti eden hiçbir şey yoktur, otomatik olarak kaldıran hiçbir şey yoktur ve paralel test yürütmesi veya CI matrix işleriyle iyi birleşmez.

Testcontainers her üç sorunu da çözer, gerçek, geçici, izole altyapıyı doğrudan test paketinin kendisinden, uygun hazır olma kontrolleri ve otomatik temizlik ile programatik olarak başlatarak.


10. Python için Testcontainers

10.1 Nedir

Testcontainers, test kodundan doğrudan, otomatik yaşam döngüsü yönetimi ile gerçek bağımlılıkları tek kullanımlık Docker container’ları olarak başlatmanızı sağlayan bir kütüphanedir (başlangıçta Java, şimdi Python, Go, .NET, Node vb. için mevcuttur).

pip install testcontainers[postgres]

10.2 Temel Postgres Örneği

import pytest
from testcontainers.postgres import PostgresContainer
import sqlalchemy

@pytest.fixture(scope="session")
def postgres_container():
    with PostgresContainer("postgres:16-alpine") as postgres:
        yield postgres

@pytest.fixture(scope="session")
def db_engine(postgres_container):
    engine = sqlalchemy.create_engine(postgres_container.get_connection_url())
    run_migrations(engine)  # Alembic migration'larını uygula, vb.
    return engine

@pytest.fixture
def db_session(db_engine):
    connection = db_engine.connect()
    transaction = connection.begin()
    Session = sqlalchemy.orm.sessionmaker(bind=connection)
    session = Session()

    yield session

    session.close()
    transaction.rollback()  # <- her testin temiz başlaması için geri al
    connection.close()

def test_user_repository_saves_and_retrieves(db_session):
    repo = UserRepository(db_session)
    user = repo.save(User(email="[email protected]"))

    fetched = repo.get(user.id)

    assert fetched.email == "[email protected]"

Buradaki temel tasarım deseni: container’ın kendisi session scope’ludur (bir kez başlatılır, pahalıdır — genellikle 1-3 saniye), ancak her bir test, test bittikten sonra geri alınan (rollback) yeni bir transaction alır (db_session fixture’ı, function scope’lu). Bu size unit test benzeri izolasyon ve hız ile gerçek Postgres semantiği verir (transaction rollback neredeyse anlıktır, tabloları truncate etmekten veya her test için container’ı yeniden başlatmaktan çok daha hızlıdır).

10.3 Hazır Olma / Bekleme Stratejileri

Testcontainers, iyi bilinen image’lar için (Postgres, MySQL, Kafka, Redis’in hepsinin dahili bekleme stratejileri vardır, gerçek port/protokolü poll ederler) klasik “container başladı ama servis henüz hazır değil” yarış durumunu dahili olarak halleder. Özel image’lar için:

from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import wait_for_logs

class MyServiceContainer(DockerContainer):
    def __init__(self):
        super().__init__("myregistry/myservice:latest")
        self.with_exposed_ports(8080)

    def start(self):
        super().start()
        wait_for_logs(self, "Server started on port 8080")
        return self

“Container’ı beklemek” için asla time.sleep(2)‘ye güvenmeyin — bu hem yavaştır (her zaman tam sleep süresini öder) hem de kararsızdır (bazen yüklü bir CI runner’da yeterince uzun olmayabilir). Her zaman protokol seviyesinde veya log tabanlı hazır olma kontrolleri kullanın.

10.4 Birden Fazla Container (Ağ Üzerinden Bağlı)

from testcontainers.core.network import Network
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer

@pytest.fixture(scope="session")
def app_stack():
    with Network() as network:
        postgres = PostgresContainer("postgres:16-alpine").with_network(network)
        redis = RedisContainer("redis:7-alpine").with_network(network)

        with postgres, redis:
            yield {"postgres": postgres, "redis": redis}

10.5 Kafka Integration Test Örneği

from testcontainers.kafka import KafkaContainer
from confluent_kafka import Producer, Consumer

@pytest.fixture(scope="session")
def kafka_container():
    with KafkaContainer("confluentinc/cp-kafka:7.5.0") as kafka:
        yield kafka

def test_order_created_event_is_published(kafka_container):
    bootstrap_servers = kafka_container.get_bootstrap_server()
    producer_config = {"bootstrap.servers": bootstrap_servers}

    consumer = Consumer({
        "bootstrap.servers": bootstrap_servers,
        "group.id": "test-group",
        "auto.offset.reset": "earliest",
    })
    consumer.subscribe(["orders"])

    service = OrderService(kafka_config=producer_config)
    service.create_order(order_id="123", amount=100)

    msg = consumer.poll(timeout=10)
    assert msg is not None
    assert json.loads(msg.value())["order_id"] == "123"

10.6 Özel Modülü Olmayan Herhangi Bir Şey için Generic Container

from testcontainers.core.container import DockerContainer

def test_against_custom_service():
    with DockerContainer("myorg/legacy-soap-service:2.1") \
            .with_exposed_ports(8080) \
            .with_env("LICENSE_KEY", "test-key") as container:

        port = container.get_exposed_port(8080)
        host = container.get_container_host_ip()

        response = requests.get(f"http://{host}:{port}/health")
        assert response.status_code == 200

10.7 Testcontainers Modül Kapsamı (ekosistem olgunluğu itibarıyla)

Yaygın birinci sınıf modüller: postgres, mysql, mongodb, redis, kafka, rabbitmq, elasticsearch, localstack (AWS emülasyonu — S3, SQS, DynamoDB vb.), neo4j, clickhouse, mssql, nginx, selenium (tarayıcı tabanlı E2E için) ve k3s/kind (geçici Kubernetes cluster’ları).

localstack, cloud-native takımlar için özellikle önemlidir — gerçek AWS’ye dokunmadan, maliyet olmadan ve CI’da network erişimine gerek kalmadan, gerçek, geçici, yerel bir AWS emülasyonuna karşı AWS SDK (boto3) etkileşimlerini (S3 yüklemeleri, SQS mesaj akışları, DynamoDB sorguları) integration test etmenizi sağlar.

from testcontainers.localstack import LocalStackContainer
import boto3

@pytest.fixture(scope="session")
def localstack():
    with LocalStackContainer("localstack/localstack:3") as ls:
        ls.with_services("s3", "sqs")
        yield ls

@pytest.fixture
def s3_client(localstack):
    return boto3.client("s3", endpoint_url=localstack.get_url())

def test_upload_report_to_s3(s3_client):
    s3_client.create_bucket(Bucket="reports")
    upload_report(s3_client, bucket="reports", key="q1.csv", data=b"...")

    obj = s3_client.get_object(Bucket="reports", Key="q1.csv")
    assert obj["Body"].read() == b"..."

10.8 CI’da CPU/Kaynak Disiplini

Testcontainers, CI runner’ında bir Docker daemon’ı gerektirir. Dikkat edilmesi gerekenler:

10.9 Testcontainers’a karşı Altyapıyı Mock’lamak — Karar Çerçevesi

SenaryoÖneri
Kendi iş mantığınızı test etmek, I/O yokUnit test, container yok
Repository/DAO SQL doğruluğunu test etmekTestcontainers (gerçek Postgres/MySQL)
Sadece mesaj serialization/deserialization mantığını test etmekFake bir mesaj yolu ile unit test
Servisinizin gerçekten Kafka’ya doğru şekilde, gerçek serialization ile yayın yaptığını test etmekTestcontainers (gerçek Kafka)
Kararsız bir bağımlılığa karşı retry/backoff mantığını test etmekN kez başarısız olup sonra başarılı olan bir Mock ile unit test
Tam istek → DB → yanıt akışını test etmekIntegration test, FastAPI’nin TestClient‘ı/httpx.AsyncClient + Testcontainers Postgres
Servisler arası sözleşmeleri test etmekContract testing (örn. Pact) veya özel bir E2E ortamı, Testcontainers değil

11. Test Mimarisi ve Proje Yapısı

11.1 Dizin Yapısı

myproject/
├── src/
│   └── myapp/
│       ├── domain/
│       ├── services/
│       └── repositories/
├── tests/
│   ├── conftest.py              # paylaşılan fixture'lar (factory'ler, faker seeding)
│   ├── unit/
│   │   ├── conftest.py          # sadece unit fixture'ları — docker import'u YOK
│   │   ├── domain/
│   │   │   └── test_pricing.py
│   │   └── services/
│   │       └── test_order_service.py
│   ├── integration/
│   │   ├── conftest.py          # Testcontainers fixture'ları SADECE burada yaşar
│   │   └── repositories/
│   │       └── test_postgres_user_repository.py
│   └── e2e/
│       └── test_checkout_flow.py
├── pyproject.toml
└── Makefile

Bunun neden önemli olduğu: Testcontainers/Docker import’larını sıkı bir şekilde tests/integration/conftest.py içinde tutmak (asla üst düzey tests/conftest.py‘de değil), pytest tests/unit çalıştırmanın asla Docker’ın kurulu olmasını gerektirmediği anlamına gelir — bu, hızlı yerel geliştirme döngüleri ve Docker erişimi olmayan katkıda bulunanlar için kritiktir.

11.2 Test Veri Builder’ları / Object Mother Deseni

Ayrıntılı nesne oluşturmayı onlarca test arasında tekrarlamaktan kaçının:

# tests/factories.py
import factory
from myapp.domain import User, Order

class UserFactory(factory.Factory):
    class Meta:
        model = User

    id = factory.Sequence(lambda n: n)
    email = factory.LazyAttribute(lambda o: f"user{o.id}@example.com")
    tier = "standard"

class OrderFactory(factory.Factory):
    class Meta:
        model = Order

    id = factory.Sequence(lambda n: n)
    user = factory.SubFactory(UserFactory)
    total = 100.0

def test_premium_user_gets_discount():
    user = UserFactory(tier="premium")
    order = OrderFactory(user=user, total=200.0)

    assert apply_discount(order) == 180.0

factory_boy (veya elle yazılmış builder fonksiyonları), her testin sadece önemsediği şeyi override etmesine izin verir, geri kalanını varsayılanlar halleder — bu, User/Order şeması yeni zorunlu alanlar kazandığında test kırılganlığını önemli ölçüde azaltır.

11.3 Web Framework’leri için Test Client Deseni

# FastAPI
from fastapi.testclient import TestClient

@pytest.fixture
def client(app_with_test_db):
    return TestClient(app_with_test_db)

def test_create_order_endpoint(client):
    response = client.post("/orders", json={"item_id": 1, "quantity": 2})
    assert response.status_code == 201
    assert response.json()["status"] == "pending"
# Django
import pytest

@pytest.mark.django_db
def test_create_order_view(client):
    response = client.post("/api/orders/", {"item_id": 1, "quantity": 2})
    assert response.status_code == 201

pytest-django‘nun @pytest.mark.django_db‘si her testi bir transaction’a sarar ve geri alır — yukarıdaki Testcontainers örneğiyle aynı rollback-izolasyon deseni, Django’nun ORM’sine uygulanmıştır.

11.4 Test Edilebilirlik için Dependency Injection

Kendi bağımlılıklarını dahili olarak oluşturan kod test edilmesi zordur:

# TEST ETMESİ ZOR — gizli bağımlılık
class OrderService:
    def __init__(self):
        self.db = PostgresConnection()   # testlerde değiştirilemez
        self.email = SmtpEmailSender()

# TEST ETMESİ KOLAY — bağımlılıklar enjekte edilmiş
class OrderService:
    def __init__(self, db: Database, email: EmailSender):
        self.db = db
        self.email = email

Bu “test edilebilirlik için aşırı mühendislik” değildir — bu, Bağımlılığın Tersine Çevrilmesi İlkesi’dir (Dependency Inversion Principle) ve bir kod tabanını herhangi bir seviyede test edilebilir kılmak için en yüksek kaldıraçlı tek tasarım kararıdır.


12. Coverage, Mutation Testing ve Test Kalitesi

12.1 Kod Coverage’ı — Gerçekte Size Ne Söylüyor

pytest --cov=myapp --cov-report=term-missing --cov-report=html

Coverage size hangi satırların çalıştığını söyler, doğru şekilde test edilip edilmediğini değil. Sıfır assertion’lı bir test, hiçbir şeyi doğrulamadan %100 coverage üretebilir.

# %100 satır coverage, SIFIR doğrulama değeri
def test_process_order():
    process_order(Order(id=1, total=100))
    # assertion yok!

Coverage’ı optimize edilecek bir hedef olarak değil, boşluklar için bir teşhis aracı olarak kullanın. CI’da bir coverage eşik geçidi (örn. “coverage < %80 ise build’i başarısız yap”) bir taban olarak makuldür, ancak %100 coverage’ı kovalamak genellikle asıl riskli mantığı kaçırırken önemsiz getter/setter’ların düşük değerli testlerini üretir.

12.2 Branch Coverage

[tool.coverage.run]
branch = true

Satır coverage’ı test edilmemiş branch’leri gizleyebilir:

def get_discount(user):
    if user.is_premium:
        return 0.2
    return 0.0

get_discount(premium_user)‘ı çağıran tek bir test, %100 satır coverage’ı verir (her satır çalışır) ama sadece %50 branch coverage’ı verir (else yolu hiç alınmaz). Her zaman branch coverage’ı etkinleştirin.

12.3 Mutation Testing — Testlerinizi Test Etmek

Coverage size kodun çalıştığını söyler; bir hatayı yakalayıp yakalamayacağını söylemez. Mutation testing (mutmut veya cosmic-ray aracılığıyla), kaynağınıza otomatik olarak küçük hatalar (“mutant”) ekler — <‘yi <=‘ye, and‘i or‘a çevirir, bir satırı kaldırır — ve test paketinizi yeniden çalıştırır. Testler hala geçiyorsa, mutant hayatta kalmıştır, yani testleriniz aslında o mantığı doğrulamamıştır.

pip install mutmut
mutmut run --paths-to-mutate=src/myapp/domain/
mutmut results
# Orijinal
def is_eligible(age):
    return age >= 18

# Mutant: age >= 18  ->  age > 18
def is_eligible(age):
    return age > 18

Test paketiniz test_is_eligible_at_exactly_18‘i içermiyorsa, bu mutant hayatta kalır — satır/branch coverage’ın asla ortaya çıkaramayacağı bir sınır koşulu boşluğunu ortaya çıkarır. Mutation testing pahalıdır (büyük bir kod tabanında saatler sürebilir), bu yüzden genellikle her commit’te değil, geceleri veya kritik modüller (fiyatlandırma, auth, ödeme mantığı) için talep üzerine çalıştırılır.

12.4 Flaky Test Yönetimi

Flaky bir test (deterministik olmayan şekilde geçer/başarısız olur), testin hiç olmamasından daha kötüdür — tüm paketteki güveni aşındırır (“CI’ı yeniden çalıştır, muhtemelen flaky’dir” kültürel bir kanserdir).

Yaygın nedenler ve çözümler:

NedenÇözüm
Gerçek sleep()/zamanlama varsayımlarıZaman aşımı ile polling veya sahte saatler (freezegun, time-machine) kullanın
Testler arasında paylaşılan mutable global durumfunction scope’lu fixture’lar, autouse sıfırlama fixture’ları
Test sırası bağımlılığıSıra bağımlılıklarını bilerek ortaya çıkarmak için pytest-randomly ile çalıştırın
Seed’lenmemiş rastgelelikTestlerde rastgele seed’leri sabitleyin (random.seed(42)) veya Hypothesis’in deterministik replay’ini kullanın
Gerçek harici servislere network çağrılarıAsla — her zaman fake/mock/Testcontainers
Container hazır değil (yarış durumu)Uygun bekleme stratejileri, asla ham sleep()

pytest-rerunfailures, bilinen flaky testleri geçici bir çözüm olarak yeniden deneyebilir, ancak bu kalıcı bir özellik değil, teknik borç olarak takip edilmelidir:

pytest --reruns 2 --reruns-delay 1 -m flaky

13. CI/CD Entegrasyonu

13.1 Aşamalı Pipeline

# .github/workflows/test.yml (örnek amaçlı)
name: Test
on: [push, pull_request]

jobs:
  static-analysis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ruff mypy
      - run: ruff check .
      - run: mypy src/

  unit-tests:
    runs-on: ubuntu-latest
    needs: static-analysis
    steps:
      - uses: actions/checkout@v4
      - run: pip install -e ".[test]"
      - run: pytest tests/unit -m "not slow" --cov=myapp --cov-fail-under=80

  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    services:
      docker:
        image: docker:dind
    steps:
      - uses: actions/checkout@v4
      - run: pip install -e ".[test]"
      - run: pytest tests/integration --maxfail=1

  e2e-tests:
    runs-on: ubuntu-latest
    needs: integration-tests
    if: github.ref == 'refs/heads/main'
    steps:
      - run: pytest tests/e2e

İlke: önce hızlı ve ucuz olanla başarısız ol. Statik analiz (saniyeler) → unit testler (saniyeler ile birkaç dakika) → Testcontainers ile integration testler (dakikalar) → E2E (en yavaş, en az sıklıkta çalıştırılır, örn. sadece main‘de veya geceleri).

13.2 Paralelleştirme

pip install pytest-xdist
pytest -n auto   # CPU çekirdekleri arasında paralelleştir

Paralelleştirme, izole olmayan testlerin acımasızca ortaya çıktığı yerdir — paylaşılan geçici dosyalar, paylaşılan portlar, paylaşılan global durum, -n auto altında hepsi bozulur. Bu, daha önce göz ardı ettiğiniz izolasyon hatalarını düzeltmek için iyi bir zorlayıcı fonksiyondur.

Özellikle Testcontainers için, session scope’lu container’lar normalde xdist altında tek bir worker süreci içinde paylaşılır — her worker kendi container örneğini alır, bu genellikle istediğiniz şeydir (worker’lar arası izolasyon) ama N worker = N container anlamına gelir, bu yüzden CI kaynaklarını buna göre bütçeleyin.

13.3 CI’da Docker Katmanlarını/Image’ları Önbelleğe Alma

CI önbellek adımında image’ları önceden çekmek, her paralel işin postgres:16-alpine‘i sıfırdan yeniden indirmesini önler, bu ölçekte önemlidir:

- run: docker pull postgres:16-alpine
- run: docker pull redis:7-alpine

14. Anti-Pattern’ler ve Yaygın Tuzaklar

  1. Davranış yerine implementasyon detaylarını test etmek. Asıl önemli olan gözlemlenebilir çıktı iken, dahili özel durumu (obj._cache) veya bir Mock üzerindeki tam çağrı sıralarını doğrulamak. Bu, testleri zararsız refactor’lara karşı kırılgan yapar.

  2. “Dondurma Külahı” (Ice Cream Cone) anti-piramidi. Çoğunlukla E2E/manuel testler, az unit test. Yavaş geri bildirim, pahalı CI, flaky paketler — sağlıklı bir piramidin tersi.

  3. Sleep tabanlı senkronizasyon. “Asenkron işi beklemek” için time.sleep(1) hem yavaştır (her zaman maliyeti öder) hem de güvenilmezdir (bazen yeterince uzun değildir). Açık polling/bekleme yardımcıları veya sahte bir saatin dependency injection’ını kullanın.

  4. Tanrı fixture’lar. Tüm uygulama bağlamını oluşturan ve her tek teste bağımlı olunan, izole, hızlı unit testlerin amacını boşa çıkaran tek bir conftest.py fixture’ı.

  5. Test arası bağımlılık. Test B, Test A’nın önce çalıştığını ve arkasında durum bıraktığını varsayar. Bunu yakalamak için düzenli olarak pytest-randomly ile çalıştırın; bu hiçbir zaman mümkün olmamalıdır.

  6. Sahip olmadığınız şeyi aşırı mock’lamak. Üçüncü taraf kütüphane dahiliyetlerini kapsamlı bir şekilde mock’lamak, testlerinizi o kütüphanenin implementasyon detaylarına bağlar ve kütüphanenin gerçek davranışı değiştiğinde (örn. bir SDK’nın retry mantığı) yanlış bir güven verir. Gerçek servisin Testcontainers tabanlı bir fake’ine karşı test etmeyi veya kütüphanenin kendisinin sağladığı iyi bakımlı bir fake’i tercih edin.

  7. Okunmaz test isimleri üreten aşırı parametrizasyon. test_thing[True-False-None-3-<object at 0x7f>] — her zaman açık id= değerleri verin.

  8. Tüm nesnenin sabit kodlanmış bir dict/JSON blob’una eşit olduğunu doğrulamak. İlgisiz alan eklemelerine karşı kırılgandır. Testle ilgili belirli alanları veya şema tabanlı kısmi bir eşleşmeyi doğrulamayı tercih edin.

  9. Global/singleton durumu sıfırlamamak. Python’da özellikle modül seviyesi önbellekleme, lru_cache ve singleton desenleri nedeniyle tehlikelidir — testler arasında singleton’ların sıfırlandığını doğrulayın, özellikle pytest-xdist worker yeniden kullanımı göz önüne alındığında.

  10. Testleri düzeltmek yerine atlamak. JIRA bildirimi/son kullanma tarihi olmadan zamanla biriken @pytest.mark.skip, herkesin unuttuğu test edilmemiş kod yollarının bir mezarlığına dönüşür.


15. Principal Seviye Test Paketleri için Kontrol Listesi


Ek: Minimal Referans pyproject.toml

[project.optional-dependencies]
test = [
    "pytest>=8.0",
    "pytest-cov>=5.0",
    "pytest-xdist>=3.5",
    "pytest-randomly>=3.15",
    "pytest-asyncio>=0.23",
    "hypothesis>=6.100",
    "testcontainers[postgres,kafka,redis,localstack]>=4.0",
    "factory-boy>=3.3",
    "freezegun>=1.5",
]

[tool.pytest.ini_options]
minversion = "8.0"
addopts = "-ra -q --strict-markers --strict-config --cov=myapp --cov-report=term-missing"
testpaths = ["tests"]
markers = [
    "slow: uzun süren testler",
    "integration: Docker/Testcontainers gerektirir",
    "e2e: tam uçtan uca sistem testleri",
]
filterwarnings = ["error"]

[tool.coverage.run]
branch = true
source = ["src/myapp"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
]

Bu rehber, 2025–2026 civarında yüksek olgunluğa sahip Python mühendislik organizasyonlarının kullandığı pratikleri yansıtmaktadır. Araçlar ve API’ler zamanla gelişir — production kodunda tam sözdizimine güvenmeden önce her zaman güncel kütüphane dokümantasyonunu (pytest, Testcontainers, Hypothesis) kontrol edin.