Python unittest Modülü ile Test Yazmak
Selamlar, bu yazımda Python'un standart kütüphanesinde gelen unittest modülüne biraz yakından bakacağız. Test yazmak ilk başta zaman kaybı gibi görünebilir; fakat bir kere alışkanlık haline geldikten sonra refactor yaparken arkanıza yaslanıp 'iyi ki yazmışım' dediğiniz tek araç oluyor. pytest çok güzel bir alternatif tabii, ama bence unittest ile başlamak hem standart kütüphane olduğu için ek bağımlılık istemiyor hem de OOP zihnine daha yakın duruyor. Hadi başlayalım.
TestCase ile ilk adım
Her şey bir unittest.TestCase sınıfını kalıtmakla başlıyor. İçindeki test_ ile başlayan metotlar, runner tarafından otomatik bulunuyor. Küçük bir örnek görelim:
# test_hesap.py
import unittest
def topla(a, b):
return a + b
def bol(a, b):
if b == 0:
raise ValueError('Sifira bolme yapilamaz')
return a / b
class TestHesap(unittest.TestCase):
def test_pozitif_toplama(self):
self.assertEqual(topla(2, 3), 5)
def test_sifira_bolme_hata_atar(self):
with self.assertRaises(ValueError):
bol(10, 0)
if __name__ == '__main__':
unittest.main()
Çalıştırmak için terminale şu komutu yazın:
python -m unittest test_hesap.py -v
-v parametresi her testin adını ve sonucunu tek tek yazdırır; CI loglarında neyin patladığını görmek için bence şart.
Assert metotları - hangisini ne zaman?
unittest size düz assert keyword'ünden çok daha zengin bir set sunuyor. assertEqual her şey için yeter denilebilir, ama doğru olanı seçmek hata mesajını netleştirir:
import unittest
class TestAssertler(unittest.TestCase):
def test_esitlik(self):
self.assertEqual(1 + 1, 2)
self.assertNotEqual('foo', 'bar')
def test_uyelik(self):
self.assertIn(3, [1, 2, 3, 4])
self.assertNotIn(99, [1, 2, 3])
def test_tip(self):
self.assertIsInstance('merhaba', str)
def test_yaklasik_esitlik(self):
# Float karsilastirmasinda dogrudan == kullanmak risklidir
self.assertAlmostEqual(0.1 + 0.2, 0.3, places=5)
def test_regex(self):
self.assertRegex('selam dunya', r'selam \w+')
assertAlmostEqual özellikle önemli; 0.1 + 0.2 == 0.3 Python'da False döner ve buna takılan arkadaşlar gördüm. Float ile uğraşıyorsanız bu metot kurtarıcıdır.
Fixture: setUp, tearDown ve sınıf düzeyi kurulum
Her testin temiz bir başlangıç noktasına ihtiyacı var. Bunun için setUp metodu her test öncesi, tearDown ise her test sonrası çalışır. Sınıf bazında bir kez çalışacak ağır kurulumlar için setUpClass ve tearDownClass var:
import unittest
import tempfile
import os
class TestDosya(unittest.TestCase):
def setUp(self):
self.tmp_dir = tempfile.mkdtemp()
self.dosya = os.path.join(self.tmp_dir, 'veri.txt')
with open(self.dosya, 'w') as f:
f.write('icerik')
def tearDown(self):
if os.path.exists(self.dosya):
os.remove(self.dosya)
os.rmdir(self.tmp_dir)
def test_dosya_var(self):
self.assertTrue(os.path.exists(self.dosya))
Şahsi kanaatim, tearDown yerine addCleanup kullanmak daha güvenli. setUp yarısında bir hata atılırsa tearDown çalışmaz; ama addCleanup ile kaydettiğiniz fonksiyonlar her durumda tetiklenir.
Mock ile dış dünyayı izole etmek
Birim testin amacı sadece sizin kodunuzu test etmek; veritabanı, HTTP servisi, e-posta sağlayıcısı gibi şeylere gerçekten gitmek istemezsiniz. unittest.mock tam burada devreye girer:
import unittest
from unittest.mock import Mock, patch
class KullaniciServisi:
def __init__(self, db, mail):
self.db = db
self.mail = mail
def kayit(self, eposta, ad):
kullanici = self.db.insert('users', {'eposta': eposta, 'ad': ad})
self.mail.gonder(eposta, f'Hosgeldin {ad}')
return kullanici
class TestKullaniciServisi(unittest.TestCase):
def test_kayit_dis_servisleri_cagirir(self):
db = Mock()
mail = Mock()
db.insert.return_value = {'id': 1}
servis = KullaniciServisi(db, mail)
sonuc = servis.kayit('a@b.com', 'Ali')
db.insert.assert_called_once()
mail.gonder.assert_called_once_with('a@b.com', 'Hosgeldin Ali')
self.assertEqual(sonuc['id'], 1)
@patch('requests.get')
def test_http_istegi_mocklanir(self, mock_get):
mock_get.return_value.status_code = 200
import requests
cevap = requests.get('https://ornek.com/api')
self.assertEqual(cevap.status_code, 200)
@patch dekoratörü ile bir modülün spesifik bir fonksiyonunu testin süresince değiştirebilirsiniz. Kritik nokta şu: patch çağrısında, fonksiyonun tanımlandığı yeri değil, kullanıldığı yeri verirsiniz. Bu detayda saatler kaybeden çok kişi gördüm.
Parametreli testler ve atlama
Aynı mantığı farklı veriyle test etmek istediğinizde subTest çok işe yarar. Bir döngünün içinde başarısız olan vaka açıkça raporlanır:
import unittest
def palindrom_mu(s: str) -> bool:
s = s.lower().replace(' ', '')
return s == s[::-1]
class TestPalindrom(unittest.TestCase):
def test_durumlar(self):
durumlar = [('kayak', True), ('ada', True), ('merhaba', False)]
for kelime, beklenen in durumlar:
with self.subTest(kelime=kelime):
self.assertEqual(palindrom_mu(kelime), beklenen)
Bir testi geçici olarak çalıştırmak istemezseniz @unittest.skip('sebep'), koşula bağlıysa @unittest.skipIf(...) dekoratörlerini kullanabilirsiniz. Sebebini her zaman yazın; üç ay sonra geri döndüğünüzde teşekkür edersiniz.
Sık karşılaşılan tuzaklar
- Testler arasında durum sızdırmak: Sınıf seviyesinde mutable bir liste tanımlayıp
setUpiçinde sıfırlamayı unutmak klasik bir hatadır. Bir test diğerini bozar, sıralama değişince patlar. Her testin kendi datasını kurması şart. patch'i yanlış yerde uygulamak:from x import yyaptıysanız,@patch('x.y')yerine@patch('mymodule.y')yazmalısınız çünkü import zaten gerçekleşti. Bu kuralı atlamak mock'unuzu sessizce devre dışı bırakır.- Test ayrıştırmayı (discovery) yanlış yapılandırmak: Dosya adınız
test_ile başlamıyorsa veyatests/klasöründe__init__.pyyoksapython -m unittest discovertestlerinizi göremez ve bunu hata olarak da raporlamaz. - Float karşılaştırmada
assertEqualkullanmak:assertAlmostEqualveyaplacesparametresi tam burada kurtarıcı.
Doğrulama ve klasör düzeni
Tipik bir proje yapısı şöyle görünür:
proje/
src/
hesap.py
tests/
__init__.py
test_hesap.py
Tüm testleri keşfetmek için kök dizinden:
python -m unittest discover -s tests -p 'test_*.py' -v
Çıktıda her testin sonunda OK görmelisiniz. Tek bir teste odaklanmak isterseniz:
python -m unittest tests.test_hesap.TestHesap.test_pozitif_toplama
Kapanış
Bu yazıda unittest ile bir TestCase yazmaktan başlayıp fixture, mock ve subtest gibi günlük hayatta sürekli karşımıza çıkan yapılara baktık. Bence test yazmaya başlamanın en kolay yolu, bir sonraki bug fix'te önce bug'ı yakalayan bir test yazıp sonra düzeltmek; bu hem regresyondan koruyor hem de modülü tanımanızı hızlandırıyor. Umarım faydalı olur, bir sonraki yazıda görüşmek üzere.
