Otomasyon Betiklerini Test Edilebilir Yazmak: pytest, Sahte API ve Geçici Dosya Sistemi
Otomasyon betiklerini pytest ile test edilebilir yazmak: yan etkileri ayırma, saat ve yol enjeksiyonu, tmp_path, monkeypatch ve sahte HTTP istemcisi.
Otomasyon betiklerini test etmek, çoğu ekipte gündeme bile gelmez. Betik bir kez yazılır, "çalıştırıp bakarım" denir, çalışırsa cron'a bağlanır ve unutulur. Sorun betiğin altı ay sonra hâlâ çalışıyor olması değil; sorun, bir şey değiştiğinde (API'nin cevap biçimi, dosya yolu, saat dilimi) hatanın nerede olduğunu bulmanın tek yolunun betiği canlı sisteme karşı yeniden koşturmak olmasıdır.
Bunu kendi işlerimde yeterince yaşadım. Her sabah bir raporu üretip klasöre yazan, başka bir sistemden veri çekip dönüştüren betiklerin hepsi başta "küçük" idi. Test yazmadığım için her değişiklik bir kumara dönüştü ve kaybettiğim sabahlarda betik sessizce boş rapor üretti. Bu yazıda böyle bir betiği nasıl gerçekten test edilebilir hale getirdiğimi anlatacağım: yan etkileri kenara toplamak, saati, dosya yolunu ve HTTP çağrısını dışarıdan verilebilir kılmak ve saf dönüşüm mantığını ayırmak.
Amaç yüzde yüz kapsama değil. Amaç, betiğin "düşünen" kısmını canlı sisteme dokunmadan, saniyeler içinde koşan bir test paketiyle sabitlemek. Bunun için pytest, tmp_path ve monkeypatch fazlasıyla yeterli; ek kütüphaneye gerek yok.
Yan etkileri kenara toplamak
Tipik bir otomasyon betiğinde neredeyse her satır bir yan etkidir: requests.get, datetime.now(), open(), print. Böyle bir kodu test etmenin iki yolu var: ya canlı sistemi kullanırsınız ya da her şeyi tek tek mock'larsınız. İkisi de kırılgandır; ilki yavaş ve tekrarlanamaz, ikincisi kodun iç yapısına o kadar bağlıdır ki ufak bir yeniden düzenlemede testler kırılır.
Benim uyguladığım düzen basit: ortada dünyaya dokunmayan saf bir çekirdek, kenarlarda ince bir yan etki kabuğu. Bu yaklaşıma literatürde "functional core, imperative shell" deniyor; adı önemli değil, ayrım önemli.
- Saf fonksiyonlar: girdi alır, çıktı döner. Dosya, saat, ağ, ortam değişkeni bilmez. Aynı girdiye her zaman aynı çıktıyı verir.
- Kabuk: veriyi okur, çekirdeğe verir, sonucu yazar. Mümkün olduğunca az karar içerir.
- Giriş noktası: kabuğu gerçek bağımlılıklarla (gerçek saat, gerçek HTTP istemcisi, gerçek klasör) çağırır.
Bu ayrımı yaptığınızda betiğin mantığının büyük kısmı sıradan birim testine dönüşür. Çekirdeğin girişinde veriyi doğrulamak da kolaylaşır; veri sözleşmesi ve şema doğrulama yazısında anlattığım Pydantic modelleri tam bu sınırda durur.
Saat, dosya yolu ve HTTP çağrısını enjekte edilebilir kılmak
Kabuğa itemediğiniz üç yan etki kalır: saat, dosya sistemi ve ağ. Bunlar fonksiyonun içine gömülüyse test onlara mahkûmdur. Çözüm framework değil, parametredir.
- Saat:
simdi=Noneparametresi; verilmezsedatetime.now(), testte sabit bir tarih. Dosya adları, tarih aralıkları ve "bugün" kararları böylece deterministik olur. - Dosya yolu: hedef klasörü
Patholarak alın; sabit yol yazmayın. Varsayılan yolu ortam değişkeninden okuyan ayrı bir küçük fonksiyon yeterli. - Ağ: HTTP çağrısını yapan nesneyi dışarıdan alın. Gerçek kodda
requestsetrafında ince bir sarmalayıcı, testte aynı yüzeyi taşıyan sahte bir sınıf.
Sahte istemcinin gerçek istemciyle aynı yüzeyi taşıdığını belgelemek için typing.Protocol kullanıyorum. Kalıtım gerektirmez; yalnızca "şu imzada bir get metodu olsun" der. Tip denetleyici çalıştırıyorsanız sahte ile gerçek arasındaki sapmayı test koşmadan yakalarsınız.
Mevcut bir betiği bu yapıya taşırken hepsini bir seferde yeniden yazmıyorum. Önce en çok değişen ve en çok korkulan parçayı, genellikle dönüşüm mantığını, saf bir fonksiyona çekiyorum ve ona bir test yazıyorum. Sonra saat, sonra dosya yolu, en son ağ. Her adımda betik çalışır durumda kalıyor; yarım kalmış bir yeniden düzenleme cron'da patlamıyor.
Örnek 1: Dosya yazan bir işi tmp_path ve monkeypatch ile test etmek
Aşağıdaki modül günlük özet raporu üretiyor. Saf hesap ayrı fonksiyonda, saat parametre, klasör parametre. Yazma işlemi geçici dosyaya yapılıp os.replace ile taşınıyor; böylece betik yarıda kesilirse yarım bir rapor kalmaz.
# rapor.py
import json
import os
from datetime import datetime
from pathlib import Path
def satirlari_ozetle(satirlar):
"""Saf fonksiyon: liste alir, sozluk dondurur. Dosya, saat, ag yok."""
toplam = sum(s["tutar"] for s in satirlar)
return {"adet": len(satirlar), "toplam": toplam}
def varsayilan_klasor() -> Path:
return Path(os.environ.get("RAPOR_KLASORU", "cikti"))
def raporu_yaz(satirlar, hedef: Path, simdi: datetime | None = None) -> Path:
simdi = simdi or datetime.now()
ozet = satirlari_ozetle(satirlar)
ozet["uretim_zamani"] = simdi.isoformat(timespec="seconds")
hedef.mkdir(parents=True, exist_ok=True)
dosya = hedef / f"rapor-{simdi:%Y%m%d}.json"
gecici = dosya.with_suffix(".tmp")
gecici.write_text(json.dumps(ozet, ensure_ascii=False), encoding="utf-8")
os.replace(gecici, dosya) # yarim dosya kalmasin
return dosya
# test_rapor.py
import json
from datetime import datetime
import rapor
def test_ozet_saf_fonksiyon():
assert rapor.satirlari_ozetle([{"tutar": 10}, {"tutar": 5}]) == {
"adet": 2, "toplam": 15,
}
def test_rapor_dosyaya_yazilir(tmp_path):
sabit = datetime(2026, 9, 10, 9, 2)
dosya = rapor.raporu_yaz([{"tutar": 10}], tmp_path / "cikti", simdi=sabit)
assert dosya == tmp_path / "cikti" / "rapor-20260910.json"
icerik = json.loads(dosya.read_text(encoding="utf-8"))
assert icerik["toplam"] == 10
assert icerik["uretim_zamani"] == "2026-09-10T09:02:00"
assert not list((tmp_path / "cikti").glob("*.tmp"))
def test_klasor_ortam_degiskeninden_gelir(tmp_path, monkeypatch):
monkeypatch.setenv("RAPOR_KLASORU", str(tmp_path / "ozel"))
assert rapor.varsayilan_klasor() == tmp_path / "ozel"
monkeypatch.delenv("RAPOR_KLASORU")
monkeypatch.chdir(tmp_path)
assert rapor.varsayilan_klasor().resolve() == (tmp_path / "cikti").resolve()
Burada iki pytest aracı iş görüyor. tmp_path her test için boş ve benzersiz bir klasör verir; test bitince pytest onu kendisi temizler, gerçek çıktı klasörünüze hiç dokunulmaz. monkeypatch ise ortam değişkenini ve çalışma dizinini yalnızca o testin süresi boyunca değiştirir ve test sonunda geri alır. Bu iki fixture sayesinde test paketi paralel de koşsa birbirine karışmaz.
Üçüncü testin bir ayrıntısına dikkat çekmek isterim: ortam değişkenini sildikten sonra çalışma dizinini de tmp_path içine taşıyorum. Aksi halde göreli varsayılan yol, testi hangi klasörden başlattığınıza göre farklı bir yere çözümlenir ve test bir makinede geçip başka makinede düşer. Bu tür "benim makinemde çalışıyor" hataları otomasyon betiklerinde çok yaygındır; test yazarken çalışma dizinini de bir bağımlılık olarak görmek gerekir.
Dikkat edin: saati taklit etmek için hiçbir şeyi yamalamadım. datetime.now fonksiyonunu monkeypatch ile ezmek mümkündür ama kırılgandır; parametre olarak almak hem daha okunur hem de kodun kendisini dürüstleştirir.
Örnek 2: HTTP çağrısını sahte istemciyle değiştirmek
İkinci örnek bir API'den kayıt çekiyor ve geçici hatalarda yeniden deniyor. Asıl mesele bekleme fonksiyonunun da parametre olması: time.sleep gerçek kodda kalır, testte listeye ekleme yapan bir fonksiyon geçilir. Böylece üstel bekleme mantığı milisaniyede doğrulanır.
# musteri_cek.py
import time
from typing import Callable, Protocol
GECICI = {429, 500, 502, 503, 504}
class HttpIstemci(Protocol):
def get(self, url: str) -> tuple[int, dict]: ...
class GeciciHata(Exception):
pass
def musterileri_cek(
istemci: HttpIstemci,
url: str,
deneme: int = 3,
bekle: Callable[[float], None] = time.sleep,
) -> list:
son_hata: Exception | None = None
for i in range(deneme):
durum, govde = istemci.get(url)
if durum == 200:
return govde["items"]
if durum in GECICI:
son_hata = GeciciHata(f"HTTP {durum}")
bekle(2 ** i)
continue
raise RuntimeError(f"Kalici hata: HTTP {durum}")
raise son_hata
# test_musteri_cek.py
import pytest
import musteri_cek
class SahteIstemci:
def __init__(self, cevaplar):
self.cevaplar = list(cevaplar)
self.cagrilar = []
def get(self, url):
self.cagrilar.append(url)
return self.cevaplar.pop(0)
def hic_bekleme(saniye):
pass
def test_ilk_denemede_basari():
istemci = SahteIstemci([(200, {"items": [{"id": 1}]})])
sonuc = musteri_cek.musterileri_cek(istemci, "/musteriler", bekle=hic_bekleme)
assert sonuc == [{"id": 1}]
assert istemci.cagrilar == ["/musteriler"]
def test_gecici_hata_sonrasi_yeniden_dener():
beklemeler = []
istemci = SahteIstemci([(503, {}), (429, {}), (200, {"items": []})])
sonuc = musteri_cek.musterileri_cek(istemci, "/m", bekle=beklemeler.append)
assert sonuc == []
assert len(istemci.cagrilar) == 3
assert beklemeler == [1, 2]
def test_kalici_hata_yeniden_denemez():
istemci = SahteIstemci([(404, {})])
with pytest.raises(RuntimeError):
musteri_cek.musterileri_cek(istemci, "/m", bekle=hic_bekleme)
assert len(istemci.cagrilar) == 1
def test_denemeler_tukenince_son_hata_firlatilir():
istemci = SahteIstemci([(503, {})] * 3)
with pytest.raises(musteri_cek.GeciciHata):
musteri_cek.musterileri_cek(istemci, "/m", bekle=hic_bekleme)
Sahte istemci bir cevap kuyruğu tutuyor ve her çağrıyı kaydediyor. Bu sayede yalnızca "sonuç doğru mu" değil, "kaç kez denedi, ne kadar bekledi, kalıcı hatada gerçekten durdu mu" sorularının hepsi tek tek test edilebiliyor. Gerçek bir API'ye karşı bu dört senaryoyu üretmek ya imkânsızdır ya da dakikalar sürer. Yeniden deneme ve hız limiti kararlarının arkasındaki tasarımı API entegrasyon desenleri yazısında ayrıntılı ele almıştım; buradaki testler o kararların bozulmadığını sabitlemek için var.
Nerede durmalı, neyi test etmemeli
Test edilebilirlik peşinde koşarken abartmak da mümkün. Kendi sınırlarım şunlar:
- Kütüphaneyi test etmeyin.
requestskütüphanesinin HTTP konuşabildiğini,jsonmodülünün JSON yazabildiğini doğrulamak size düşmez. Sizin mantığınızı test edin. - Bir tane gerçek duman testi tutun. Sahte istemci gerçek API ile aynı davranmayabilir. Canlıya karşı koşan, salt okunur tek bir test yazın ama onu normal test paketinden ayırın; elle ya da günde bir kez koşsun.
- Kabuğu ince tutun, test etmeyin. Giriş noktasındaki on satırlık
mainfonksiyonu test edilmeyebilir; içinde karar yoksa kırılacak bir şey de yoktur. - Her hata için önce test yazın. Üretimde çıkan bir sorunu düzeltmeden önce onu yeniden üreten bir test ekleyin. Böylece test paketi zamanla gerçek arıza tarihçesine dönüşür.
Bu düzene geçtiğimde en çok fark ettiğim şey hız oldu; hem test koşma hızı hem de bir değişikliğe karar verme hızı. Bir betiği değiştirmek artık "sabah cron'da patlar mı" endişesi değil, birkaç saniyelik bir pytest koşusu demek. Testlerin bir kısmı, geçmişte üretimde yaşadığım gerçek arızaların birebir kaydı; kod değişse de o arızaların geri gelmeyeceğini biliyorum.
Elinizde "çalıştırıp bakarım" ile idare edilen, dokunmaya çekindiğiniz betikler varsa bu yapıya geçiş bir öğleden sonra sürecek bir iş olabilir. Veri ve otomasyon danışmanlığı kapsamında bu tür betikleri birlikte ayrıştırıp test altına alıyorum; mevcut betiklerinizi konuşmak isterseniz iletişim üzerinden yazabilirsiniz.