İçeriğe geç
academia.sh

Ders 12 / 13

Kabul Testi Odaklı Geliştirme

Bir kullanıcı isteğinin kabul ölçütlerinin adım biçiminde yazılması, adımları uygulamaya bağlayan bir sürücüyle otomatikleştirilmesi ve dış halkası kabul ölçütünde iç halkası birim testinde dönen iki katmanlı döngü.

İçindekiler

Önceki derste örnekler tek bir işlevin sözleşmesini anlattı. Bir kullanıcı isteği ise bundan geniştir. “Üye kitabının süresini kendi uzatabilsin” cümlesi bir işlevi değil bir akışı tarif eder: kayıt bulunur, kural uygulanır, sonuç kalıcı hâle gelir ve üyeye haber verilir. Bu akışın tamamlandığını söyleyen ölçütler de isteğin kendisiyle birlikte gelir.

Kabul testi odaklı geliştirme (acceptance test driven development) o ölçütleri geliştirmenin başlangıç noktası yapar. Ölçüt önce yazılır, otomatik bir denetime çevrilir ve kırmızı kalır; işin bittiğini söyleyen şey o denetimin yeşile dönmesidir. Birim testleri ortadan kalkmaz — kabul ölçütünün gösterdiği yere yazılırlar.

Kabul Ölçütünün Biçimi

Ölçüt üç tür adımdan oluşur. Verilen adımları başlangıç durumunu kurar, yapıldığında adımı tek bir işlemi tetikler, beklenen adımları sonucu denetler. Bu ayrım birinci dersteki hazırla, uygula, doğrula bölümlerinin akış düzeyindeki karşılığıdır.

cat > kabul.txt <<'KABUL'
# kabul olcutleri — uye kendi oduncunun suresini uzatabilsin
hikaye: Uye elindeki kitabin suresini kendi uzatabilsin

olcut: Uygun bir odunc yenilendiginde son tarih ileri alinir ve uyeye bildirim gider
  verilen: uye no=U-17 tur=ogrenci
  verilen: odunc kitapNo=K-903 uyeNo=U-17 sonGun=1028
  yapildiginda: yenileme uyeNo=U-17 kitapNo=K-903 bugun=1020
  beklenen: odunc kitapNo=K-903 sonGun=1048 yenilemeSayisi=1
  beklenen: bildirim uyeNo=U-17 ileti="yeni son tarih: 1048"

olcut: Rezerve edilmis kitabin yenilenmesi reddedilir ve son tarih degismez
  verilen: uye no=U-42 tur=uye
  verilen: odunc kitapNo=K-101 uyeNo=U-42 sonGun=1024 rezerveEdildi=evet
  yapildiginda: yenileme uyeNo=U-42 kitapNo=K-101 bugun=1020
  beklenen: red gerekce="rezerve edilmis kitap yenilenemez"
  beklenen: odunc kitapNo=K-101 sonGun=1024 yenilemeSayisi=0
KABUL

Önceki dersteki örnek satırlarından iki fark var. Adımlar sıralıdır ve durumu paylaşır: ölçüt bir senaryo anlatır, tek bir çağrı değil. İkincisi, adımlarda geçen sözcükler uygulamanın kavramlarıdır — üye, ödünç, bildirim, red — belirli bir işlevin imzası değil.

// kabul-cozumleyici.mjs — kabul olcutlerini adim listesine cevirir
const ALAN = /(\w+)=("[^"]*"|\S+)/g;
const ADIM_ANAHTARLARI = ['verilen', 'yapildiginda', 'beklenen'];

const cevir = (ham) => {
  const deger = ham.startsWith('"') ? ham.slice(1, -1) : ham;
  if (deger === 'evet') return true;
  if (deger === 'hayir') return false;
  return /^-?\d+$/.test(deger) ? Number(deger) : deger;
};

const alanlar = (metin) => Object.fromEntries(
  [...metin.matchAll(ALAN)].map(([, ad, ham]) => [ad, cevir(ham)]),
);

export function cozumle(metin) {
  const olcutler = [];
  for (const ham of metin.split('\n')) {
    const satir = ham.trim();
    if (satir === '' || satir.startsWith('#') || satir.startsWith('hikaye:')) continue;
    if (satir.startsWith('olcut:')) {
      olcutler.push({ baslik: satir.slice(6).trim(), adimlar: [] });
      continue;
    }
    const [anahtar, ...kalan] = satir.split(':');
    if (ADIM_ANAHTARLARI.includes(anahtar) === false) throw new Error(`taninmayan adim: ${satir}`);
    if (olcutler.length === 0) throw new Error(`olcutsuz adim: ${satir}`);
    const govde = kalan.join(':').trim();
    olcutler.at(-1).adimlar.push({
      ad: `${anahtar} ${govde.split(/\s+/)[0]}`,
      alanlar: alanlar(govde),
      metin: satir,
    });
  }
  return olcutler;
}

Uygulama ve Sürücü

Ölçütlerin dokunduğu birim tek bir kural işlevi değil, akışı yürüten servistir. Servis kaydı yükler, kuralı uygular ve sonucu kaydeder. Bu ilk sürümde bildirim gönderimi yoktur; ölçütün kırmızı kalacağı yer orasıdır.

// yenileme.mjs — yenileme kurallari
export const KURALLAR = { ogrenci: { oduncGun: 28 }, uye: { oduncGun: 14 } };
export const EN_COK_YENILEME = 2;

export function yenile(kayit, bugun) {
  if (kayit.rezerveEdildi) throw new Error('rezerve edilmis kitap yenilenemez');
  if (kayit.yenilemeSayisi >= EN_COK_YENILEME) throw new Error('en cok 2 kez yenilenebilir');
  if (bugun > kayit.sonGun) throw new Error('gecikmis odunc yenilenemez');
  return {
    ...kayit,
    sonGun: bugun + KURALLAR[kayit.uyeTuru].oduncGun,
    yenilemeSayisi: kayit.yenilemeSayisi + 1,
  };
}
// servis.mjs — surum 1: kaydi yukler, kurali uygular, sonucu kaydeder
import { yenile } from './yenileme.mjs';

export function servisKur({ depo, bildirim }) {
  return {
    yenile(uyeNo, kitapNo, bugun) {
      const uye = depo.uyeBul(uyeNo);
      const odunc = depo.oduncBul(kitapNo);
      if (uye === undefined || odunc === undefined || odunc.uyeNo !== uyeNo) {
        return { durum: 'red', gerekce: 'odunc bulunamadi' };
      }
      try {
        const yeni = yenile({ ...odunc, uyeTuru: uye.tur }, bugun);
        depo.oduncKaydet(yeni);
        return { durum: 'kabul', odunc: yeni };
      } catch (hata) {
        return { durum: 'red', gerekce: hata.message };
      }
    },
  };
}

Metin ile uygulama arasındaki bağ sürücüdür (driver): her adım türünü bir kod parçasına eşleyen tablo. Sürücü, altıncı derste tanıtılan taklit nesnelerle bir “dünya” kurar ve adımları o dünya üzerinde yürütür.

// kabul.test.mjs — kabul olcutlerini surucuyle kosulabilir teste cevirir
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { cozumle } from './kabul-cozumleyici.mjs';
import { servisKur } from './servis.mjs';

function dunyaKur() {
  const uyeler = new Map();
  const oduncler = new Map();
  const bildirimler = [];
  const depo = {
    uyeEkle: (uye) => uyeler.set(uye.no, uye),
    uyeBul: (no) => uyeler.get(no),
    oduncBul: (kitapNo) => oduncler.get(kitapNo),
    oduncKaydet: (odunc) => oduncler.set(odunc.kitapNo, odunc),
  };
  const bildirim = { gonder: (uyeNo, ileti) => bildirimler.push([uyeNo, ileti]) };
  return { depo, bildirimler, servis: servisKur({ depo, bildirim }), sonuc: undefined };
}

const VARSAYILAN_ODUNC = { yenilemeSayisi: 0, rezerveEdildi: false };

const SURUCU = {
  'verilen uye': (d, a) => d.depo.uyeEkle({ no: a.no, tur: a.tur }),
  'verilen odunc': (d, a) => d.depo.oduncKaydet({ ...VARSAYILAN_ODUNC, ...a }),
  'yapildiginda yenileme': (d, a) => {
    d.sonuc = d.servis.yenile(a.uyeNo, a.kitapNo, a.bugun);
  },
  'beklenen odunc': (d, a) => {
    const { kitapNo, ...beklenen } = a;
    const kayit = d.depo.oduncBul(kitapNo);
    for (const [alan, deger] of Object.entries(beklenen)) {
      assert.equal(kayit[alan], deger, `${kitapNo}.${alan} uyusmuyor`);
    }
  },
  'beklenen bildirim': (d, a) => assert.deepEqual(d.bildirimler, [[a.uyeNo, a.ileti]]),
  'beklenen red': (d, a) => {
    assert.equal(d.sonuc.durum, 'red');
    assert.equal(d.sonuc.gerekce, a.gerekce);
  },
};

const metin = readFileSync(new URL('./kabul.txt', import.meta.url), 'utf8');

for (const olcut of cozumle(metin)) {
  describe(olcut.baslik, () => {
    const dunya = dunyaKur();
    for (const adim of olcut.adimlar) {
      test(adim.metin, () => {
        const surucu = SURUCU[adim.ad];
        if (surucu === undefined) throw new Error(`surucusu olmayan adim: ${adim.ad}`);
        surucu(dunya, adim.alanlar);
      });
    }
  });
}

Her adım ayrı bir test olarak koşar ve aynı dünyayı paylaşır. Dördüncü derste kurulan bağımsızlık kuralı burada ölçüt düzeyinde uygulanır: paylaşım ölçüt içinde kalır, her ölçüt kendi dünyasını kurar.

Dış Halka: Kırmızı Ölçüt

node --test --test-reporter=tap kabul.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
    ok 1 - verilen: uye no=U-17 tur=ogrenci
    ok 2 - verilen: odunc kitapNo=K-903 uyeNo=U-17 sonGun=1028
    ok 3 - yapildiginda: yenileme uyeNo=U-17 kitapNo=K-903 bugun=1020
    ok 4 - beklenen: odunc kitapNo=K-903 sonGun=1048 yenilemeSayisi=1
    not ok 5 - beklenen: bildirim uyeNo=U-17 ileti="yeni son tarih: 1048"
not ok 1 - Uygun bir odunc yenilendiginde son tarih ileri alinir ve uyeye bildirim gider
    ok 1 - verilen: uye no=U-42 tur=uye
    ok 2 - verilen: odunc kitapNo=K-101 uyeNo=U-42 sonGun=1024 rezerveEdildi=evet
    ok 3 - yapildiginda: yenileme uyeNo=U-42 kitapNo=K-101 bugun=1020
    ok 4 - beklenen: red gerekce="rezerve edilmis kitap yenilenemez"
    ok 5 - beklenen: odunc kitapNo=K-101 sonGun=1024 yenilemeSayisi=0
ok 2 - Rezerve edilmis kitabin yenilenmesi reddedilir ve son tarih degismez
# tests 10
# pass 9
# fail 1

İkinci ölçüt karşılanıyor, birincisi karşılanmıyor. Daha değerlisi, hangi adımda karşılanmadığı görünüyor: son tarih doğru ilerledi, kayıt doğru güncellendi, eksik olan tek şey bildirim. Kabul ölçütü böylece bir kırmızı adım değil, kırmızı adımın adresi oldu.

İç Halka: Birim Testi

Kabul ölçütü doğrudan gerçekleştirime çevrilmez. İşaret ettiği yerde bir birim testi yazılır ve döngü orada döner. Bu iki katmanlı yapıya dış halka ve iç halka denir: dış halka bir kullanıcı isteği boyunca kırmızı kalır, iç halka o süre içinde birçok kez kırmızıdan yeşile geçer.

// servis.test.mjs — ic halka: kabul olcutunun isaret ettigi birim
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { servisKur } from './servis.mjs';

function kur() {
  const oduncler = new Map([['K-903', { kitapNo: 'K-903', uyeNo: 'U-17', sonGun: 1028, yenilemeSayisi: 0, rezerveEdildi: false }]]);
  const bildirimler = [];
  const depo = {
    uyeBul: () => ({ no: 'U-17', tur: 'ogrenci' }),
    oduncBul: (kitapNo) => oduncler.get(kitapNo),
    oduncKaydet: (odunc) => oduncler.set(odunc.kitapNo, odunc),
  };
  return { servis: servisKur({ depo, bildirim: { gonder: (u, i) => bildirimler.push([u, i]) } }), bildirimler };
}

test('kabul edilen yenileme uyeye yeni son tarihi bildirir', () => {
  const { servis, bildirimler } = kur();

  servis.yenile('U-17', 'K-903', 1020);

  assert.deepEqual(bildirimler, [['U-17', 'yeni son tarih: 1048']]);
});
node --test --test-reporter=tap servis.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
not ok 1 - kabul edilen yenileme uyeye yeni son tarihi bildirir
# tests 1
# pass 0
# fail 1

Yeşil adım tek satırdır.

// servis.mjs — surum 2: kabul edilen yenilemede uyeye bildirim gonderilir
import { yenile } from './yenileme.mjs';

export function servisKur({ depo, bildirim }) {
  return {
    yenile(uyeNo, kitapNo, bugun) {
      const uye = depo.uyeBul(uyeNo);
      const odunc = depo.oduncBul(kitapNo);
      if (uye === undefined || odunc === undefined || odunc.uyeNo !== uyeNo) {
        return { durum: 'red', gerekce: 'odunc bulunamadi' };
      }
      try {
        const yeni = yenile({ ...odunc, uyeTuru: uye.tur }, bugun);
        depo.oduncKaydet(yeni);
        bildirim.gonder(uyeNo, `yeni son tarih: ${yeni.sonGun}`);
        return { durum: 'kabul', odunc: yeni };
      } catch (hata) {
        return { durum: 'red', gerekce: hata.message };
      }
    },
  };
}
node --test --test-reporter=tap servis.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
node --test --test-reporter=tap kabul.test.mjs | grep -E '^(ok|not ok|# (tests|pass|fail))'
ok 1 - kabul edilen yenileme uyeye yeni son tarihi bildirir
# tests 1
# pass 1
# fail 0
ok 1 - Uygun bir odunc yenilendiginde son tarih ileri alinir ve uyeye bildirim gider
ok 2 - Rezerve edilmis kitabin yenilenmesi reddedilir ve son tarih degismez
# tests 10
# pass 10
# fail 0

İç halka yeşile döndü ve dış halka onunla birlikte kapandı. İkinci komutta yalnız girintisiz satırlar süzülüyor; ölçüt düzeyinde bakıldığında iki satır, işin bittiğini söyleyen bütün bilgidir.

İki Halkanın İş Bölümü

Dış ve iç halkanın farkı hız ve kapsamdadır ve bu fark bilerek korunur.

Dış halka yavaş ve geniştir. Bir ölçüt akışın tamamına dokunur, bu yüzden düştüğünde nerede bozulduğunu tek başına söylemez — yukarıdaki koşumda adım adım raporlama bu eksiği kısmen kapatıyor. Sayısı azdır: bir kullanıcı isteği başına birkaç ölçüt.

İç halka hızlı ve dardır. Bir birim testi tek bir kuralı sınar ve düştüğünde adresi verir. Sayısı çoktur ve ikinci derste ölçülen sinyal gücü orada aranır.

İki halkanın karıştırılması iki tipik hataya yol açar. Kabul ölçütleri birim testi gibi çoğaltıldığında koşum yavaşlar ve ölçüt listesi okunamaz hâle gelir; kullanıcıyla paylaşılabilir olma özelliği kaybolur. Ters yönde, birim testleri hiç yazılmayıp her şey kabul ölçütüne bırakıldığında düşen bir ölçüt uzun bir arama başlatır — ikinci dersteki arama alanı ölçümü burada da geçerlidir.

Kabul ölçütünün bittiğini söyleyen şey de bellidir: ölçüt yeşile döndüğünde iş kabul edilmiş sayılır. Bu, “geliştirici bitti dedi” ölçütünden farklıdır, çünkü bitmişliğin tanımı işi isteyen tarafla birlikte ve önceden yazılmıştır.

Özet

  • Kabul ölçütü, bir kullanıcı isteğinin karşılandığını söyleyen ve önceden yazılan denetimdir; verilen, yapıldığında ve beklenen adımlarından oluşur.
  • Ölçüt adımları sıralıdır ve durumu paylaşır; paylaşım ölçüt içinde kalır, her ölçüt kendi dünyasını kurar.
  • Sürücü, metindeki adım türlerini uygulama çağrılarına eşler; sürücüsü olmayan adım sessizce atlanmaz, hata verir.
  • Kırmızı bir kabul ölçütü kırmızı adımın kendisi değil adresidir; iç halkada yazılan birim testi o adrese gider.
  • Dış halka yavaş, geniş ve az sayıdadır; iç halka hızlı, dar ve çok sayıdadır. İkisinin karıştırılması ya okunamaz bir ölçüt listesi ya da uzun bir arama alanı üretir.

Sonraki Adım

Bu konuda kurulan her şey boş bir sayfadan başladı: test önce yazıldı, kod sonra geldi. Var olan bir kod tabanında bu sıra kurulamaz. Yıllardır çalışan, testi olmayan ve bağımlılıklarını kendi içinde yaratan bir işlevin önce ne yaptığı kayda geçirilmeli, sonra değiştirilebilir hâle getirilmelidir. Sonraki ders bu işi üç aşamada yapar ve kursu kapatır.

İlerlemeni kaydetmek ve not almak için Giriş yap

Notlarım

Not almak için giriş yapmalısın.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat