Flask projelerinde pytest ile bekçi testleri: kuralları koda kilitlemek

Uygulama fabrikası, geçici veritabanı ve site haritasını gezen testlerle marka, SEO ve gizlilik kurallarını kalıcı hâle getirmek.

Yazar: TS SystemLabs mühendislik ekibi · Yayın: · 7 dk okuma

Küçük bir web sitesinde bile zamanla yazılı olmayan pek çok kural birikir: belli bir ad sitede hiç geçmeyecek, her sayfanın başlığı farklı olacak, çerez onayı varsayılanı bütün Google etiketlerinden önce çalışacak, iletişim formundaki gizli alan dolu gelirse kayıt atılmayacak. Bu kurallar genellikle bir toplantıda ya da bir mesajlaşmada kararlaştırılır ve birkaç hafta sonra, kimse kötü niyetli olmadan, bir şablon düzenlemesiyle sessizce bozulur.

Bu yazıda bu tür kararları koda kilitlemek için kullandığımız yöntemi anlatıyoruz: bekçi testleri. Bekçi testi, bir özelliğin çalıştığını kanıtlamaktan çok, alınmış bir kararın bozulmadığını sürekli kontrol eden kısa bir testtir. Örneklerin tamamı bu sitenin kendi test klasöründen sadeleştirilerek alındı; site Flask ile yazılmış, testler pytest ile çalışıyor ve bugün depoda yaklaşık 200 test bulunuyor.

Temel: uygulama fabrikası ve geçici veritabanı

Bekçi testlerinin hızlı ve güvenilir olması için her testin temiz bir uygulamayla başlaması gerekir. Bunun en sade yolu Flask'ın uygulama fabrikası desenidir: uygulamayı modül düzeyinde bir kez kurmak yerine, ayar sözlüğü alan bir create_app fonksiyonu yazarsınız. Test, bu fonksiyona kendi ayarlarını verir; en önemlisi de veritabanı yolunu pytest'in her test için açtığı geçici klasöre (tmp_path) yönlendirmektir.

import pytest
from app import create_app

@pytest.fixture
def client(tmp_path):
    app = create_app({"TESTING": True, "DATABASE": str(tmp_path / "t.db")})
    return app.test_client()

def test_ana_sayfa(client):
    r = client.get("/")
    assert r.status_code == 200

Bu düzenin üç faydası var. Birincisi, testler gerçek form kayıtlarına asla dokunmaz; SQLite dosyası her test için sıfırdan oluşur ve test bitince silinir. İkincisi, testler birbirinin bıraktığı veriden etkilenmez; sıralarını değiştirmek sonucu değiştirmez. Üçüncüsü, TESTING bayrağı uygulamaya arka plan işlerini (bizde günlük yedekleme zamanlayıcısı) başlatmamasını söyler; testler hızlı biter ve geride çalışan bir iş parçacığı kalmaz.

Kararı teste çevirmek

Bekçi testinin özü şu alışkanlıktır: bir kural kararlaştırıldığı gün, o kuralı ihlal eden durumu yakalayan bir test de yazılır. Örneğin bir müşterinin ya da eski bir markanın adının sitede hiç geçmemesine karar verildiyse, bütün önemli sayfaları gezip o adı arayan bir test eklenir. Testin docstring'ine kararın tarihi ve kısa gerekçesi yazılır; böylece bir yıl sonra testi okuyan kişi neden orada olduğunu anlar.

YASAK_ADLAR = ["eski marka", "musteri adi"]

def test_yasak_adlar_gecmez(client):
    """Karar (tarih): bu adlar ziyaretçiye görünen hiçbir sayfada geçmez."""
    yollar = ["/", "/iletisim", "/gizlilik", "/hakkinda"]
    for yol in yollar:
        html = client.get(yol).get_data(as_text=True).lower()
        for ad in YASAK_ADLAR:
            assert ad not in html, (yol, ad)

Buradaki küçük ama önemli ayrıntı, assert satırına bir açıklama demeti eklemektir. Test kırıldığında pytest yalnız "False" demez; hangi adreste hangi adın yakalandığını da yazdırır. Bekçi testleri genellikle haftalar sonra, konuyla ilgisi olmayan bir değişiklikte kırılır; hata mesajının kendi kendini açıklaması bu yüzden değerlidir.

Aynı mantıkla bu sitede başka kararlar da korunuyor: açık adres sayfada yazılmaz, kurucu adları yalnız künye bölümünde geçer, depo klasörünün iç adı ziyaretçiye görünmez. Her biri birkaç satırlık bir test ve her biri bir zamanlar gerçekten verilmiş bir karar.

Site haritasını gezen testler

Sayfa listesini testin içine elle yazmak bir süre işe yarar; ama yeni bir sayfa eklendiğinde listeye eklenmesi unutulur ve bekçi o sayfayı hiç görmez. Daha sağlam yol, testin gezilecek adresleri sitenin kendi site haritasından okumasıdır. Böylece site haritasına giren her yeni sayfa otomatik olarak denetim kapsamına girer.

Bunun en iyi örneği başlık tekilliğidir. Arama motorları aynı <title> değerini taşıyan sayfaları kopya içerik şüphesiyle değerlendirebilir. Çok dilli bir sitede bu hata kolayca olur: bir çeviri eksik kalır ve sayfa varsayılan dilin başlığına düşer.

import re

def test_title_benzersiz(client):
    sm = client.get("/sitemap.xml").get_data(as_text=True)
    yollar = sorted({re.sub(r"https?://[^/]+", "", u)
                     for u in re.findall(r"<loc>([^<]+)</loc>", sm)})
    basliklar = {}
    for yol in yollar:
        html = client.get(yol).get_data(as_text=True)
        baslik = re.search(r"<title>(.*?)</title>", html, re.S).group(1)
        assert baslik not in basliklar, (yol, basliklar.get(baslik))
        basliklar[baslik] = yol

Site haritasındaki adresler canlı alan adını taşır; test istemcisi ise yalnız yol ile çalışır. Bu yüzden düzenli ifade ile alan adı kısmı atılıyor. Aynı döngü, her sayfanın 200 döndüğünü, kanonik bağlantının doğru olduğunu ya da yasak bir kelimenin geçmediğini kontrol etmek için de genişletilebilir. Çok dilli yapının kendisini hreflang ve site haritası yazımızda anlattık.

Çeviri anahtarlarının tamlığı

Arayüz metinlerini dil başına bir sözlükte tutuyorsanız, en sık hata yeni bir metnin yalnız Türkçe sözlüğe eklenmesidir. Uygulama eksik anahtarı varsayılan dile düşürerek çalışmaya devam eder; yani hata ekranda Almanca bir sayfanın ortasında Türkçe bir cümle olarak görünür ve kimse fark etmeyebilir. Bunu yakalayan test çok kısadır:

import ceviri, ceviri_en, ceviri_de, ceviri_es

def test_ceviri_anahtarlari_tam():
    for modul in (ceviri_en, ceviri_de, ceviri_es):
        eksik = set(ceviri.UI_TR) - set(modul.UI)
        assert not eksik, (modul.__name__, eksik)

Küme farkı, eksik anahtarların tam listesini verir; hata mesajı doğrudan yapılacak işi söyler. Bu test, çevirinin kalitesini denetlemez; yalnız varlığını denetler. Kaliteyi hâlâ dili bilen birinin okuması gerekir. Ama boşluğu kapatmak, en azından eksik metnin hiç fark edilmeden yayına çıkmasını önler.

Üretim ayarını taklit etmek: monkeypatch

Bazı davranışlar yalnız üretim ortamında açılır. Bizde Google etiketleri ve reklam yükleyicisi yalnız FLASK_ENV=production iken sayfaya basılır; geliştirme sırasında yerel ziyaretler ölçüme karışmasın diye. Bu iyi bir varsayılan, ama testlerin bu davranışı da sınaması gerekir. pytest'in monkeypatch aracı, ortam değişkenini yalnız o test süresince ayarlar ve test bitince eski hâline döndürür.

def istemci(monkeypatch, tmp_path, **ortam):
    for ad in ("FLASK_ENV", "GA4_ID", "GTM_ID", "ADSENSE_CLIENT"):
        monkeypatch.delenv(ad, raising=False)
    for ad, deger in ortam.items():
        monkeypatch.setenv(ad, deger)
    app = create_app({"TESTING": True, "DATABASE": str(tmp_path / "t.db")})
    return app.test_client()

def test_gelistirmede_etiket_yok(monkeypatch, tmp_path):
    html = istemci(monkeypatch, tmp_path, GA4_ID="G-TEST123").get("/").get_data(as_text=True)
    assert "google-etiket.js" not in html

Önce ilgili değişkenleri silmek önemlidir: geliştiricinin kendi makinesinde ayarlı kalmış bir değişken, testin sonucunu sessizce değiştirebilir. Uygulamanın ortam değişkenlerini create_app içinde, çağrı anında okuması da bu desenin ön koşuludur; modül yüklenirken okunan bir değer, test içinde değiştirildiğinde etkisiz kalır.

Sıralamayı test etmek: head içindeki betikler

Bazı kurallar varlıkla değil sırayla ilgilidir. Consent Mode v2'de izin varsayılanı, herhangi bir Google etiketinden önce çalışmalıdır. Bunu ham HTML'deki konumları karşılaştırarak test ediyoruz: varsayılanın metni, etiket dosyasından önce; etiket dosyası, reklam yükleyicisinden önce; hepsi de <title> öğesinden önce gelmeli.

def test_onay_varsayilani_once(monkeypatch, tmp_path):
    c = istemci(monkeypatch, tmp_path, FLASK_ENV="production", GA4_ID="G-TEST123")
    for yol in ("/", "/en/", "/iletisim"):
        html = c.get(yol).get_data(as_text=True)
        i = html.find("gtag('consent','default'")
        assert 0 < i < html.find("google-etiket.js") < html.find("<title>"), yol

Python'un zincirleme karşılaştırması bu tür testleri okunur kılar. find bulamadığında -1 döndürdüğü için zincirin başındaki 0 < i koşulu, aranan metnin gerçekten var olduğunu da garanti eder.

Güvenlik bekçileri: açık yönlendirme ve bal küpü

Formlar küçük güvenlik kararları taşır ve bunlar da teste çevrilmelidir. Bülten formumuz, işlem bitince kullanıcıyı geldiği sayfaya döndürmek için bir geri alanı alır. Bu alan doğrulanmazsa saldırgan, sitenin adını taşıyan ama başka bir siteye yönlendiren bağlantılar üretebilir. Kural basit: yalnız tek eğik çizgiyle başlayan göreli yollar kabul edilir. Test de bu kuralı dışarıdan bir adres göndererek sınar.

def test_acik_yonlendirme_yok(client):
    r = client.post("/bulten", data={"eposta": "x@y.co", "geri": "https://kotu.example/"})
    assert r.status_code == 302
    assert r.headers["Location"].endswith("/guncellemeler")

def test_bal_kupu(client):
    r = client.post("/iletisim", data={"ad": "Bot", "eposta": "b@x.com",
                                       "mesaj": "otomatik mesaj", "web": "dolu"})
    assert r.status_code == 302

Bal küpü, insanların görmediği ama botların doldurduğu gizli bir alandır. Dolu gelirse form sessizce başarılıymış gibi davranır, kayıt atılmaz. Test burada yalnız yanıt kodunu kontrol ediyor; daha sıkı bir sürüm veritabanında kayıt olmadığını da doğrulayabilir. //kotu.example gibi iki eğik çizgiyle başlayan adresler ve tarayıcıların aynı biçimde yorumladığı /\kotu.example gibi ters eğik çizgili biçimler de başka bir alana gider; doğrulama bunları da reddetmeli, test de bu durumu ayrıca denemeli.

Pratik öneriler

  • Testi kararla aynı gün yazın. Sonraya bırakılan bekçi çoğu zaman hiç yazılmaz.
  • Docstring'e tarih ve gerekçe koyun. Testin silinip silinmeyeceğine karar verecek kişi buna ihtiyaç duyar.
  • Listeyi elle tutmayın. Adresleri site haritasından, anahtarları sözlükten, ürünleri tek kaynaktan okuyun.
  • Hata mesajını zenginleştirin. assert satırına yol, anahtar ya da değer ekleyin.
  • Hızlı tutun. Bütün takım birkaç on saniyede bitmeli; yavaş testler çalıştırılmamaya başlar.

Test takımını her commit'ten önce çalıştırmak alışkanlık hâline geldiğinde, bekçiler sizin yerinize hatırlar. Bu sitede yaptığımız her yayının güncellemeler sayfasında bir kaydı var; o kayıtların arkasında bu tür testlerin geçmiş olması şartı duruyor.

Sonuç

Bekçi testleri karmaşık değildir; çoğu beş on satırdır. Değerleri, kuralları insanların hafızasından alıp koda taşımalarından gelir. Uygulama fabrikası ve geçici veritabanı ile temiz bir başlangıç, site haritasından okunan adres listeleri, monkeypatch ile taklit edilen üretim ortamı ve açıklayıcı hata mesajları bir araya geldiğinde, küçük bir ekip bile büyüyen bir sitede verdiği kararları korumaya devam edebilir. Kendi projenizde benzer bir düzen kurmak isterseniz bize yazabilirsiniz.

Benzer bir sistem mi kuruyorsunuz?

İhtiyacınızı birkaç cümleyle yazın; uygun bir yol varsa birlikte netleştirelim.

Bize yazın