Neden test yazarız?
Ilgaz Bank'ta komisyon hesaplayan fonksiyonu değiştirdin; üç ay sonra biri "EFT komisyonu yanlış çıkıyor" dedi. Test olmadan hatanın hangi değişiklikle geldiğini bulmak saatler alır. Test (test) olduğunda ise her değişiklikten sonra tek komutla "hâlâ her şey doğru mu?" sorusunun yanıtını alırsın. Test, kodun sözleşmesidir: "Bu girdiyle bu çıktıyı veririm" cümlesinin çalıştırılabilir hali. İyi test üç şey sağlar: hatayı erken yakalar, kodu korkmadan değiştirmeni (refactoring) sağlar ve fonksiyonun nasıl kullanıldığını belgeler.
unittest ile ilk test sınıfı
Python'ın standart test çerçevesi unittest'tir. Kural basit: unittest.TestCase'den türeyen bir sınıf yazarsın, adı test_ ile başlayan her metot bir testtir, içinde self.assert... metotlarıyla beklentini belirtirsin. Normalde testleri terminalden python -m unittest ile koşarsın; burada tarayıcıda olduğumuz için paketi (suite) elle yükleyip TextTestRunner ile çalıştıracağız. Çıktıyı io.StringIO'ya yönlendirmek, test raporunun program çıktısına karışmasını önler.
import io
import unittest
def komisyon(tutar: float, kanal: str) -> float:
"""Ilgaz Bank havale komisyonu: mobil ücretsiz, şube binde 5, en az 5 TL."""
if tutar <= 0:
raise ValueError("tutar pozitif olmalı")
if kanal == "mobil":
return 0.0
return max(5.0, tutar * 0.005)
class KomisyonTest(unittest.TestCase):
def test_mobil_ucretsiz(self):
self.assertEqual(komisyon(1000, "mobil"), 0.0)
def test_sube_binde_bes(self):
self.assertAlmostEqual(komisyon(10_000, "sube"), 50.0)
def test_sube_asgari_bes(self):
self.assertEqual(komisyon(100, "sube"), 5.0)
def test_negatif_tutar_hata(self):
with self.assertRaises(ValueError):
komisyon(-1, "mobil")
suite = unittest.defaultTestLoader.loadTestsFromTestCase(KomisyonTest)
print("test sayısı:", suite.countTestCases())
sonuc = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
print("koşan:", sonuc.testsRun, "| hata:", len(sonuc.failures) + len(sonuc.errors))
print("hepsi geçti mi?", sonuc.wasSuccessful())
assertAlmostEqual kayan noktalı sayıları 7 basamağa kadar karşılaştırır; para hesabında assertEqual(0.1 + 0.2, 0.3) yazarsan test boşuna düşer. assertRaises bir bağlam yöneticisidir (context manager): bloğun içinde beklenen istisna çıkmazsa test başarısız olur.
| Metot | Ne kontrol eder | Örnek |
|---|---|---|
| assertEqual(a, b) | a == b | assertEqual(topla(2, 3), 5) |
| assertNotEqual(a, b) | a != b | assertNotEqual(token1, token2) |
| assertTrue(x) / assertFalse(x) | bool(x) | assertTrue(gecerli_mi("TR01")) |
| assertIn(a, b) | a in b | assertIn("Ankara", sehirler) |
| assertIsNone(x) | x is None | assertIsNone(bul("yok")) |
| assertAlmostEqual(a, b, places=2) | round(a-b, places) == 0 | assertAlmostEqual(kdv, 20.0, places=2) |
| assertRaises(Hata) | blok Hata fırlatır | with self.assertRaises(ValueError): ... |
| assertCountEqual(a, b) | aynı elemanlar, sıra fark etmez | assertCountEqual(sonuc, [1, 2, 3]) |
setUp, tearDown ve subTest
Her testte aynı veriyi kurmak tekrar yaratır. setUp her test metodundan önce, tearDown her testten sonra çalışır; nesneleri self üzerinde paylaşırsın. subTest ise tek bir test metodunda birden çok girdiyi denemeni sağlar; biri düşse bile diğerleri koşmaya devam eder ve rapor hangi girdinin düştüğünü söyler. Bu, pytest'teki parametrize'ın unittest karşılığıdır.
import io
import unittest
class Sepet:
def __init__(self):
self.urunler = []
def ekle(self, ad, fiyat, adet=1):
if adet < 1:
raise ValueError("adet en az 1")
self.urunler.append((ad, fiyat, adet))
def toplam(self):
return sum(f * a for _, f, a in self.urunler)
def indirimli(self, oran):
return round(self.toplam() * (1 - oran), 2)
class SepetTest(unittest.TestCase):
def setUp(self):
self.sepet = Sepet()
self.sepet.ekle("klavye", 450.0)
self.sepet.ekle("kablo", 25.0, 2)
def tearDown(self):
self.sepet.urunler.clear()
def test_toplam(self):
self.assertEqual(self.sepet.toplam(), 500.0)
def test_indirim_oranlari(self):
for oran, beklenen in [(0.0, 500.0), (0.1, 450.0), (0.5, 250.0)]:
with self.subTest(oran=oran):
self.assertAlmostEqual(self.sepet.indirimli(oran), beklenen)
def test_sifir_adet_hata(self):
with self.assertRaises(ValueError):
self.sepet.ekle("x", 1.0, 0)
suite = unittest.defaultTestLoader.loadTestsFromTestCase(SepetTest)
adlar = [t.id().split(".")[-1] for t in suite]
print("testler:", adlar)
sonuc = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
print("başarılı:", sonuc.wasSuccessful(), "| koşan:", sonuc.testsRun)
for t in suite artık hiçbir şey vermez. Test adlarını ya da sayısını raporlayacaksan run çağrısından önce al. Yukarıdaki adlar satırının yeri bu yüzden önemli.pytest tarzı: düz assert ve parametrize
Sektörde çoğu proje pytest kullanır (pip ile kurulur; burada çalıştıramıyoruz ama tanıman gerekir). Fark biçimseldir: sınıf şart değil, test_ ile başlayan düz fonksiyon yeter; self.assertEqual yerine düz assert yazarsın ve pytest hata mesajını kendisi zenginleştirir. Parametreli test için dekoratör (decorator) vardır:
# pytest tarzı (gösterim; burada çalıştırmıyoruz)
ornek = '''
import pytest
from bank import komisyon
def test_mobil_ucretsiz():
assert komisyon(1000, "mobil") == 0.0
@pytest.mark.parametrize("tutar, beklenen", [
(100, 5.0),
(10_000, 50.0),
(2_000, 10.0),
])
def test_sube(tutar, beklenen):
assert komisyon(tutar, "sube") == pytest.approx(beklenen)
def test_negatif():
with pytest.raises(ValueError):
komisyon(-1, "mobil")
'''
print(ornek.strip().count("def test_"), "test fonksiyonu; parametrize 3 ayrı test üretir")
print("komut: pytest -v -> test_sube[100-5.0], test_sube[10000-50.0], ...")
pytest unittest sınıflarını da koşturabilir; yani bugün unittest ile yazdığın testler pytest kurulduğunda da çalışır. Yeni projede pytest, standart kütüphaneyle sınırlı ortamda unittest seçersin; mantık ikisinde de aynı: düzenle (arrange), çalıştır (act), doğrula (assert).
Dış bağımlılığı taklit etmek: unittest.mock
"Gece saatinde yapılan işlem şüphelidir" kuralını test etmek istiyorsun; ama fonksiyon datetime.now() çağırıyor ve testin sonucu saat kaçta koştuğuna bağlı. Çözüm, gerçek bağımlılığı sahte bir nesneyle (mock) değiştirmek. patch bir adı geçici olarak değiştirir, MagicMock ise her çağrıyı kaydeden ve istediğin değeri döndüren esnek bir nesnedir. Kural: bağımlılığı kullanıldığı yerde yamalarsın, tanımlandığı yerde değil.
import io
import unittest
from datetime import datetime
from unittest.mock import MagicMock, patch
class IslemKurali:
def saat(self) -> int:
return datetime.now().hour # dış bağımlılık: gerçek saat
def gece_mi(self) -> bool:
s = self.saat()
return s >= 23 or s < 6
def sms_gonder(gonderici, telefon, metin):
return gonderici.gonder(telefon, metin)
class KuralTest(unittest.TestCase):
def test_gece_uc(self):
with patch.object(IslemKurali, "saat", return_value=3):
self.assertTrue(IslemKurali().gece_mi())
def test_ogle_on_iki(self):
with patch.object(IslemKurali, "saat", return_value=12):
self.assertFalse(IslemKurali().gece_mi())
def test_sms_cagrisi(self):
sahte = MagicMock()
sahte.gonder.return_value = "OK"
self.assertEqual(sms_gonder(sahte, "5551234567", "Kod: 1234"), "OK")
sahte.gonder.assert_called_once_with("5551234567", "Kod: 1234")
suite = unittest.defaultTestLoader.loadTestsFromTestCase(KuralTest)
sonuc = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
print("başarılı:", sonuc.wasSuccessful(), "| koşan:", sonuc.testsRun)
print("gerçek saat şu an gece mi?", IslemKurali().gece_mi())
patch.object(IslemKurali, "saat", return_value=3) sınıfın saat metodunu blok boyunca sabit 3 döndüren sahte bir metotla değiştirir. Modül düzeyindeki bir fonksiyonu yamalamak için patch("anadolu.kurallar.saat_ver", return_value=3) gibi modül yolu yazarsın; bu derste tek dosyada çalıştığımız için sınıf üzerinden yamalıyoruz. assert_called_once_with ise mock'un tam olarak bu argümanlarla bir kez çağrıldığını doğrular; SMS'in gerçekten gönderilmediğinden de emin olursun.
TDD döngüsü: Kırmızı, Yeşil, Düzenle
Test güdümlü geliştirme (Test-Driven Development) kodu yazmadan önce testi yazmaktır. Döngü üç adımdır ve her tur birkaç dakika sürer:
- Kırmızı: İstediğin davranış için küçük bir test yaz; henüz kod olmadığı için test düşer.
- Yeşil: Testi geçirecek en basit kodu yaz. Güzel olması gerekmez, geçmesi gerekir.
- Düzenle (refactor): Testler yeşilken kodu temizle: tekrarı kaldır, ad düzelt. Testler hâlâ yeşilse bitti; sonraki davranışa geç.
TDD'nin asıl kazancı tasarımdır: fonksiyonu önce kullanan kişi olarak yazdığın için arayüzü (parametre adları, dönüş tipi) kullanıcı gözüyle seçersin. Alıştırmalarda tam bunu yapacaksın: önce test sınıfı, sonra fonksiyon.
Derinleş: İyi test yazmanın araç kutusu
Test yazabiliyorsun; şimdi soru şu: testlerin gerçekten hata yakalıyor mu? Her zaman geçen bir test, hiç olmayan testten daha tehlikelidir çünkü sahte güven verir. Bu bölümde doğru assert metodunu seçmeyi, mock'u ayrıntılı kullanmayı (side_effect, çağrı doğrulama, spec), belge içi testleri (doctest), testleri komut satırından koşturmayı ve kapsamı (coverage) ölçmeyi görüyoruz. pytest'in fixture ve eklenti dünyası pytest dersinde, property-based test ve CI entegrasyonu test-ileri ile kalite-ci derslerinde.
Doğru assert, iyi hata mesajı
assertTrue(a == b) düşerse yalnızca "False is not true" yazar; assertEqual(a, b) ise iki değeri ve farkı gösterir. Hata mesajı, testi yazmayan birinin sorunu anlamasını sağlamalıdır.
| Durum | unittest | pytest karşılığı |
|---|---|---|
| Eşitlik (liste/sözlük farkını gösterir) | assertEqual(a, b) | assert a == b |
| Float karşılaştırma | assertAlmostEqual(a, b, places=2) | a == pytest.approx(b) |
| Sırasız aynı elemanlar | assertCountEqual(a, b) | Counter(a) == Counter(b) |
| İstisna ve mesajı | assertRaisesRegex(ValueError, "negatif") | pytest.raises(ValueError, match="negatif") |
| Üyelik, tip, None | assertIn, assertIsInstance, assertIsNone | assert x in y ... |
| Log üretildi mi | assertLogs("ad", level="WARNING") | caplog fixture |
| Bilerek atla / beklenen hata | @skip, @expectedFailure | @pytest.mark.skip, xfail |
import io, unittest
def iade(tutar, gun):
if tutar < 0:
raise ValueError("tutar negatif olamaz")
return tutar if gun <= 14 else round(tutar * 0.5, 2)
class IadeTest(unittest.TestCase):
def test_mesaj_kontrolu(self):
with self.assertRaisesRegex(ValueError, "negatif"):
iade(-1, 3)
def test_istisna_nesnesi(self):
with self.assertRaises(ValueError) as bag:
iade(-5, 1)
self.assertIn("tutar", str(bag.exception))
def test_sirasiz(self):
self.assertCountEqual([iade(100, 20), iade(10, 1)], [10, 50.0])
def test_kotu_mesaj_ornegi(self):
self.assertTrue(iade(100, 20) == 100) # bilerek yanlış: mesaja bak
r = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(unittest.defaultTestLoader.loadTestsFromTestCase(IadeTest))
print("koşan:", r.testsRun, "| düşen:", len(r.failures))
print("assertTrue mesajı:", r.failures[0][1].strip().splitlines()[-1])
Mock ayrıntıları: side_effect, çağrı doğrulama, spec
return_value her çağrıda aynı değeri döndürür. side_effect ise üç şey olabilir: bir liste (her çağrıda sıradaki değer; istisna nesnesi varsa fırlatılır), bir istisna (her çağrıda fırlatılır) ya da bir fonksiyon (argümanlara göre hesaplar). Böylece "banka ilk iki denemede zaman aşımına düştü, üçüncüde başardı" senaryosunu ağa çıkmadan test edersin. Ardından mock'a ne ile, kaç kez çağrıldığını sorarsın.
from unittest.mock import Mock, call
class BankaHatasi(Exception):
pass
def tahsil_et(banka, tutar, deneme=3):
for i in range(deneme):
try:
return banka.cek(tutar)
except BankaHatasi:
if i == deneme - 1:
raise
banka = Mock()
banka.cek.side_effect = [BankaHatasi("zaman aşımı"), BankaHatasi("zaman aşımı"), "ONAY-77"]
print(tahsil_et(banka, 250))
print("çağrı sayısı:", banka.cek.call_count)
banka.cek.assert_called_with(250)
print(banka.cek.call_args_list == [call(250)] * 3)
sms = Mock()
sms.gonder("0555", "Siparişiniz yolda")
sms.gonder.assert_called_once_with("0555", "Siparişiniz yolda")
sms.gonder.reset_mock()
sms.gonder.assert_not_called()
class SmsServisi:
def gonder(self, numara, metin): ...
siki = Mock(spec=SmsServisi)
try:
siki.gondr("0555", "x") # yazım hatası: spec yakalar
except AttributeError as e:
print("spec yakaladı:", e)
Mock() var olmayan bir metoda da çağrı kabul eder; gerçek sınıfta metot adı değişse bile testlerin geçmeye devam eder. Mock(spec=Sinif) ya da patch(..., autospec=True) ile mock'u gerçek arayüze bağla.Test dublörleri ve bağımlılık enjeksiyonu
Her sahte nesne "mock" değildir. Stub hazır cevap verir, mock çağrıları doğrular, fake çalışan ama basit bir gerçekleme sunar (ör. sözlükle çalışan bellek içi depo). En temiz yöntem çoğu zaman yamalamak değil, bağımlılığı parametre olarak almaktır (dependency injection): fonksiyon saati, depoyu ya da istemciyi dışarıdan alırsa testte gerçeğinin yerine basit bir fake verirsin.
from datetime import datetime
class BellekDepo: # fake: gerçek veritabanının basit hâli
def __init__(self):
self.kayitlar = {}
def kaydet(self, no, veri):
self.kayitlar[no] = veri
def siparis_al(depo, no, tutar, saat=datetime.now):
damga = saat()
depo.kaydet(no, {"tutar": tutar, "gece": damga.hour < 6})
return damga
depo = BellekDepo()
sabit = lambda: datetime(2026, 3, 15, 2, 30) # stub saat
siparis_al(depo, "S-1", 120, saat=sabit)
print(depo.kayitlar)
doctest: belgedeki örnek de bir testtir
Docstring içine >>> ile yazdığın örnekleri doctest çalıştırıp çıktıyı karşılaştırır. Belge ile kodun birbirinden kopmasını engeller; küçük saf fonksiyonlar için idealdir, karmaşık senaryolar için unittest/pytest daha uygundur.
import doctest
def kurus(tutar_tl):
"""TL tutarını kuruşa çevirir.
>>> kurus(12.5)
1250
>>> kurus(0.1)
10
"""
return round(tutar_tl * 100)
kosucu = doctest.DocTestRunner(verbose=False)
for t in doctest.DocTestFinder().find(kurus, "kurus", globs={"kurus": kurus}):
kosucu.run(t)
print(kosucu.summarize(verbose=False))
Komut satırında koşturmak ve kapsam ölçmek
Testler genelde tests/ klasöründe test_*.py dosyalarında durur. Kendi bilgisayarında proje kökünde şu komutları kullanırsın:
python -m unittest discover -s tests -v # tüm unittest testleri python -m unittest tests.test_sepet.SepetTest.test_toplam # tek test python -m doctest -v kurus.py # docstring örnekleri pip install pytest pytest-cov # sanal ortam etkinken pytest -q # kısa çıktı pytest -k "iade and not negatif" # adla süz pytest -x --lf # ilk hatada dur, son düşenleri tekrar koş pytest --cov=src --cov-report=term-missing # kapsam: hangi satırlar test edilmedi
Sık karşılaşılan sorunlar: Ran 0 tests görüyorsan dosya adı test_ ile başlamıyor ya da tests klasöründe __init__.py yok (unittest discover için gerekebilir). ModuleNotFoundError: No module named 'src' alıyorsan paketi pip install -e . ile kur ya da testleri proje kökünden çalıştır. Kapsam (coverage) yüzdesi hedef değil, bir el fenerdir: %100 kapsam doğru assert'ler olmadan hiçbir şey kanıtlamaz.
<= yerine <) ve testin kırmızıya döndüğünü gör. Buna mutasyon testi denir; mutmut gibi araçlar bunu otomatik yapar. Bu dersteki bazı alıştırmalar da testini bozuk bir gerçeklemeye karşı koşturarak doğrular.Test piramidi ve kurumsal pratik
Bir projede üç katman test bulunur. Tabanda birim testleri (unit): tek fonksiyon ya da sınıf, milisaniyede koşar, yüzlerce olur. Ortada entegrasyon testleri: veritabanı, dosya, API ile birlikte çalışır; sqlite3 ":memory:" burada kahramandır. Tepede uçtan uca testler: gerçek sistemin tamamı; azdır ve yavaştır. Piramidin ters dönmesi (çok uçtan uca, az birim) testlerin yavaş ve kırılgan olmasına yol açar.
Üç pratik kural: Birincisi, test adı bir cümle olsun: test_sube_komisyonu_asgari_bes_lira; düşen testin adı hatayı anlatır. İkincisi, her test tek bir şeyi doğrulasın; üç assert'li test düştüğünde hangisi diye bakmak zaman alır. Üçüncüsü, kapsama (coverage) yüzdesine takılma; kritik iş kuralı (para, yetki, tarih) yüzde yüz, loglama satırı sıfır olabilir. CI (sürekli entegrasyon) ortamında testler her commit'te otomatik koşar; İleri seviyede GitHub Actions ile bunu kuracaksın. O gün geldiğinde elinde zaten yeşil bir test paketi olmalı.