Test Verisi Yönetimi: Fixture ve Factory Kullanımı

Prodüksiyonda bir şeylerin patladığını öğrendiğinizde aklınıza gelen ilk soru genellikle şu olur: “Test ortamında bu senaryoyu denediniz mi?” Çoğu zaman cevap “hayır” çünkü test verisi hazırlamak zahmetli, tutarsız ve zaman alıcı bir süreç. İşte tam da bu noktada fixture ve factory kavramları hayat kurtarıcı oluyor. Bu yazıda test verisi yönetimini gerçek anlamda çalışır hale getirmek için kullandığım yaklaşımları, araçları ve kaçınılması gereken tuzakları paylaşacağım.

Test Verisi Yönetimi Neden Bu Kadar Kritik?

Sistemleri test etmek için veri gerekir. Bu basit gerçek, pratikte inanılmaz derecede karmaşık bir hal alabilir. Küçük bir e-ticaret projesinde bile düşündüğünüzde şu soruları yanıtlamanız gerekir: Kullanıcı kaydı testi için nasıl bir kullanıcı oluşturursunuz? Sipariş işleme testinde stok verisi nereden gelecek? Ödeme akışını test ederken hangi ürün fiyatlarını kullanacaksınız?

Bu soruları “şimdilik elle yazayım” diye geçiştirdiğinizde, zamanla test süitleriniz birbirine bağımlı hale gelir. Bir test başka bir testin yarattığı veriye güvenmeye başlar, testler sıra bağımlılığı kazanır ve CI/CD pipeline’ınızda her çalıştırmada farklı sonuçlar almaya başlarsınız. Bu duruma flaky test denir ve sysadmin dünyasında bu terimi duymaktan nefret edersiniz.

Fixture yaklaşımı: Önceden tanımlanmış, sabit test verisi setleri kullanmak. Factory yaklaşımı: Test anında dinamik olarak veri üretmek.

Her ikisinin de yeri var. Hangisini ne zaman kullanacağınızı bilmek, iyi bir test stratejisinin temelidir.

Fixture Yaklaşımı: Ne Zaman Güvenilir, Ne Zaman Tehlikeli?

Fixture’lar özünde “hazır veri paketleri”dir. Django projelerinde JSON fixture’ları, Rails’de YAML fixture’ları, ya da sadece bir SQL dump dosyası olabilir. Avantajı açık: bir kez yaz, her testte kullan.

Ancak fixture’ların sinsi bir sorunu vardır: veri ile test mantığı arasındaki kopukluk. Fixture dosyasına bakıyorsunuz, testin neden başarısız olduğunu anlamak için dosya ile test kodu arasında gidip geliyorsunuz. Ekibinize yeni biri katıldığında bu fixture’ların nereden geldiğini, ne için kullanıldığını anlamak saatler alıyor.

Buna rağmen fixture’ların mükemmel çalıştığı durumlar var: referans verisi denilen kategoriler, dil kodları, ülke listesi, sabit konfigürasyonlar. Bunlar değişmez, basit ve testlerin temel altyapısını oluşturur.

Python/pytest ile Fixture Kullanımı

# conftest.py
import pytest
import json
from pathlib import Path

@pytest.fixture(scope="session")
def country_data():
    """Referans veri: Ülke listesi - testler arası değişmez"""
    fixture_path = Path(__file__).parent / "fixtures" / "countries.json"
    with open(fixture_path) as f:
        return json.load(f)

@pytest.fixture(scope="function")
def clean_db(db):
    """Her test fonksiyonu için temiz veritabanı başlangıcı"""
    yield db
    # Test sonrası temizlik
    db.execute("DELETE FROM orders WHERE test_data = true")
    db.execute("DELETE FROM users WHERE email LIKE '%@test.example.com'")
# test_user_registration.py
def test_user_cannot_register_with_existing_email(clean_db, country_data):
    # Fixture'dan Türkiye verisini al
    turkey = next(c for c in country_data if c["code"] == "TR")
    
    # İlk kayıt
    create_user(email="[email protected]", country_id=turkey["id"])
    
    # Aynı e-posta ile ikinci kayıt denemesi
    with pytest.raises(DuplicateEmailError):
        create_user(email="[email protected]", country_id=turkey["id"])

Burada dikkat edin: scope="session" ile ülke verisi tüm test oturumu boyunca bir kez yükleniyor. Bu performans açısından kritik. Ama kullanıcı verisini scope="function" ile yönetiyoruz çünkü her test izole olmalı.

Factory Yaklaşımı: Gerçek Güç Burada

Factory pattern, nesne yaratmayı soyutlaştırır. Test için ihtiyaç duyduğunuz nesneyi “fabrika”dan talep edersiniz, fabrika size gerçekçi, geçerli ve tutarlı bir nesne verir. Python ekosisteminde factory_boy kütüphanesi bu konuda standart haline gelmiştir.

pip install factory-boy faker
# factories.py
import factory
from factory.django import DjangoModelFactory
from faker import Faker
from .models import User, Product, Order, OrderItem

fake = Faker('tr_TR')  # Türkçe locale ile gerçekçi veri

class UserFactory(DjangoModelFactory):
    class Meta:
        model = User
    
    username = factory.LazyFunction(lambda: fake.user_name())
    email = factory.LazyAttribute(lambda obj: f"{obj.username}@test.example.com")
    first_name = factory.LazyFunction(lambda: fake.first_name())
    last_name = factory.LazyFunction(lambda: fake.last_name())
    phone = factory.LazyFunction(lambda: fake.phone_number())
    is_active = True
    is_verified = False
    
    class Params:
        # Trait: Doğrulanmış kullanıcı
        verified = factory.Trait(
            is_verified=True,
            verification_date=factory.LazyFunction(fake.past_datetime)
        )
        # Trait: Admin kullanıcı
        admin = factory.Trait(
            is_staff=True,
            is_superuser=True
        )

class ProductFactory(DjangoModelFactory):
    class Meta:
        model = Product
    
    name = factory.LazyFunction(lambda: fake.catch_phrase())
    sku = factory.Sequence(lambda n: f"SKU-{n:06d}")
    price = factory.LazyFunction(lambda: round(fake.pyfloat(min_value=10, max_value=5000, right_digits=2), 2))
    stock = factory.LazyFunction(lambda: fake.random_int(min=0, max=500))
    is_active = True

Factory’lerin en güçlü yanı trait sistemi. Bir fabrika tanımlamanız yeterli; farklı durumlar için trait kullanırsınız.

# Testlerde kullanım
def test_verified_user_can_checkout(clean_db):
    # Normal kullanıcı - ödeme yapamamalı
    unverified_user = UserFactory()
    
    # Doğrulanmış kullanıcı - ödeme yapabilmeli
    verified_user = UserFactory(verified=True)
    
    product = ProductFactory(price=299.99, stock=10)
    
    assert can_checkout(unverified_user, product) == False
    assert can_checkout(verified_user, product) == True

İlişkisel Veri ve Bağımlılık Yönetimi

Gerçek dünya senaryolarında veri izole değildir. Bir sipariş, kullanıcıya ve ürüne bağlıdır. Sipariş kalemi, siparişe bağlıdır. Bu ilişkileri doğru kurmak factory’lerin asıl değerini ortaya çıkarır.

class OrderFactory(DjangoModelFactory):
    class Meta:
        model = Order
    
    # SubFactory: Otomatik ilişkili nesne oluşturur
    user = factory.SubFactory(UserFactory, verified=True)
    status = "pending"
    shipping_address = factory.LazyFunction(lambda: fake.address())
    created_at = factory.LazyFunction(fake.past_datetime)
    
    class Params:
        completed = factory.Trait(
            status="completed",
            completed_at=factory.LazyFunction(fake.past_datetime)
        )
        cancelled = factory.Trait(
            status="cancelled",
            cancellation_reason="Müşteri isteği"
        )

class OrderItemFactory(DjangoModelFactory):
    class Meta:
        model = OrderItem
    
    order = factory.SubFactory(OrderFactory)
    product = factory.SubFactory(ProductFactory)
    quantity = factory.LazyFunction(lambda: fake.random_int(min=1, max=10))
    unit_price = factory.LazyAttribute(lambda obj: obj.product.price)

Bu tanımlamalarla şunu yapabilirsiniz:

def test_order_total_calculation():
    # 3 ürünlü tamamlanmış sipariş
    order = OrderFactory(completed=True)
    items = OrderItemFactory.create_batch(3, order=order)
    
    expected_total = sum(item.quantity * item.unit_price for item in items)
    
    assert calculate_order_total(order) == expected_total
    assert order.status == "completed"

create_batch metodu tek satırda birden fazla nesne yaratır. Performans testlerinde, stres senaryolarında inanılmaz pratik.

Node.js Ekosisteminde Factory Pattern

Python’dan farklı bir dünyaya geçelim. Node.js projelerinde Prisma veya Sequelize ORM kullanıyorsanız, fishery veya @faker-js/faker ile benzer bir yapı kurabilirsiniz.

npm install --save-dev fishery @faker-js/faker
// factories/user.factory.js
const { Factory } = require('fishery');
const { faker } = require('@faker-js/faker');

// Türkçe locale ayarı
faker.setLocale('tr');

const userFactory = Factory.define(({ sequence, params }) => ({
  id: sequence,
  username: faker.internet.userName(),
  email: params.email || `user-${sequence}@test.example.com`,
  firstName: faker.name.firstName(),
  lastName: faker.name.lastName(),
  phone: faker.phone.number('+90 5## ### ## ##'),
  isActive: true,
  isVerified: false,
  createdAt: faker.date.past(),
}));

// Trait benzeri: özel durumlar
const verifiedUserFactory = userFactory.params({
  isVerified: true,
  verificationDate: faker.date.recent(),
});

const adminUserFactory = userFactory.params({
  isVerified: true,
  role: 'admin',
  permissions: ['read', 'write', 'delete'],
});

module.exports = { userFactory, verifiedUserFactory, adminUserFactory };
// test/checkout.test.js
const { userFactory, verifiedUserFactory } = require('../factories/user.factory');
const { productFactory } = require('../factories/product.factory');

describe('Checkout İşlemleri', () => {
  it('doğrulanmamış kullanıcı ödeme yapamamalı', async () => {
    const user = userFactory.build();
    const product = productFactory.build({ price: 199.99, stock: 5 });
    
    await expect(checkout(user, product)).rejects.toThrow('Email doğrulaması gerekli');
  });
  
  it('stokta olmayan ürün sepete eklenememeli', async () => {
    const user = verifiedUserFactory.build();
    const outOfStockProduct = productFactory.build({ stock: 0 });
    
    await expect(addToCart(user, outOfStockProduct)).rejects.toThrow('Stokta yok');
  });
});

build() metodu veritabanına kaydetmeden nesne oluşturur. create() ise veritabanına kaydeder. Unit testlerde build(), integration testlerde create() kullanın. Bu ayrım, test hızınızı dramatik biçimde etkiler.

CI/CD Pipeline’ında Test Verisi Stratejisi

Bir pipeline’da fixture ve factory’leri doğru konumlandırmak, build sürelerini ciddi şekilde etkiler. Gerçek bir projede karşılaştığım şu senaryoyu paylaşayım:

Test ortamımızda her PR için tüm testler çalışıyordu ve ortalama süre 14 dakikayı geçmişti. Sorun incelendiğinde, her test fonksiyonunun veritabanını baştan kurduğu ve yüzlerce fixture kaydı oluşturduğu görüldü.

# docker-compose.test.yml
version: '3.8'
services:
  db-test:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass
    tmpfs:
      - /var/lib/postgresql/data  # RAM'de çalışır, 3-4x hız artışı
    ports:
      - "5433:5432"
  
  test-runner:
    build: .
    environment:
      DATABASE_URL: postgresql://testuser:testpass@db-test:5432/testdb
      DJANGO_SETTINGS_MODULE: config.settings.test
    depends_on:
      - db-test
    command: >
      sh -c "python manage.py migrate --run-syncdb &&
             python manage.py loaddata fixtures/reference_data.json &&
             pytest tests/ -v --tb=short -n auto"

tmpfs kullanımı kritik: PostgreSQL’i RAM üzerinde çalıştırmak, disk I/O’yu ortadan kaldırır. Bizim senaryomuzda test süresi 14 dakikadan 6 dakikaya düştü. -n auto ise pytest-xdist ile paralel test çalıştırmayı aktif eder.

Hassas Veri Maskeleme: Prodüksiyon Verisi ile Test

Zaman zaman gerçek prodüksiyon verisiyle test etmek gerekebilir, özellikle karmaşık iş mantığını doğrulamak için. Ancak bu yaklaşım KVKK ve GDPR açısından ciddi riskler taşır. Prodüksiyon verisini test ortamına taşımadan önce mutlaka maskeleme yapılmalıdır.

# data_masking.py
import hashlib
import random
from faker import Faker

fake = Faker('tr_TR')

def mask_user_data(row: dict) -> dict:
    """Prodüksiyon kullanıcı verisini test için maskele"""
    masked = row.copy()
    
    # E-posta: hash ile anonimleştir ama format koru
    email_hash = hashlib.md5(row['email'].encode()).hexdigest()[:8]
    masked['email'] = f"masked_{email_hash}@test.example.com"
    
    # Telefon: gerçekçi ama sahte numara
    masked['phone'] = fake.phone_number()
    
    # TC Kimlik: tamamen sahte
    masked['tc_no'] = generate_fake_tc()
    
    # İsim: fake ama cinsiyet tutarlılığı koru (opsiyonel)
    masked['first_name'] = fake.first_name()
    masked['last_name'] = fake.last_name()
    
    # Finansal veri: sıfırla veya aralıkta rastgele değer
    if 'balance' in masked:
        masked['balance'] = round(random.uniform(0, 1000), 2)
    
    return masked

def generate_fake_tc() -> str:
    """LUHN benzeri doğrulama geçen sahte TC kimlik"""
    digits = [random.randint(1, 9)]
    digits += [random.randint(0, 9) for _ in range(9)]
    
    # TC algoritması
    odd_sum = sum(digits[i] for i in range(0, 9, 2))
    even_sum = sum(digits[i] for i in range(1, 8, 2))
    d10 = (odd_sum * 7 - even_sum) % 10
    d11 = sum(digits) % 10
    
    digits.append(d10)
    digits.append(d11)
    return ''.join(map(str, digits))

Bu tür bir maskeleme scriptini ETL pipeline’ınıza entegre ederek, üretim benzeri ama güvenli test verisi oluşturabilirsiniz.

Snapshot Testing ile Fixture Entegrasyonu

API yanıtlarını test ederken snapshot testing çok işe yarar. İlk çalıştırmada “doğru” yanıtı kaydeder, sonraki çalıştırmalarda bununla karşılaştırır.

# test_api_snapshots.py
import pytest
from syrupy import SnapshotAssertion

def test_user_profile_api_response(client, snapshot: SnapshotAssertion):
    # Factory ile tutarlı veri oluştur
    user = UserFactory(
        username="snapshot_test_user",
        first_name="Ahmet",
        last_name="Yılmaz",
        verified=True
    )
    
    response = client.get(f"/api/v1/users/{user.id}/profile/")
    
    assert response.status_code == 200
    # Snapshot ile karşılaştır
    assert response.json() == snapshot
# İlk çalıştırmada snapshot oluştur
pytest tests/test_api_snapshots.py --snapshot-update

# Sonraki çalıştırmalarda karşılaştır
pytest tests/test_api_snapshots.py

Snapshot’lar otomatik olarak __snapshots__ klasörüne kaydedilir ve Git’e commit edilir. API yanıtı değiştiğinde test başarısız olur ve kasıtlı bir değişiklikse --snapshot-update ile güncellersiniz.

Yaygın Hatalar ve Nasıl Kaçınılır?

Pek çok projede aynı hataların tekrarlandığını görüyorum. Bunları doğrudan yaşadım ya da miras aldım:

Test izolasyonu ihmal etmek: Testler birbirinin verilerine bağımlı hale geldiğinde, herhangi bir testin sırası değiştiğinde her şey patlar. Her test kendi verisini yaratmalı ve temizlemeli.

Hardcoded ID kullanmak: Fixture’larda sabit ID’ler kullanmak, farklı ortamlarda çakışmalara yol açar. Factory’lerin sequence mekanizması veya UUID bu sorunu çözer.

Çok büyük fixture dosyaları: Yüzlerce kayıt içeren fixture JSON dosyaları hem bakımı zorlaştırır hem de testleri yavaşlatır. Referans verisi dışındaki her şey factory ile dinamik oluşturulmalı.

Faker seed kullanmamak: Faker her seferinde farklı veri üretir. Bazen tutarlı veri gerektiğinde seed kullanın:

# Tekrarlanabilir test verisi için seed
from faker import Faker
fake = Faker('tr_TR')
Faker.seed(42)  # Sabit seed ile her seferinde aynı veri

# Ya da pytest fixture'ında
@pytest.fixture(autouse=True)
def faker_seed():
    Faker.seed(0)

Factory’leri overuse etmek: Her testin fabrikaya ihtiyacı yoktur. Basit bir string validasyonu testi için factory kurmaya gerek yok. Araçları doğru yerde kullanın.

Sonuç

Test verisi yönetimi, çoğu zaman “sonra hallederiz” kategorisine düşen ama zamanla teknik borcun en büyük kalemi haline gelen bir alan. Fixture ve factory arasındaki seçimi şu şekilde özetleyebilirim:

  • Fixture kullanın: Referans verisi, sabit konfigürasyonlar, nadiren değişen lookup tabloları için.
  • Factory kullanın: İş mantığı testleri, ilişkisel veri senaryoları, edge case’ler ve her türlü dinamik test verisi için.
  • İkisini birlikte kullanın: Referans verisini fixture ile kurun, üstüne factory ile iş verisi inşa edin.

Maskeleme, izolasyon, seed kontrolü ve CI/CD entegrasyonu gibi konuları baştan planlamak, ileride onlarca saatlik debug süresinin önüne geçer. Prodüksiyonda bir şey patladığında “test ortamında denediniz mi?” sorusuna rahatlıkla “evet, factory ile kapsamlı senaryo testleri çalıştırdık” diyebilmek, hem teknik hem psikolojik açıdan değerli bir konumdur.

Ekibinizde test verisi yaklaşımı henüz tanımlı değilse, küçük bir sprint ayırın ve bu altyapıyı kurun. İlk haftadan itibaren farkı hissedeceksiniz.

Bir yanıt yazın

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