Test Driven Development (TDD) Nedir ve Nasıl Uygulanır?

Yıllar önce bir projeye dahil olduğumda, ekip “biz zaten test yazıyoruz” diyordu. Merak edip dosyalara bakınca gördüm ki testler, kod yazıldıktan sonra, deploy öncesi acelesiyle, çoğu zaman da yarım yamalak eklenmiş. “Test var” demekle “test driven development yapıyoruz” demek arasındaki fark, işte tam olarak buydu. TDD, test yazma alışkanlığından çok bir tasarım felsefesidir ve bunu anlamadan uygulamak mümkün değil.

TDD Nedir, Ne Değildir?

Test Driven Development, kodu yazmadan önce test yazma pratiğidir. Ama bu tanım yetersiz kalıyor, çünkü insanlar bunu duyunca “ah, önce test sonra kod” deyip geçiyor. Oysa TDD’nin özü şu: testler, kodun nasıl davranması gerektiğini tanımlar ve kod bu tanıma uymak zorundadır.

TDD’nin değildir listesi belki daha açıklayıcı olur:

  • Yüzde yüz kod coverage garantisi değildir
  • “Her şeye test yaz” baskısı değildir
  • Sadece unit test yazmak değildir
  • Yavaşlatan bir süreç değildir (başta öyle hissettirse de)

TDD’nin temel döngüsü üç adımdan oluşur ve buna Red-Green-Refactor denir:

  • Red: Önce başarısız olan bir test yaz
  • Green: Bu testi geçecek minimum kodu yaz
  • Refactor: Çalışan kodu temizle, iyileştir, testi geçmeye devam ettir

Bu döngü çok küçük adımlarla işler. Bir seferde devasa bir özellik yazmaya çalışmazsın. Küçük bir davranış tanımlarsın, onu geçersin, devam edersin.

Neden Sysadmin ve DevOps Camiası TDD’ye Bakmalı?

Şimdi “bu bir yazılım geliştirme konusu, bizi ne ilgilendirir” diye düşünenler olabilir. Haklı bir soru. Ama şu gerçeklerle yüzleşelim:

  • Bash scriptleri artık onlarca, yüzlerce satır
  • Ansible playbook’ları karmaşıklaştı
  • Python ile yazılan otomasyon araçları büyüdü
  • Terraform modülleri iş mantığı içeriyor
  • CI/CD pipeline’ları için yazılan Go veya Python araçları production’ı etkiliyor

Bunların hepsini “script yazdım, çalışıyor” diyerek geçiştirmek artık lüks değil. Bir restart scriptinin yanlış çalışması production’ı patlatabilir. Bir backup rotasyon scriptinin bug’ı, aylar sonra backup olmadığını gösterir.

Pratik Uygulama: Python ile Başlayalım

Python, sysadmin dünyasında en yaygın kullanılan dil olduğu için buradan başlamak mantıklı. Örnek senaryo: bir log rotasyon aracı yazıyoruz.

Önce test framework’ü kuralım:

pip install pytest pytest-cov
mkdir log_rotator
cd log_rotator
touch log_rotator.py
touch test_log_rotator.py

Şimdi TDD döngüsünü adım adım uygulayalım. İlk özellik: bir log dosyasının boyutunu kontrol eden fonksiyon.

Red aşaması – önce testi yazıyoruz:

# test_log_rotator.py
import pytest
import os
from log_rotator import should_rotate

def test_dosya_boyutu_siniri_asildiysa_rotate_et():
    # 100MB sınır, 150MB dosya
    assert should_rotate("/var/log/app.log", max_size_mb=100, current_size_mb=150) == True

def test_dosya_boyutu_siniri_asilmadiysa_rotate_etme():
    # 100MB sınır, 50MB dosya
    assert should_rotate("/var/log/app.log", max_size_mb=100, current_size_mb=50) == False

def test_dosya_tam_sinirda_rotate_etme():
    # Tam sınırda olan dosya rotate edilmemeli
    assert should_rotate("/var/log/app.log", max_size_mb=100, current_size_mb=100) == False

Bu testleri çalıştıralım:

pytest test_log_rotator.py -v

Tabii ki hata alırız çünkü log_rotator.py içinde henüz hiçbir şey yok. Bu Red aşaması. Testi geçmek için fonksiyonu yazmamız gerekiyor.

Green aşaması – minimum kodu yazıyoruz:

# log_rotator.py
def should_rotate(log_path: str, max_size_mb: int, current_size_mb: float) -> bool:
    return current_size_mb > max_size_mb

Şimdi testi tekrar çalıştır:

pytest test_log_rotator.py -v
# Üç test de geçmeli

Refactor aşaması: Şu an kod yeterince basit, refactor gerekmiyor. Ama bir sonraki özelliği ekleyelim: gerçek dosya boyutunu okuma.

# test_log_rotator.py - yeni test ekliyoruz
import tempfile

def test_gercek_dosya_boyutunu_oku():
    with tempfile.NamedTemporaryFile(delete=False, suffix='.log') as f:
        f.write(b'x' * 1024 * 1024)  # 1MB veri yaz
        temp_path = f.name
    
    try:
        boyut = get_file_size_mb(temp_path)
        assert boyut == pytest.approx(1.0, abs=0.1)
    finally:
        os.unlink(temp_path)

Önce test başarısız olur (get_file_size_mb fonksiyonu yok), sonra fonksiyonu yazarız:

# log_rotator.py - fonksiyon ekliyoruz
def get_file_size_mb(log_path: str) -> float:
    return os.path.getsize(log_path) / (1024 * 1024)

Bash Scriptleri İçin TDD: BATS Kullanımı

Python için pytest güzel ama sysadminlerin çoğu bash yazıyor. BATS (Bash Automated Testing System) tam burada devreye giriyor.

# BATS kurulumu
git clone https://github.com/bats-core/bats-core.git
cd bats-core
./install.sh /usr/local

# veya
apt-get install bats  # Debian/Ubuntu
brew install bats-core  # macOS

Senaryo: Disk kullanımını kontrol eden ve kritik eşiği geçince alert üreten bir script.

#!/usr/bin/env bats
# test_disk_monitor.bats

setup() {
    # Her testten önce çalışır
    export TEST_THRESHOLD=80
    source ./disk_monitor.sh
}

@test "disk kullanimi esik altindaysa uyari uretme" {
    # df komutunu mock'luyoruz
    df() { echo "Filesystem Use%"; echo "/dev/sda1 70%"; }
    export -f df
    
    result=$(check_disk_usage "/" "$TEST_THRESHOLD")
    [ "$result" = "OK" ]
}

@test "disk kullanimi esigi astiysa uyari uret" {
    df() { echo "Filesystem Use%"; echo "/dev/sda1 85%"; }
    export -f df
    
    result=$(check_disk_usage "/" "$TEST_THRESHOLD")
    [ "$result" = "CRITICAL" ]
}

@test "gecersiz threshold hatasi don" {
    run check_disk_usage "/" "invalid"
    [ "$status" -eq 1 ]
}

Testi çalıştırıyoruz, hata alıyoruz:

bats test_disk_monitor.bats

Ardından disk_monitor.sh scriptini yazıyoruz:

#!/bin/bash
# disk_monitor.sh

check_disk_usage() {
    local mount_point="$1"
    local threshold="$2"
    
    # Threshold validasyonu
    if ! [[ "$threshold" =~ ^[0-9]+$ ]]; then
        echo "Hata: Threshold sayisal olmali" >&2
        return 1
    fi
    
    local usage
    usage=$(df "$mount_point" | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
    
    if [ "$usage" -gt "$threshold" ]; then
        echo "CRITICAL"
    else
        echo "OK"
    fi
}

Mock ve Stub Kavramları: Gerçek Dünya Sorunları

TDD yaparken en çok takılınan nokta dış bağımlılıklardır. Database bağlantısı, API çağrısı, dosya sistemi işlemleri… Bunları test ederken gerçek kaynakları kullanmak istemeyiz. İşte mock ve stub devreye giriyor.

Senaryo: Sunucu monitoring scripti, bir REST API’ye metrik gönderiyor.

# test_metrics_sender.py
from unittest.mock import patch, MagicMock
from metrics_sender import send_metric

def test_basarili_metrik_gonderimi():
    with patch('metrics_sender.requests.post') as mock_post:
        mock_post.return_value.status_code = 200
        mock_post.return_value.json.return_value = {"status": "ok"}
        
        result = send_metric("cpu_usage", 75.5, "web-server-01")
        
        assert result == True
        mock_post.assert_called_once()
        
        # Gönderilen verinin doğru formatta olduğunu kontrol et
        call_args = mock_post.call_args
        assert call_args[1]['json']['metric_name'] == "cpu_usage"
        assert call_args[1]['json']['value'] == 75.5

def test_api_hatasi_durumunda_false_don():
    with patch('metrics_sender.requests.post') as mock_post:
        mock_post.side_effect = ConnectionError("API erişilemiyor")
        
        result = send_metric("cpu_usage", 75.5, "web-server-01")
        
        assert result == False

def test_api_500_hatasi_durumunda_false_don():
    with patch('metrics_sender.requests.post') as mock_post:
        mock_post.return_value.status_code = 500
        
        result = send_metric("cpu_usage", 75.5, "web-server-01")
        
        assert result == False

Bu testleri geçecek kodu yazıyoruz:

# metrics_sender.py
import requests
import logging

logger = logging.getLogger(__name__)

def send_metric(metric_name: str, value: float, host: str) -> bool:
    payload = {
        "metric_name": metric_name,
        "value": value,
        "host": host
    }
    
    try:
        response = requests.post(
            "https://metrics.internal/api/v1/metrics",
            json=payload,
            timeout=5
        )
        
        if response.status_code == 200:
            return True
        
        logger.error(f"API hata kodu dondu: {response.status_code}")
        return False
        
    except Exception as e:
        logger.error(f"Metrik gonderim hatasi: {e}")
        return False

CI/CD Pipeline’a TDD Entegrasyonu

TDD yazmak güzel ama bunu otomatize etmeden yarısı kalır. GitHub Actions ile basit bir pipeline:

# .github/workflows/test.yml
name: Test Suite

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Python ortamını hazırla
      uses: actions/setup-python@v4
      with:
        python-version: '3.11'
    
    - name: Bağımlılıkları yükle
      run: |
        pip install pytest pytest-cov
        pip install -r requirements.txt
    
    - name: Python testlerini çalıştır
      run: |
        pytest --cov=. --cov-report=xml --cov-fail-under=80
    
    - name: BATS testlerini çalıştır
      run: |
        sudo apt-get install -y bats
        bats tests/bash/
    
    - name: Coverage raporunu yükle
      uses: codecov/codecov-action@v3

Burada --cov-fail-under=80 parametresi önemli: coverage yüzde 80’in altına düşerse pipeline başarısız sayılır. Bu sayede “test var ama hiç coverage yok” durumu otomatik olarak engellenir.

TDD’nin Önündeki Gerçek Engeller

Teorik anlatımdan sonra dürüst olmak gerekiyor. TDD uygulamak her zaman kolay değil ve bazı gerçek engeller var:

Zaman baskısı: “Bunu bugün deploy etmem lazım” denen ortamlarda TDD zor gelir. Ama şunu anlamak gerekiyor: başta yavaş hissettiren TDD, orta vadede debug ve hotfix süresini dramatik olarak kısaltır. Bir keresinde TDD uyguladığımız bir modülde üç ay boyunca tek bir production bug’ı çıkmamıştı. Diğer modüllerde ise aynı sürede onlarca ticket açılmıştı.

Mevcut kod tabanı: Yeni başlayan projede TDD uygulamak kolay. Ama beş yıllık legacy bir sisteme TDD sokmak başka mesele. Burada tavsiyem şu: yeni feature eklerken ve bug fix yaparken TDD uygula. Mevcut koda zorla test yazmaya çalışma, önce refactor gerekir ve bu ayrı bir iş.

Test edilmesi zor kod: Bazı şeyler gerçekten zor test edilir. Sistem çağrıları, donanım etkileşimleri, zamana bağlı işlemler bunların başında gelir. Dependency injection ve mock kullanımını öğrenmek bu noktada kritik.

Ekip direnci: “Bu boşa vakit harcamak” diyenler olacak. Burada sayılarla konuşmak gerekiyor. Test yazılan ve yazılmayan modüllerin bug oranlarını, fix sürelerini karşılaştır, verilerle göster.

Coverage Analizi: Ne Kadar Yeterli?

Yüzde yüz coverage hedeflemek çoğu zaman mantıklı değil. Zaman kaybı ve bazı durumlarda anlamsız testler üretir. Gerçekçi hedefler:

  • Kritik iş mantığı: Yüzde 90 ve üzeri
  • Utility fonksiyonları: Yüzde 80 ve üzeri
  • Konfigürasyon dosyaları: Coverage aranmaz
  • UI/görsel katman: Farklı test yaklaşımı gerekir

Coverage raporunu görsel olarak incelemek için:

# HTML coverage raporu üret
pytest --cov=log_rotator --cov-report=html

# Raporu aç
open htmlcov/index.html  # macOS
xdg-open htmlcov/index.html  # Linux

# Sadece terminal çıktısı için
pytest --cov=log_rotator --cov-report=term-missing

--cov-report=term-missing özellikle kullanışlı: hangi satırların test edilmediğini doğrudan terminalde gösterir.

Pytest Fixtures ile Test Ortamı Yönetimi

Gerçek projelerde test verisini her test için tekrar oluşturmak zahmetli ve yavaş. Fixtures bu sorunu çözer:

# conftest.py - proje genelinde kullanılabilir fixtures
import pytest
import tempfile
import os

@pytest.fixture
def temp_log_directory():
    """Geçici log dizini oluşturur, test sonrası temizler"""
    with tempfile.TemporaryDirectory() as tmpdir:
        # Örnek log dosyaları oluştur
        for i in range(5):
            log_path = os.path.join(tmpdir, f"app.log.{i}")
            with open(log_path, 'w') as f:
                f.write("2024-01-01 00:00:00 INFO Test log entryn" * 1000)
        yield tmpdir
        # TemporaryDirectory context manager otomatik temizler

@pytest.fixture
def mock_config():
    return {
        "max_log_size_mb": 100,
        "retention_days": 30,
        "compress_after_days": 7,
        "alert_threshold": 80
    }

# Test dosyasında kullanım
def test_eski_loglari_temizle(temp_log_directory, mock_config):
    from log_rotator import cleanup_old_logs
    
    cleaned_count = cleanup_old_logs(
        temp_log_directory, 
        mock_config["retention_days"]
    )
    
    assert isinstance(cleaned_count, int)
    assert cleaned_count >= 0

Sonuç

TDD bir din değil, bir araç. Her projeye, her ortama körü körüne uygulamak doğru değil. Ama özellikle production’ı etkileyen scriptler, otomasyon araçları ve servisler için TDD uygulamak, uzun vadede çok ciddi bir kararlılık ve güvenilirlik sağlıyor.

Başlamak için büyük adımlar atmak şart değil. Bugün yazdığın bir sonraki Python fonksiyonu için önce testi yaz. Küçük hisset. Garip hisset. Ama devam et. Birkaç hafta sonra bak, ne kadar değiştiğini göreceksin.

Sysadmin ve DevOps dünyasında kod kalitesi artık bir “yazılımcı meselesi” değil. Altyapıyı kod olarak yönetiyoruz, otomasyonu kod olarak yazıyoruz. O zaman bu kodun kalitesini ciddiye almak da bizim işimiz.

Bir yanıt yazın

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