Ana içeriğe geç
Kesit Python
Orta · Test

Test yazma: unittest, pytest ve mock

unittest ile TestCase yazar, assert metotlarını ve setUp/subTest kalıplarını kullanır, pytest tarzını tanır, mock ile dış bağımlılığı taklit eder ve TDD döngüsünü uygularsın.

Uygulamada aç, 21 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

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.

MetotNe kontrol ederÖrnek
assertEqual(a, b)a == bassertEqual(topla(2, 3), 5)
assertNotEqual(a, b)a != bassertNotEqual(token1, token2)
assertTrue(x) / assertFalse(x)bool(x)assertTrue(gecerli_mi("TR01"))
assertIn(a, b)a in bassertIn("Ankara", sehirler)
assertIsNone(x)x is NoneassertIsNone(bul("yok"))
assertAlmostEqual(a, b, places=2)round(a-b, places) == 0assertAlmostEqual(kdv, 20.0, places=2)
assertRaises(Hata)blok Hata fırlatırwith self.assertRaises(ValueError): ...
assertCountEqual(a, b)aynı elemanlar, sıra fark etmezassertCountEqual(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)
Test adlarını run'dan ÖNCE toplaSuite çalıştırıldıktan sonra içi boşaltılır; 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.
Testler birbirinden bağımsız olmalıunittest metotları alfabetik sırada koşturur, sen yazdığın sırada değil. Bir testin diğerinin bıraktığı veriye güvenmesi gizli hata üretir; setUp her test için temiz başlangıç sağlar.

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.

Mock'u az kullanHer şeyi mock'larsan testler kodun yapısına yapışır; fonksiyonu yeniden düzenlediğinde testler bozulur ama hata yoktur. Yalnızca yavaş, dışa bağımlı ya da rastgele olanı (saat, ağ, veritabanı, random) taklit et.

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:

  1. Kırmızı: İstediğin davranış için küçük bir test yaz; henüz kod olmadığı için test düşer.
  2. Yeşil: Testi geçirecek en basit kodu yaz. Güzel olması gerekmez, geçmesi gerekir.
  3. 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ş

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.

Durumunittestpytest karşılığı
Eşitlik (liste/sözlük farkını gösterir)assertEqual(a, b)assert a == b
Float karşılaştırmaassertAlmostEqual(a, b, places=2)a == pytest.approx(b)
Sırasız aynı elemanlarassertCountEqual(a, b)Counter(a) == Counter(b)
İstisna ve mesajıassertRaisesRegex(ValueError, "negatif")pytest.raises(ValueError, match="negatif")
Üyelik, tip, NoneassertIn, assertIsInstance, assertIsNoneassert x in y ...
Log üretildi miassertLogs("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)
spec kullanmayan mock her şeye evet derDüz 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.

Testini test etBir testin değerli olup olmadığını anlamanın en kısa yolu: koddaki bir koşulu bilerek boz (ör. <= 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.
Gerçek işteIlgaz Bank'ta bir hata canlıya çıktığında ilk iş, hatayı yeniden üreten bir test yazmaktır (regression test); test kırmızıyken düzeltme yapılır, yeşile dönünce birleştirilir. Pull request'ler ancak tüm testler CI'da geçtiğinde ve değişen satırların kapsamı belirli bir eşiğin üzerinde olduğunda onaylanır. Dış servisler (SMS, ödeme, kur) testlerde her zaman spec'li mock ya da fake ile değiştirilir; test süitinin ağsız ve birkaç saniyede bitmesi hedeflenir.
Uzmanlaş

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ı.

Alıştırmalara geç (21)