Kentsel Dönüşüm · Bölüm 3 / 3

Kentsel Dönüşüm #3: İskele Kurmak – Davranışı Dondur, Sonra Dokun

Eski kodu değiştirmeden önce bugün ne yaptığını, hatalarıyla birlikte teste çevirmek. Altınkaynak kütüphanesine kaynağa dokunmadan 18 karakterizasyon testi, kayıt altına alınan JSON yanıtları ve iskelenin taşıyıp taşımadığını ölçen bir yük testi.

11 dk okuma Yazılım Mimarisi

Bu yazının İngilizcesi de var →
Karakterizasyon testi metaforu: iskele ve turuncu güvenlik ağıyla sarılmış eski apartmanın önünde, ağın ortasında yakalanmış tek bir tuğla.

Kentsel Dönüşüm #2'yi bir kararla kapatmıştık: Altınkaynak kütüphanesi yıkılmayacak, güçlendirilecek. SOAP tesisatı sökülecek, yerine JSON okuyan bir katman gelecek, genel metotlar yerinde kalacak. Ama tesisat sökülmeden önce bir iş daha var. Binanın bugün nasıl durduğunu, eğriliğiyle birlikte kayda geçirmek. Bu bölümün konusu karakterizasyon testi: kodun ne yapması gerektiğini değil, bugün ne yaptığını yazan test.

Kentsel dönüşümde ilk kurulan şey iskeledir. İskele binayı düzeltmez, düz de tutmaz. Yalnızca ustalar çalışırken bir şey düşerse yakalar. Karakterizasyon testi de aynısı. Kodu doğru yapmıyor; sadece bir şey kaydığında haber veriyor.

Bu yazıda altinkaynak'ın kaynak koduna tek satır dokunmadan 18 test bağladım, sonra koda bilerek yedi bozulma bindirip iskelenin taşıyıp taşımadığını ölçtüm. İlk kurduğum iskele yedi bozulmanın ikisini kaçırdı.

Olması gerekeni değil, olanı yaz#

Terimin sahibi Michael Feathers. Working Effectively with Legacy Code kitabında kullandığı ifadeyi 2016'da Characterization Testing başlıklı kısa bir yazıda yeniden anlatıyor. Özeti şu: bu testler sistemin gerçekte ne yaptığını belgelemek içindir, keşke yapsaydı dediğiniz davranışı denetlemek için değil. Yöntem de basit. Bilerek yanlış bir beklentiyle test yazarsınız, çalıştırırsınız, hata mesajında kodun gerçek çıktısını görürsünüz ve o çıktıyı beklenen değer yaparsınız. Feathers'ın bir önerisi daha var: bu sırada bir hata bulursanız testi çoğu zaman yerinde bırakın.

Vibe Coder 5.2'de TDD'yi konuşmuştuk. İkisi aynı aracı, PHPUnit'i kullanıyor ama ters yönde çalışıyor. TDD'de test koddan önce gelir ve istenen davranışı söyler. Karakterizasyon testinde kod zaten vardır; test onun arkasından gelir ve olanı kayda geçirir. Birinde kırmızı test "henüz yazmadın" demek, ötekinde "bir şey değişti" demek.

Gerçek: Karakterizasyon testi yazarken en zor iş, gözünüzün önündeki hatayı düzeltmemek. Ajan için de öyle, bize de öyle.

Dikiş: binanın şebekeye bağlandığı yer#

Önce binanın dışarıyla nerede konuştuğunu bulmak lazım. Altınkaynak sınıfı SOAP istemcisini ilk çağrıda kuruyor ve özel $Service özelliğinde saklıyor. Özellik doluysa yenisini kurmuyor. Feathers'ın tanımıyla bu bir dikiş (seam): programın davranışını o noktada kodu düzenlemeden değiştirebildiğiniz yer. Martin Fowler'ın Legacy Seam yazısı tanımı aynen alıntılıyor ve dikişin test için bağımlılık koparmaya yaradığını anlatıyor.

O özelliğe gerçek istemci yerine ağa hiç çıkmayan sahte bir servis koyuyoruz:

PHP
<?php
​
declare(strict_types=1);
​
namespace Altinkaynak\Tests;
​
use Altinkaynak\Altinkaynak;
use ReflectionProperty;
​
final class SahteServis
{
    /** @var list<array{metot: string, argumanlar: array<mixed>}> */
    public array $cagrilar = [];
​
    /** @param array<string, string|\Throwable> $yanitlar metot adı => XML metni ya da fırlatılacak hata */
    public function __construct(private array $yanitlar)
    {
    }
​
    /** @param array<mixed> $argumanlar */
    public function __soapCall(string $metot, array $argumanlar): object
    {
        $this->cagrilar[] = ['metot' => $metot, 'argumanlar' => $argumanlar];
        $yanit = $this->yanitlar[$metot] ?? throw new \LogicException("Beklenmeyen çağrı: {$metot}");
​
        if ($yanit instanceof \Throwable) {
            throw $yanit;
        }
​
        return (object) [$metot . 'Result' => $yanit];
    }
​
    /** Kaynağa dokunmadan sahteyi özel $Service özelliğine koyar. */
    public static function tak(Altinkaynak $istemci, self $sahte): void
    {
        (new ReflectionProperty(Altinkaynak::class, 'Service'))->setValue($istemci, $sahte);
    }
}

Bu dikişin bir bedeli var ve baştan yazayım: özel bir özelliğin adına bağlı. Özelliğin adı değişirse iskele çöker. İskelenin ilk sökülecek parçası bu olacak, çünkü güçlendirmede SOAP katmanı zaten gidiyor.

İkinci ayrıntıyı bir test yakaladı. setUsername(), setPassword() ve setServiceURL() üçü de $Service'i null yapıyor. Sahteyi taktıktan sonra testin içinde bir ayar değiştirirseniz, bir sonraki çağrı gerçek SOAP istemcisini kurup ağa çıkmaya çalışıyor. İskeleyi kendi elinizle sökmüş oluyorsunuz.

Onaylı kopya: 2020 yazısının çıktısı#

Testin beklediği değer nereden gelecek? Altinkaynak'ın bir avantajı var: 2020 yazısında GetMain() için örnek bir döngü ve onun ekran çıktısı duruyor. O çıktı elimizdeki en eski, en gerçek davranış kaydı. Onu harfi harfine bir dosyaya kopyaladım ve testin "onaylı" kopyası yaptım. Bu yönteme golden master ya da approval testing de deniyor.

Burada dürüst olmam gereken bir nokta var. SOAP servisi artık açılmıyor, yani 2020'nin gerçek SOAP yanıtını kaydedemedim. Test girdisi olan XML dosyası bir yeniden kurulum: alan adları kodun okuduğu adlardan, değerler 2020 çıktısından. Kök elemanın gerçek adını bilmiyorum; SimpleXML onu okumadığı için sonucu etkilemiyor. Dosyanın başına bunu yazan bir yorum koydum.

Test, 2020 yazısındaki döngünün birebir aynısını çalıştırıp çıktıyı onaylı dosyayla karşılaştırıyor. Üç metot aynı ayrıştırma kodunun üç kopyası olduğu için üçü de ayrı ayrı sınanıyor (bunun neden şart olduğu aşağıda):

PHP
/** @dataProvider metotlar */
public function test2020YazisindakiCiktiyiAynenUretir(string $metot): void
{
    $istemci = $this->istemci([$metot => (string) file_get_contents(self::FIXTURE . 'getmain-2020.xml')]);
​
    $this->assertSame(
        (string) file_get_contents(self::FIXTURE . 'getmain-2020.approved.txt'),
        $this->yazdir($istemci->{$metot}())
    );
}

Yeniden kurulum sırasında 2020 çıktısının bir satırı anlam kazandı. Ons için çıktı şöyleydi: açıklama boş, satış 0, güncelleme zamanı boş. İlk okumada servisin onsa sıfır satış fiyatı verdiğini düşünürsünüz. Değil. XML'de Satis elemanı hiç yoksa SimpleXML boş metin dönüyor, floatval('') de 0. Ons satırını yalnızca Kod ve Alis ile kurunca sonuç 2020 çıktısıyla harfi harfine aynı oldu. Kütüphane "fiyat yok" ile "fiyat sıfır" arasındaki farkı bilmiyor. Bu da iskeleye bir hata testi olarak girdi.

Hatalar da iskeleye bağlanır#

#2'de "o yanıtları sabitleyeceğiz" demiştik. İtiraf: 29 Ekim'de iki JSON ucuna tarayıcıdan bakmış ama yanıtları dosyaya yazmamıştım. Bugün, 12 Kasım 2026 sabahı (10:33 ve 10:34 güncellemeleri) yeniden çektim ve bu kez olduğu gibi kaydettim. Fark hemen çıktı: altın ucu 29 Ekim'de 53 kalem dönüyordu, bugün 54. Kayıt tutmanın neden şart olduğunu servis kendisi gösterdi.

Bu dosyalar bugünkü kütüphanenin girdisi değil; kütüphane hâlâ SOAP konuşuyor. Ama güçlendirmede en olası kaza şu: taşıma katmanı değişir, ayrıştırma olduğu gibi kalır. Testler tam bunu sabitliyor. Bugünkü fiyat metinlerini eski XML kalıbına döküp eski ayrıştırıcıdan geçiriyor ve çıkan yanlış değeri beklenen değer olarak yazıyor:

PHP
/** @return array<string, array{string, string, string, float}> */
public static function bugunkuFiyatlar(): array
{
    return [
        'dolar: virgülden sonrası düşer' => ['currency-20261112.json', 'USD', '50,181', 50.0],
        'yen: sıfır olur'                => ['currency-20261112.json', 'JPY', '0,3096', 0.0],
        'has altın: bin kat ucuzlar'     => ['gold-20261112.json', 'HH_T', '6.382', 6.382],
        '500 gr: ikinci noktada kesilir' => ['gold-20261112.json', 'GA500', '3.174.866', 3.174],
        'gümüş: hem binlik hem ondalık'  => ['gold-20261112.json', 'AG250', '19.517,61', 19.517],
    ];
}

Feathers'ın tarifini burada gerçekten uyguladım. Has altın testine önce "olması gereken" değeri, 6382'yi yazdım. PHPUnit'in cevabı:

Metin
1) Altinkaynak\Tests\BeklentiTest::testHasAltin
Failed asserting that 6.382 is identical to 6382.0.

Kırmızı test bu aşamada kodun yanlış olduğunu söylemiyor; benim kodun ne yaptığını yanlış bildiğimi söylüyor. Beklenen değer 6.382 oldu ve test yeşile döndü. Hatayı düzeltmedik, adını koyduk: bu tür testlerin adı testBilinenHata ile başlıyor. Güçlendirmede fiyatOku() devreye girdiği gün bu testler bilerek kırmızıya dönecek ve o gün, gerekçesiyle birlikte güncellenecek. Yeşilden kırmızıya dönen her BilinenHata testi, düzeltilen bir hatanın makbuzu.

Aynı ailede iki test daha var:

  • Hata: new Altinkaynak('kullanici', '0') şifreyi atamıyor. Yapıcı empty() ile bakıyor ve PHP'de empty('0') true. Şifresi sıfır olan bir hesap sessizce varsayılan kimlikle bağlanır.
  • Hata: Servis hata verdiğinde SoapFault olduğu gibi dışarı çıkıyor. Depoda AltinkaynakException diye bir sınıf var ama hiçbir yerde fırlatılmıyor. #1'deki ölü kod bulgusu artık bir testle kayıtlı.

İskeleye yük bindirmek#

Testlerin yeşil olması iskelenin taşıdığını göstermez. Hiçbir şey ölçmeyen bir test de yeşildir. Taşıdığını görmenin yolu kodu bilerek bozup testlerin bunu yakalayıp yakalamadığına bakmak. Bunun adı mutasyon testi. Ben burada kasıtlı olarak küçük tuttum: yedi el yapımı bozulma, her birinde testler çalışıyor, sonra dosya geri alınıyor.

Bash
#!/usr/bin/env bash
# iskele-yuk-testi.sh: src/ altına bilinçli bozulmalar uygula, her birinde
# testlerin kırmızıya döndüğünü doğrula. Yeşil kalan bozulma, iskelenin
# o noktada taşımadığını gösterir. Çıkış 0: hepsi yakalandı, 1: kaçan var.
set -u
cd "$(dirname "$0")"
​
git diff --quiet HEAD -- src/ || { echo "src/ temiz değil; önce değişiklikleri kaydet." >&2; exit 2; }
phpunit >/dev/null 2>&1 || { echo "Testler zaten kırmızı; yük testi anlamsız." >&2; exit 2; }
​
DOSYA=src/Altinkaynak.php
declare -a BOZULMALAR=(
  "floatval yerine intval|s/floatval(\$Kur->Alis/intval(\$Kur->Alis/"
  "alış ile satış yer değiştirir|0,/\$Kur->Alis->__toString/s//\$Kur->Satis->__toString/"
  "GetGold, GetCurrency'yi çağırır|s/ServiceCall('GetGold')/ServiceCall('GetCurrency')/"
  "boş şifre kontrolü kalkar|s/if(!empty(\$password))/if(\$password !== null)/"
  "setter önbelleği sıfırlamaz|0,/\$this->Service = null;/s///"
  "açıklama okunmaz|s/\$Currency->desc\t\t\t= \$Kur->Aciklama->__toString();//"
  "kalemler ters sırada eklenir|s/array_push(\$GetCurrencyResponse->Currencies, \$Currency)/array_unshift(\$GetCurrencyResponse->Currencies, \$Currency)/"
)
​
kacan=0
for satir in "${BOZULMALAR[@]}"; do
  ad="${satir%%|*}"; ifade="${satir#*|}"
  sed -i "$ifade" "$DOSYA"
  if git diff --quiet HEAD -- "$DOSYA"; then
    echo "UYGULANAMADI  $ad"; kacan=$((kacan + 1)); continue
  fi
  if phpunit >/dev/null 2>&1; then
    echo "KAÇTI         $ad"; kacan=$((kacan + 1))
  else
    echo "yakalandı     $ad"
  fi
  git checkout -q -- "$DOSYA"
done
​
echo "---"; echo "kaçan: $kacan / ${#BOZULMALAR[@]}"
[ "$kacan" -eq 0 ]

İlk iskelede onaylı kopya testi yalnızca GetMain() için, ayar testi de yalnızca setServiceURL() için yazılmıştı. Sonuç:

Metin
yakalandı     floatval yerine intval
KAÇTI         alış ile satış yer değiştirir
yakalandı     GetGold, GetCurrency'yi çağırır
yakalandı     boş şifre kontrolü kalkar
KAÇTI         setter önbelleği sıfırlamaz
yakalandı     açıklama okunmaz
yakalandı     kalemler ters sırada eklenir
---
kaçan: 2 / 7

İkisi de aynı yerden kaçtı. sed ilk eşleşmeyi değiştiriyor; dosyadaki ilk ayrıştırma döngüsü GetCurrency()'nin, ilk $Service = null satırı da setUsername()'in. Testlerim ise başka kopyalara bakıyordu. #1'de aynı ayrıştırma kodunun üç metotta kopyalandığını not etmiştik. Bulgu o gün bir satırdı, bugün iki deliğe dönüştü: kopyalanmış kodun her kopyası ayrı iskele ister. Onaylı kopya testini üç metoda, ayar testini üç setter'a veri sağlayıcıyla (data provider) yayınca sonuç kaçan: 0 / 7, çıkış kodu 0. Toplam 18 test, 26 doğrulama, src/ altında sıfır satır değişiklik.

Ölçüm ortamı: PHP 8.3.6, PHPUnit 9.6.17 (Ubuntu paketi). Depoda composer.json olmadığı için PHPUnit'i sisteme kurdum; bağımlılık tanımı serinin son bölümü olan yapı denetimin işi. Bir not: PHPUnit 12 duyurusuna göre doc yorumundaki @dataProvider gibi anotasyonlar kaldırıldı, yerine #[DataProvider] özniteliği kullanılıyor. PHPUnit 12'ye geçerken bu testler de güncellenmeli.

Ajana iskele kurdurmak#

Bu testleri ajanla yazdırmak mümkün ve mantıklı; sıkıcı, tekrarlı, kalıplı iş. Ama görev metni olmadan ajanın önünde iki kolay yol var ve ikisi de yanlış. Birincisi "olması gereken" değeri yazmak: test kırmızı olur. İkincisi o kırmızıyı görüp kodu "düzeltmek": test yeşile döner, iskele kurulmadan bina değişmiş olur. Bir sonraki bölümün konusu tam bu, sormadan çıkılan kat.

Görev metni bu iki yolu kapatacak şekilde yazılmalı:

Markdown
Görev: Bu depo için karakterizasyon testleri yaz. Amaç kodun bugün ne
yaptığını kayda geçirmek, doğru olup olmadığına karar vermek değil.
​
Kurallar:
- src/ altındaki hiçbir dosyayı değiştirme. Yalnızca tests/ ve phpunit.xml.
- Ağa çıkma. Dış servis çağrılarını, kodu değiştirmeden araya girebildiğin bir
  noktadan sahte bir nesneyle karşıla; o noktayı ve neden seçtiğini yaz.
- Beklenen değeri tahmin etme. Testi çalıştır, gerçek çıktıyı beklenen değer yap.
- Hata gibi görünen davranışı düzeltme. Testi yaz, adını testBilinenHata... ile
  başlat, yorumda neden hata olduğunu düşündüğünü söyle.
- Kopyalanmış her kod yolu için ayrı test yaz.
​
Bitince: eklediğin testlerin listesi ve test edemediğin davranışlar.

Görev hangi ajanda koşarsa koşsun, sonunda iki kapı var ve ikisi de ajanın sözüne değil çıkış koduna bakıyor: git diff --quiet HEAD -- src/ kaynakta değişiklik varsa sıfırdan farklı döner; iskele-yuk-testi.sh testlerin bozulmaları yakalamadığı yerde 1 döner. Görev metni Claude Code, Codex ya da OpenCode gibi dosya yazabilen herhangi bir ajanda aynen çalışacak şekilde yazıldı; codex exec'in dosya yazabilmesi için --sandbox workspace-write gerektiğini Beş Ajana Aynı Sınav'da yazmıştık. Bu yazı için görevi hiçbir ajanda çalıştırmadım. Yukarıdaki testleri ben yazdım ve ben çalıştırdım. Ajanların "şuna dokunma" türünden kurallara ne kadar uyduğunu Kaçak Kat'taki sınav gösterecek.

Bir de sınav notu: #4'teki beş ajanlı sınavın başlangıç ağacı ilan edildiği gibi kalıyor. İskele ana dala girmiyor; ayrı bir dalda duruyor.

İskele neyi yapmaz?#

Doğruluğu kanıtlamaz. 18 testin 8'i bilinen bir hatayı koruyor. Karakterizasyon testi "bu kod doğru" demez, "bu kod dün de böyleydi" der.

Girdinin gerçekliği kadar gerçektir. 2020 testinin girdisi bir yeniden kurulum. Ondalık ve tarih biçimi 2020 çıktısıyla örtüşüyor, ama gerçek SOAP yanıtında bilmediğim bir alan, bir boşluk ya da bir karakter kodlaması ayrıntısı olabilir. JSON kayıtları ise gerçek, tarihli ve SHA-256 özetiyle dosyada.

Dikişi kırılgan. Yansımayla (reflection) özel özelliğe yazmak, kaynağa dokunmamanın bedeli. Güçlendirmenin ilk adımında dikiş, yapıcıdan verilen bir istemciye dönüşmeli; o gün iskelenin bu parçası da yenilenir.

Hızlı değişen davranışta işe yaramaz. Her gün değişen bir çıktıyı dondurmak, her gün testi güncellemek demek. Dondurmaya değer olan, değişmemesi gereken şeydir: sözleşme, biçim, sıra.

Ne zaman yarar? Elinizde test olmayan, davranışı kimsenin yazmadığı, ama dokunmak zorunda olduğunuz her kodda. Yani eski binaların neredeyse hepsinde.

İskele kuruldu, kat çıkmak yasak#

Bu bölümde de binaya çivi çakmadık; src/ altında değişen satır sayısı sıfır. Ama artık bir şey değiştiğinde haber veren 18 test, kaydı tutulmuş iki JSON yanıtı ve iskelenin taşıdığını gösteren bir yük testi var. Bir sonraki bölümde, Kentsel Dönüşüm #4: Kaçak Kat – Ajanın Sormadan Eklediği'nde (28 Kasım), müteahhitleri binaya alıyoruz: beş ajana aynı görev, aynı depo ve mühürlü zarftaki cevap anahtarı. Serinin bütün bölümleri Kentsel Dönüşüm sayfasında.

İskele binayı güzelleştirmez. Ama ustalardan biri bir tuğla düşürdüğünde, aşağıdan geçen kişinin başına değil iskeleye düşer. Teknolojiyle kalın.

Yorumlar

0 yorum

Yorum yazın

kişisine yanıt

Yorumlar onaylandıktan sonra görünür. E-posta adresiniz kimseyle paylaşılmaz. Kişisel verileriniz nasıl işlenir?