Python unittest Modülü ile Test Yazımı

Production ortamında bir kez “şu kod çalışıyor, dokunma” tuzağına düşmüşsünüzse, test yazmanın neden bu kadar kritik olduğunu zaten biliyorsunuzdur. Ama Türkiye’deki çoğu ekipte test kültürü hâlâ “zaman olursa yazarız” aşamasında. Bu yazıda Python’un standart kütüphanesinde gelen unittest modülünü, gerçek dünya senaryolarıyla ve sysadmin perspektifinden ele alacağız. Pytest kadar popüler olmayabilir, ama hiçbir bağımlılık gerektirmiyor ve CI/CD pipeline’larında son derece güvenilir çalışıyor.

unittest Nedir ve Neden Kullanmalıyız?

unittest, Python’un standart kütüphanesine dahil bir test framework’üdür. Java’nın JUnit’inden ilham alınarak tasarlanmıştır. Pip ile hiçbir şey yüklemenize gerek kalmadan, Python kurulu olan her ortamda çalışır. Bu özellik, özellikle kısıtlı ortamlarda (air-gapped sistemler, legacy sunucular, minimal Docker imajları) çok büyük avantaj sağlar.

Bir otomasyon scripti yazıyorsunuz, sistem kaynaklarını kontrol eden bir araç geliştiriyorsunuz ya da bir API wrapper üretiyorsunuz. Bu kodun doğru çalıştığından nasıl emin olacaksınız? Manuel test? Her değişiklikten sonra elle çalıştırmak? Hayır. Test otomasyonu burada devreye giriyor.

Temel Yapı: TestCase Sınıfı

unittest framework’ünün temel yapı taşı TestCase sınıfıdır. Her test dosyası bu sınıftan türeyen sınıflar içerir ve her test metodu test_ önekiyle başlar.

# test_basic.py

import unittest

class SystemCheckTest(unittest.TestCase):

    def test_simple_assertion(self):
        sonuc = 2 + 2
        self.assertEqual(sonuc, 4)

    def test_string_kontrol(self):
        hostname = "web-server-01"
        self.assertTrue(hostname.startswith("web"))
        self.assertIn("server", hostname)

    def test_none_kontrol(self):
        config_degeri = None
        self.assertIsNone(config_degeri)

if __name__ == "__main__":
    unittest.main()

Çalıştırmak için:

python -m unittest test_basic.py -v

-v (verbose) parametresi her testin ismini ve sonucunu ayrı ayrı gösterir. CI pipeline’larında log okunabilirliği açısından bu parametreyi her zaman eklemenizi öneririm.

setUp ve tearDown: Test Öncesi ve Sonrası

Gerçek dünya testlerinde çoğu zaman her testten önce bir ortam hazırlamanız, sonrasında ise temizlemeniz gerekir. Geçici dosya oluşturma, veritabanı bağlantısı açma veya bir mock servis başlatma bunların başında gelir.

# test_dosya_isleme.py

import unittest
import os
import tempfile

class DosyaIslemeTesti(unittest.TestCase):

    def setUp(self):
        # Her testten önce çalışır
        self.test_dizin = tempfile.mkdtemp()
        self.test_dosya = os.path.join(self.test_dizin, "test.log")
        with open(self.test_dosya, "w") as f:
            f.write("ERROR: disk dolun")
            f.write("INFO: servis başlatıldın")
            f.write("ERROR: bağlantı zaman aşımın")

    def tearDown(self):
        # Her testten sonra çalışır - temizlik burada
        import shutil
        shutil.rmtree(self.test_dizin, ignore_errors=True)

    def test_error_satir_sayisi(self):
        with open(self.test_dosya) as f:
            satirlar = f.readlines()
        error_satirlar = [s for s in satirlar if s.startswith("ERROR")]
        self.assertEqual(len(error_satirlar), 2)

    def test_dosya_bos_degil(self):
        self.assertGreater(os.path.getsize(self.test_dosya), 0)

setUp başarısız olursa test çalışmaz ama tearDown her koşulda çalışır. Bu detay önemli: eğer setUp‘ta bir exception fırlarsa, o test için tearDown çağrılmaz. Bu yüzden kritik temizlik işlemleri için addCleanup metodunu tercih edin.

setUpClass ve tearDownClass: Sınıf Seviyesinde Hazırlık

Bazen her test için değil, tüm test sınıfı için bir kez hazırlık yapmanız gerekir. Örneğin bir veritabanı bağlantısı her test için açılıp kapatılmamalıdır.

# test_veritabani.py

import unittest
import sqlite3
import tempfile
import os

class VeriTabanıTesti(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        # Tüm testlerden önce bir kez çalışır
        cls.db_dosya = tempfile.mktemp(suffix=".db")
        cls.conn = sqlite3.connect(cls.db_dosya)
        cls.cursor = cls.conn.cursor()
        cls.cursor.execute("""
            CREATE TABLE sunucular (
                id INTEGER PRIMARY KEY,
                hostname TEXT,
                ip TEXT,
                aktif INTEGER
            )
        """)
        cls.cursor.executemany(
            "INSERT INTO sunucular VALUES (?, ?, ?, ?)",
            [
                (1, "web-01", "10.0.0.1", 1),
                (2, "db-01", "10.0.0.2", 1),
                (3, "backup-01", "10.0.0.3", 0),
            ]
        )
        cls.conn.commit()

    @classmethod
    def tearDownClass(cls):
        # Tüm testlerden sonra bir kez çalışır
        cls.conn.close()
        os.unlink(cls.db_dosya)

    def test_aktif_sunucu_sayisi(self):
        self.cursor.execute("SELECT COUNT(*) FROM sunucular WHERE aktif=1")
        sayi = self.cursor.fetchone()[0]
        self.assertEqual(sayi, 2)

    def test_hostname_sorgulama(self):
        self.cursor.execute(
            "SELECT ip FROM sunucular WHERE hostname=?", ("web-01",)
        )
        ip = self.cursor.fetchone()[0]
        self.assertEqual(ip, "10.0.0.1")

Mock Kullanımı: Dış Bağımlılıkları İzole Etmek

Bu kısım, test yazmanın gerçekten güçlendiği yer. Sunucuya ping atmayan, gerçek API çağrısı yapmayan, dosya sistemine dokunmayan testler yazabilirsiniz. unittest.mock bu iş için biçilmiş kaftan.

Diyelim ki bir sunucu sağlık kontrolü yapan fonksiyonunuz var:

# sunucu_kontrol.py

import subprocess
import requests

def ping_kontrol(host):
    sonuc = subprocess.run(
        ["ping", "-c", "1", "-W", "2", host],
        capture_output=True
    )
    return sonuc.returncode == 0

def api_saglik_kontrol(url):
    try:
        yanit = requests.get(url, timeout=5)
        return yanit.status_code == 200
    except requests.exceptions.ConnectionError:
        return False

Bu fonksiyonları test ederken gerçekten ağa çıkmak istemeyiz:

# test_sunucu_kontrol.py

import unittest
from unittest.mock import patch, MagicMock
from sunucu_kontrol import ping_kontrol, api_saglik_kontrol

class SunucuKontrolTesti(unittest.TestCase):

    @patch("sunucu_kontrol.subprocess.run")
    def test_ping_basarili(self, mock_run):
        mock_run.return_value = MagicMock(returncode=0)
        sonuc = ping_kontrol("10.0.0.1")
        self.assertTrue(sonuc)
        mock_run.assert_called_once()

    @patch("sunucu_kontrol.subprocess.run")
    def test_ping_basarisiz(self, mock_run):
        mock_run.return_value = MagicMock(returncode=1)
        sonuc = ping_kontrol("10.0.0.99")
        self.assertFalse(sonuc)

    @patch("sunucu_kontrol.requests.get")
    def test_api_saglik_basarili(self, mock_get):
        mock_get.return_value = MagicMock(status_code=200)
        sonuc = api_saglik_kontrol("http://localhost:8080/health")
        self.assertTrue(sonuc)

    @patch("sunucu_kontrol.requests.get")
    def test_api_baglanti_hatasi(self, mock_get):
        import requests
        mock_get.side_effect = requests.exceptions.ConnectionError()
        sonuc = api_saglik_kontrol("http://cevapsiz-sunucu/health")
        self.assertFalse(sonuc)

patch decorator’ı, fonksiyonun içinde kullanılan modülün tam yolunu alır. Bu detayı çok insan karıştırır: subprocess‘i nerede import ettiğiniz değil, test ettiğiniz modülün nerede kullandığı önemli.

assertRaises: Exception Testleri

Kodunuzun hata durumlarında doğru exception fırlattığını test etmek, en az başarı senaryolarını test etmek kadar önemli. Log rotation scripti yanlış argüman alırsa ne olmalı? Konfigürasyon dosyası eksikse ne fırlatmalı?

# test_exception.py

import unittest

def log_rotasyon(dosya_yolu, max_boyut_mb):
    if not isinstance(max_boyut_mb, (int, float)):
        raise TypeError("max_boyut_mb sayısal olmalıdır")
    if max_boyut_mb <= 0:
        raise ValueError("max_boyut_mb sıfırdan büyük olmalıdır")
    if max_boyut_mb > 10240:
        raise ValueError("max_boyut_mb 10GB'ı geçemez")
    return True

class LogRotasyonTesti(unittest.TestCase):

    def test_gecersiz_tip(self):
        with self.assertRaises(TypeError):
            log_rotasyon("/var/log/app.log", "100mb")

    def test_sifir_boyut(self):
        with self.assertRaises(ValueError):
            log_rotasyon("/var/log/app.log", 0)

    def test_negatif_boyut(self):
        with self.assertRaises(ValueError) as context:
            log_rotasyon("/var/log/app.log", -5)
        # Exception mesajını da kontrol edebiliriz
        self.assertIn("sıfırdan büyük", str(context.exception))

    def test_gecerli_deger(self):
        sonuc = log_rotasyon("/var/log/app.log", 100)
        self.assertTrue(sonuc)

assertRaises context manager olarak kullandığınızda, context.exception üzerinden exception objesine erişip mesajını da test edebilirsiniz. Bu, kullanıcıya gösterilen hata mesajlarının tutarlılığı için kritik.

subTest: Parametrik Testler

Aynı testi farklı girdilerle çalıştırmak istediğinizde subTest kullanabilirsiniz. Pytest’in parametrize decorator’ına benzer ama biraz daha elle tutulur.

# test_ip_validasyon.py

import unittest
import ipaddress

def ip_gecerli_mi(ip_string):
    try:
        ipaddress.ip_address(ip_string)
        return True
    except ValueError:
        return False

class IPValidasyonTesti(unittest.TestCase):

    def test_gecerli_ipler(self):
        gecerli_ipler = [
            "192.168.1.1",
            "10.0.0.1",
            "172.16.0.1",
            "8.8.8.8",
            "2001:db8::1",  # IPv6
        ]
        for ip in gecerli_ipler:
            with self.subTest(ip=ip):
                self.assertTrue(ip_gecerli_mi(ip), f"{ip} geçerli olmalıydı")

    def test_gecersiz_ipler(self):
        gecersiz_ipler = [
            "256.256.256.256",
            "192.168.1",
            "benim-sunucum",
            "",
            "192.168.1.1.1",
        ]
        for ip in gecersiz_ipler:
            with self.subTest(ip=ip):
                self.assertFalse(ip_gecerli_mi(ip), f"{ip} geçersiz olmalıydı")

subTest olmadan bu testleri yazarsanız, ilk başarısız değerde döngü durur. subTest ile tüm değerler test edilir ve her biri ayrı ayrı raporlanır.

Test Discovery ve Organizasyon

Proje büyüdükçe test dosyaları da çoğalır. unittest‘in test discovery özelliği ile tüm testleri tek komutla çalıştırabilirsiniz.

# Proje yapısı
proje/
    src/
        sunucu_kontrol.py
        log_yonetim.py
        network_utils.py
    tests/
        __init__.py
        test_sunucu_kontrol.py
        test_log_yonetim.py
        test_network_utils.py
# Tüm testleri çalıştır
python -m unittest discover -s tests -p "test_*.py" -v

# Belirli bir test dosyası
python -m unittest tests.test_sunucu_kontrol -v

# Belirli bir test sınıfı
python -m unittest tests.test_sunucu_kontrol.SunucuKontrolTesti -v

# Belirli bir test metodu
python -m unittest tests.test_sunucu_kontrol.SunucuKontrolTesti.test_ping_basarili -v

-s: Test arama başlangıç dizini -p: Test dosyası deseni -v: Verbose modu

CI/CD Pipeline’a Entegrasyon

GitLab CI örneğiyle test çalıştırma:

# .gitlab-ci.yml

stages:
  - test
  - deploy

unit_testler:
  stage: test
  image: python:3.11-slim
  script:
    - pip install -r requirements.txt
    - python -m unittest discover -s tests -p "test_*.py" -v
  coverage: '/TOTAL.*s+(d+%)$/'
  artifacts:
    reports:
      junit: test-results.xml
    when: always

# XML raporu için runner scripti
xml_rapor:
  stage: test
  script:
    - python -m pytest tests/ --junitxml=test-results.xml
  # veya unittest-xml-reporting paketi ile:
  # python -m xmlrunner discover -s tests -o test-results/

Jenkins için basit bir Makefile ile entegrasyon da oldukça temiz çalışır:

# Makefile

.PHONY: test test-verbose lint

test:
	python -m unittest discover -s tests -p "test_*.py"

test-verbose:
	python -m unittest discover -s tests -p "test_*.py" -v

test-coverage:
	coverage run -m unittest discover -s tests -p "test_*.py"
	coverage report -m
	coverage html -d coverage_html/

lint:
	flake8 src/ tests/

coverage aracını unittest ile kullanmak da pytest’ten farklı değil. pip install coverage yeterli, framework bağımsız çalışıyor.

Gerçek Dünya Senaryosu: Disk Kullanım Monitörü Testi

Bunu bir arada görelim. Disk kullanım uyarısı gönderen bir sınıf yazıyoruz ve test ediyoruz:

# disk_monitor.py

import shutil

class DiskMonitor:

    def __init__(self, esik_yuzde=85):
        if not 0 < esik_yuzde <= 100:
            raise ValueError("Eşik değeri 1-100 arasında olmalıdır")
        self.esik_yuzde = esik_yuzde
        self.uyarilar = []

    def kullanim_yuzde(self, yol="/"):
        toplam, kullanilan, _ = shutil.disk_usage(yol)
        return (kullanilan / toplam) * 100

    def kontrol_et(self, yol="/"):
        yuzde = self.kullanim_yuzde(yol)
        if yuzde >= self.esik_yuzde:
            mesaj = f"UYARI: {yol} disk kullanımı %{yuzde:.1f}"
            self.uyarilar.append(mesaj)
            return True, mesaj
        return False, None

    def uyari_sayisi(self):
        return len(self.uyarilar)
# tests/test_disk_monitor.py

import unittest
from unittest.mock import patch
from disk_monitor import DiskMonitor

class DiskMonitorTesti(unittest.TestCase):

    def setUp(self):
        self.monitor = DiskMonitor(esik_yuzde=80)

    def test_gecersiz_esik_degeri(self):
        with self.assertRaises(ValueError):
            DiskMonitor(esik_yuzde=0)
        with self.assertRaises(ValueError):
            DiskMonitor(esik_yuzde=101)

    def test_gecerli_esik_degeri(self):
        monitor = DiskMonitor(esik_yuzde=90)
        self.assertEqual(monitor.esik_yuzde, 90)

    @patch("disk_monitor.shutil.disk_usage")
    def test_uyari_tetiklenmeli(self, mock_usage):
        # 90GB kullanılmış, 100GB toplam -> %90
        mock_usage.return_value = (100 * 2**30, 90 * 2**30, 10 * 2**30)
        uyari_var, mesaj = self.monitor.kontrol_et("/var")
        self.assertTrue(uyari_var)
        self.assertIn("UYARI", mesaj)
        self.assertIn("/var", mesaj)

    @patch("disk_monitor.shutil.disk_usage")
    def test_uyari_tetiklenmemeli(self, mock_usage):
        # 70GB kullanılmış, 100GB toplam -> %70
        mock_usage.return_value = (100 * 2**30, 70 * 2**30, 30 * 2**30)
        uyari_var, mesaj = self.monitor.kontrol_et("/var")
        self.assertFalse(uyari_var)
        self.assertIsNone(mesaj)

    @patch("disk_monitor.shutil.disk_usage")
    def test_birden_fazla_uyari_sayisi(self, mock_usage):
        mock_usage.return_value = (100 * 2**30, 90 * 2**30, 10 * 2**30)
        self.monitor.kontrol_et("/")
        self.monitor.kontrol_et("/var")
        self.monitor.kontrol_et("/home")
        self.assertEqual(self.monitor.uyari_sayisi(), 3)

if __name__ == "__main__":
    unittest.main()

Sık Yapılan Hatalar

  • Test bağımsızlığını bozmak: Testler birbirinin state’ine güvenmemeli. Her test kendi setUp’ında başlamalı.
  • Yanlış mock path: unittest.mock.patch hedeflediğiniz modülün import ettiği yeri değil, test ettiğiniz modülün kullandığı yeri patching etmeli.
  • tearDown’ı es geçmek: Geçici dosyalar, açık bağlantılar, mock’lar temizlenmezse testler kirli state bırakır ve CI’da çakışmalar olur.
  • Sadece happy path test etmek: Exception senaryoları, boş liste, None değer, boundary değerler test edilmezse production’da sürpriz bekler.
  • Test adlarını anlamsız bırakmak: test_1, test_fonksiyon yerine test_disk_yuzde_esigi_asildiginda_uyari_verir yazın. Test başarısız olduğunda neyin bozulduğunu anında anlarsınız.

Sonuç

unittest, özellikle ek bağımlılık ekleyemediğiniz ortamlarda ve Python standardına yakın kalmak istediğinizde güçlü bir seçenek. Mock mekanizması, test discovery, setUp/tearDown döngüsü ve subTest gibi özellikler çoğu gerçek dünya senaryosunu karşılar.

Test yazmak başta yavaşlatır, ama üç ay sonra o kodu değiştirmek zorunda kaldığınızda, ya da yeni bir ekip üyesi kodu anlamamaya çalışırken, testler hem güvence hem de canlı dokümantasyon görevi görür. Sysadmin scriptleri ve DevOps araçları için test yazmak “güzel olursa” kategorisinde değil, artık “olmazsa olmaz” kategorisinde. Prod’da bir şeyin neden bozulduğunu anlamaya çalışmak yerine, onu yakaladığınız anı düşünün. Fark, testin varlığı kadar basit.

Bir yanıt yazın

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