Ders 24 / 34
Sözleşme Testleri
Tüketici beklentilerinin dosyaya yazılması, sağlayıcıya karşı çalıştırılması, kırıcı bir değişiklikte hangi tüketicinin sınamasının düştüğünün gösterilmesi ve etki alanının değişiklikten önce hesaplanması.
İçindekiler
Makine okunur tanım, sunucunun ne ürettiğini denetliyor ama tüketicinin ne kullandığını bilmiyor. Tanım ödünç kaydının beş alanı olduğunu söyler; raf terminali bunların hepsini mi okuyor, yoksa yalnız ikisini mi? Bu bilinmeden her değişiklik en kötü duruma göre planlanır ve hiçbir alan güvenle kaldırılamaz.
Sözleşme testleri bu boşluğu kapatır. Her tüketici, sağlayıcıdan ne beklediğini yazar; sağlayıcı bu beklentileri kendi sınama takımının parçası olarak çalıştırır. Beklentiler tüketicinin gerçek kullanımından türetildiği için tanımdan farklı bir bilgi taşır: tanım neyin verildiğini, beklenti neyin kullanıldığını söyler.
Tüketici Beklentisi
Beklenti dosyası tüketicinin adını, yaptığı istekleri ve o isteklerden okuduğu alanları içerir. Ödünç servisinin iki tüketicisi vardır: raflardaki barkod terminali ve üyelerin telefon uygulaması.
{ "tuketici": "raf-terminali", "etkilesimler": [ { "ad": "kaydın kimliği ve durumu okunur", "istek": { "yontem": "GET", "yol": "/odunc/O-1" }, "beklenen": { "kod": 200, "alanlar": { "/id": "string", "/durum": "string" } } } ] }
{ "tuketici": "mobil-uygulama", "etkilesimler": [ { "ad": "ödünç kaydı ekranda gösterilir", "istek": { "yontem": "GET", "yol": "/odunc/O-1" }, "beklenen": { "kod": 200, "alanlar": { "/uye": "string", "/kalemler/0/isbn": "string", "/iadeTarihi": "string" } } }, { "ad": "geçersiz üye kimliği alan hatasıyla reddedilir", "istek": { "yontem": "POST", "yol": "/odunc", "govde": { "uye": "1001", "kalemler": [{ "isbn": "978-0262033848" }] } }, "beklenen": { "kod": 422, "alanlar": { "/type": "string", "/errors/0/yol": "string", "/errors/0/kod": "string" } } } ] }
Beklentinin ne içermediği en az içerdiği kadar önemlidir. Raf terminali uye ve
kalemler alanlarını hiç yazmamıştır, çünkü okumamaktadır. Mobil uygulama id alanını
yazmamıştır. Beklenti dosyası “yanıt şu alanları içerir” demez, “bu tüketici şu alanlara
bağımlıdır” der. Fark, sağlayıcının kaldırdığı bir alanın kimi bozacağını belirler.
Mobil uygulamanın ikinci beklentisi hata yolunu da kapsıyor. Hata sözleşmesi de
sözleşmenin parçasıdır: 422 yanıtının errors dizisinde yol ve kod alanlarının bulunması,
o uygulamanın hataları giriş kutularının yanında gösterebilmesinin koşuludur.
Beklentilerin Sağlayıcıya Karşı Çalıştırılması
Sağlayıcı iki sürümde çalışabilir: v1 ve kırıcı değişikliğin yapıldığı v2. Kırıcı
değişiklik iadeTarihi alanının sonTarih olması ve uye alanının nesneye dönmesidir.
// saglayici.mjs — odunc servisi saglayicisi // Kullanim: node saglayici.mjs <port> [v2] // v2: iadeTarihi alani sonTarih olur, uye nesneye doner (kirici degisiklik) import { createServer } from "node:http"; const PORT = Number(process.argv[2] ?? 8438); const V2 = process.argv[3] === "v2"; const KAYIT = { id: "O-1", uye: "U-1001", kalemler: [{ isbn: "978-0262033848" }], iadeTarihi: "2026-03-20", durum: "acik" }; const disaVer = (k) => V2 ? { id: k.id, uye: { kimlik: k.uye }, kalemler: k.kalemler, sonTarih: k.iadeTarihi, durum: k.durum } : { ...k }; const govdeOku = (istek) => new Promise((coz) => { let v = ""; istek.on("data", (p) => (v += p)); istek.on("end", () => coz(v)); }); createServer(async (istek, yanit) => { yanit.sendDate = false; const yol = istek.url.split("?")[0]; const json = (kod, g, tip = "application/json") => { yanit.writeHead(kod, { "content-type": `${tip}; charset=utf-8` }); yanit.end(JSON.stringify(g)); }; if (istek.method === "GET" && yol === "/odunc/O-1") return json(200, disaVer(KAYIT)); if (istek.method === "POST" && yol === "/odunc") { const g = JSON.parse((await govdeOku(istek)) || "{}"); if (!/^U-\d{4}$/.test(g.uye ?? "")) { return json(422, { type: "https://ornek.kutuphane/sorunlar/dogrulama", title: "İstek gövdesi doğrulanamadı", status: 422, detail: "Üye kimliği biçimi geçersiz.", instance: "ol-0001", errors: [{ yol: "/uye", kod: "bicim" }] }, "application/problem+json"); } return json(201, disaVer({ ...KAYIT, id: "O-2", uye: g.uye, kalemler: g.kalemler ?? [] })); } json(404, { type: "https://ornek.kutuphane/sorunlar/kaynak-yok", title: "Kaynak bulunamadı", status: 404, detail: `${yol} yok.`, instance: "ol-0002" }, "application/problem+json"); }).listen(PORT, "127.0.0.1", () => console.log(`saglayici 127.0.0.1:${PORT} ${V2 ? "v2" : "v1"}`));
Sınama dosyası beklentileri dizinden okur, sağlayıcıyı ayrı bir süreç olarak başlatır ve her etkileşimi gerçekten yollar.
// sozlesme.test.mjs — tuketici beklentilerini saglayiciya karsi calistirir // Kullanim: node --test sozlesme.test.mjs (saglayici v1) // SAGLAYICI_SURUM=v2 node --test sozlesme.test.mjs import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { readdirSync, readFileSync } from "node:fs"; const PORT = 8438; const TABAN = `http://127.0.0.1:${PORT}`; let surec; before(async () => { surec = spawn("node", ["saglayici.mjs", String(PORT), process.env.SAGLAYICI_SURUM ?? "v1"], { stdio: "ignore" }); for (let i = 0; i < 40; i++) { // saglayici ayaga kalkana kadar bekle try { await fetch(`${TABAN}/odunc/O-1`); return; } catch { await new Promise((c) => setTimeout(c, 50)); } } throw new Error("sağlayıcı başlatılamadı"); }); after(() => surec.kill()); const oku = (govde, yol) => yol.split("/").slice(1).reduce((d, p) => (d == null ? undefined : d[p]), govde); const tipi = (d) => (Array.isArray(d) ? "array" : d === null ? "null" : typeof d); for (const dosya of readdirSync("beklentiler").sort()) { const { tuketici, etkilesimler } = JSON.parse(readFileSync(`beklentiler/${dosya}`, "utf8")); for (const e of etkilesimler) { test(`${tuketici}: ${e.ad}`, async () => { const cevap = await fetch(TABAN + e.istek.yol, { method: e.istek.yontem, headers: e.istek.govde ? { "content-type": "application/json" } : {}, body: e.istek.govde ? JSON.stringify(e.istek.govde) : undefined, }); assert.equal(cevap.status, e.beklenen.kod, "durum kodu"); const govde = await cevap.json(); for (const [yol, beklenenTip] of Object.entries(e.beklenen.alanlar)) { const deger = oku(govde, yol); assert.notEqual(deger, undefined, `${yol} alanı yanıtta yok`); assert.equal(tipi(deger), beklenenTip, `${yol} alanının tipi`); } }); } }
node --test sozlesme.test.mjs 2>&1 | head -8
✔ mobil-uygulama: ödünç kaydı ekranda gösterilir (59.873209ms) ✔ mobil-uygulama: geçersiz üye kimliği alan hatasıyla reddedilir (3.696709ms) ✔ raf-terminali: kaydın kimliği ve durumu okunur (0.830209ms) ℹ tests 3 ℹ suites 0 ℹ pass 3 ℹ fail 0 ℹ cancelled 0
Süreler makineye göre değişir. Şimdi sağlayıcı kırıcı değişikliği yapar:
SAGLAYICI_SURUM=v2 node --test sozlesme.test.mjs 2>&1 | head -20
✖ mobil-uygulama: ödünç kaydı ekranda gösterilir (60.630959ms) ✔ mobil-uygulama: geçersiz üye kimliği alan hatasıyla reddedilir (4.205875ms) ✔ raf-terminali: kaydın kimliği ve durumu okunur (2.19525ms) ℹ tests 3 ℹ suites 0 ℹ pass 2 ℹ fail 1 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 127.002333 ✖ failing tests: test at sozlesme.test.mjs:28:5 ✖ mobil-uygulama: ödünç kaydı ekranda gösterilir (60.630959ms) AssertionError [ERR_ASSERTION]: /uye alanının tipi 'object' !== 'string'
Üç sınamadan biri düştü. Düşen sınama hangi tüketicinin hangi etkileşiminin hangi alanında bozulduğunu adıyla söylüyor. Raf terminalinin sınaması geçti, çünkü değişen alanları okumuyor.
Bu, tanıma karşı doğrulamanın veremeyeceği bir bilgidir. Tanım denetimi “yanıt tanımdan saptı” der ve durur; sözleşme sınaması “mobil uygulamanın ödünç ekranı bozuldu, raf terminali etkilenmedi” der. İlki bir kural ihlalini, ikincisi bir sonucu bildirir.
Etkinin Değişiklikten Önce Hesaplanması
Sınamanın düşmesi için değişikliğin yapılmış olması gerekir. Beklenti dosyaları değişiklikten önce de okunabilir: hangi alanın hangi tüketici tarafından okunduğu zaten yazılıdır.
// etki.mjs — beklenti dosyalarindan, hangi degisikligin hangi tuketiciyi bozacagini cikarir import { readdirSync, readFileSync } from "node:fs"; // Tuketici -> bagimli oldugu alan yollari const bagimlilik = new Map(); for (const dosya of readdirSync("beklentiler").sort()) { const { tuketici, etkilesimler } = JSON.parse(readFileSync(`beklentiler/${dosya}`, "utf8")); const yollar = etkilesimler.flatMap((e) => Object.keys(e.beklenen.alanlar).map((y) => `${e.istek.yol} ${y}`)); bagimlilik.set(tuketici, new Set(yollar)); } // Tanimin GET /odunc/O-1 yanitinda bildirdigi butun alanlar const TANIMLI = ["/id", "/uye", "/kalemler/0/isbn", "/iadeTarihi", "/durum"].map((y) => `/odunc/O-1 ${y}`); console.log("alan bağımlı tüketiciler"); for (const yol of TANIMLI) { const bagli = [...bagimlilik].filter(([, k]) => k.has(yol)).map(([t]) => t); console.log(`${yol.replace("/odunc/O-1 ", "").padEnd(30)} ${bagli.length ? bagli.join(", ") : "— (kimse okumuyor)"}`); } // Onerilen degisiklik: iadeTarihi kalkiyor, uye nesneye donuyor. const DEGISIKLIK = ["/odunc/O-1 /iadeTarihi", "/odunc/O-1 /uye"]; const etkilenen = [...bagimlilik].filter(([, k]) => DEGISIKLIK.some((d) => k.has(d))).map(([t]) => t); console.log(`\nönerilen değişiklik: ${DEGISIKLIK.map((d) => d.split(" ")[1]).join(", ")}`); console.log(`etkilenen tüketici: ${etkilenen.length}/${bagimlilik.size} (${etkilenen.join(", ")})`); console.log(`hiç okunmayan alan sayısı: ${TANIMLI.filter((y) => ![...bagimlilik.values()].some((k) => k.has(y))).length}`);
alan bağımlı tüketiciler /id raf-terminali /uye mobil-uygulama /kalemler/0/isbn mobil-uygulama /iadeTarihi mobil-uygulama /durum raf-terminali önerilen değişiklik: /iadeTarihi, /uye etkilenen tüketici: 1/2 (mobil-uygulama) hiç okunmayan alan sayısı: 0
Bu tablo, kullanımdan kaldırma dersindeki telemetriyle aynı işi alan düzeyinde yapar. Telemetri hangi tüketicinin hangi sürümü çağırdığını sayıyordu; beklenti dosyaları hangi tüketicinin hangi alanı okuduğunu söylüyor. İkisi birlikte, bir değişikliğin maliyetini tahmin değil hesap konusu yapar.
Son satır ayrıca bir uyarıdır. “Hiç okunmayan alan sayısı: 0” güven verici görünür ama yalnızca beklenti dosyası yazmış tüketiciler için doğrudur. Bir alanın kimse tarafından okunmadığı sonucu, ancak bütün tüketiciler beklentilerini yazdığında geçerlidir. Sözleşme sınamalarının en kırılgan yeri budur: eksik beklenti, olmayan bağımlılık gibi görünür.
Bu yüzden sözleşme sınaması iki tarafın da katıldığı bir düzendir. Tüketici beklentisini yazar ve sağlayıcının deposuna verir; sağlayıcı bu dosyaları kendi sınama takımında çalıştırır. Beklenti yazmayan tüketici, sağlayıcının kararlarında görünmez ve kırıldığında haber vermekten başka bir gücü kalmaz. Kütüphanenin kendi raf terminali için bu düzenin kurulması kolaydır; dışarıdaki ilçe kütüphanelerinin uygulamaları için beklenti toplamak, düzenin en zor parçasıdır.
Özet
- Makine okunur tanım neyin verildiğini, tüketici beklentisi neyin kullanıldığını söyler; bir alanın kaldırılmasının kimi bozacağı ancak ikincisinden bilinir.
- Beklenti dosyasının içermediği alanlar, tüketicinin o alanlara bağımlı olmadığı anlamına gelir; hata yanıtının yapısı da beklentinin parçasıdır.
- Beklentiler sağlayıcıya karşı çalıştırıldığında kırıcı değişiklik, hangi tüketicinin hangi etkileşiminin hangi alanında bozulduğunu adıyla bildirir.
- Tanım denetimi bir kural ihlalini, sözleşme sınaması bir sonucu bildirir; ikisi farklı sorulara yanıt verir.
- Beklenti dosyaları değişiklikten önce de okunabilir ve önerilen bir değişikliğin etkilediği tüketici sayısı hesaplanabilir.
- “Kimsenin okumadığı alan” sonucu yalnızca beklentisini yazmış tüketiciler için geçerlidir; eksik beklenti, olmayan bağımlılık gibi görünür.
Sonraki Adım
Beklenti dosyaları şu ana kadar tek yönlü kullanıldı: tüketici yazdı, sağlayıcı sınadı. Aynı dosyalar ters yönde de işe yarar. Yeni bir tüketici geliştirilmeye başlandığında sağlayıcı henüz o uç noktayı yazmamış olabilir; tüketicinin beklemesi ise gereksizdir, çünkü sözleşme zaten bellidir. Sonraki ders şemadan örnek yanıt üreten bir sunucu yazar, istemciyi ona karşı geliştirir ve aynı istemci kodunun gerçek sunucuya geçtiğinde değişmeden çalıştığını gösterir.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.