İçeriğe geç

Sözleşme Testi Ne Zaman Gerekir? Mikroservislerde API Testinin Sınırları

Mock geçti, prod patladı: tanıdık senaryo

Servis A’nın testleri yeşil, servis B’nin testleri yeşil, ikisi birlikte çalışınca 500 dönüyor. Mikroservis ekiplerinde entegrasyon kırılmalarının çoğu kod hatası değil; tüketicinin sağlayıcı hakkındaki varsayımının eskimesidir. Sağlayıcı customerId alanını customer.id altına taşır, status değerini ACTIVE yerine active yazmaya başlar, opsiyonel bir alanı kaldırır. Kendi testleri buna göre güncellendiği için hiçbir şey kırılmaz. Tüketicinin testleri ise mock’a bakar, mock hâlâ eski gerçeği anlatır.

Mock’un işi bu: yazıldığı günkü gerçeği dondurmak. Mocking ve stubbing testi hızlandırmak ve izole etmek için doğru araçtır, ama bir mock hiçbir zaman “sağlayıcı bu cevabı gerçekten üretiyor mu?” sorusunu cevaplamaz. Bu tipik olarak şöyle görünür: alan adı değişikliği haftalarca fark edilmez, çünkü ilgili uç nokta yalnızca belirli bir kullanıcı segmentinde tetiklenir; hata bir destek kaydı olarak geri döner, CI’dan değil. Sözleşme testi tam olarak bu boşluğu kapatmak için var.

Sözleşme testi tam olarak neyi doğrular

Sözleşme testi bir işlevsellik testi değildir. İki servis arasındaki mesaj biçimi ve beklenti uyumunu doğrular: tüketici hangi isteği gönderiyor, hangi cevap alanlarını gerçekten okuyor, sağlayıcı bu beklentiyi hâlâ karşılıyor mu.

Ayrımı net tutmak lazım. “İndirim %20’den fazla olamaz” bir iş kuralıdır; sözleşme testinin konusu değil. “discountRate alanı cevapta var ve sayı tipinde” bir arayüz beklentisidir; sözleşme testinin tam konusu. Sözleşme iş kuralını değil, arayüzü korur.

İkinci kritik özellik: doğrulama bağımsızdır. Tüketici testi çalıştığında makine tarafından okunabilir bir sözleşme dosyası üretilir. Sağlayıcı bu dosyayı kendi pipeline’ında, tüketici kodu hiç ortada olmadan oynatır. İki servisi aynı anda ayağa kaldırmadan “bu ikisi konuşabiliyor mu” sorusuna cevap alırsın. Uçtan uca testin pahalı biçimde aldığı cevabı saniyeler içinde almanın yolu bu.

Tüketici odaklı ve sağlayıcı odaklı sözleşme: farkı bilmeden seçim yapma

Consumer-driven contract (Pact tarzı) tüketicinin gerçek ihtiyacını sözleşmeye çevirir. Tüketici id, total ve status okuyorsa sözleşmede bu üç alan vardır; sağlayıcı cevaba on alan daha ekleyebilir, kullanılmayan bir alanı silebilir, kimse kırılmaz. Kazanç şu: sağlayıcı neyin kullanıldığını öğrenir ve kullanılmayan yerlerde serbest kalır.

Provider-driven / şema tabanlı yaklaşım (OpenAPI, protobuf, JSON Schema) tersini yapar: sağlayıcı vaadini yayınlar, tüketiciler ona uyar. Kimin hangi alanı kullandığı bilinmez, dolayısıyla hiçbir alan güvenle silinemez — ama yüzlerce bilinmeyen tüketiciye tek bir doğruluk kaynağı sunar.

Seçim kuralı basit: public API’de şema tabanlı, çünkü tüketicileri tanımıyorsun ve onların testlerini pipeline’ına sokamazsın. İç mikroservis ağında tüketici odaklı, çünkü tüketiciler sayılıdır, repo’ları erişilebilirdir ve gerçek soru “hangi alanı güvenle değiştirebilirim” sorusudur. İkisini karıştırmak en pahalı yol: OpenAPI şemasını sözleşme sanıp doğrulamayı hiç çalıştırmamak, dokümantasyon üretir, güvence üretmez.

Test piramidinde sözleşme testinin yeri

Sözleşme testi unit ile uçtan uca arasındaki boşluğu doldurur. Entegrasyon testinin yerine geçmez, sayısını azaltır. Test seviyelerine yerleştirirken şunu düşün: uçtan uca testin kapsamaya çalıştığı senaryoların bir kısmı aslında “servisler birbirini anlıyor mu” sorusudur. Bu kısım sözleşme testine devredilebilir. Geriye kalan — çok servisli iş akışı, gerçek veri tutarlılığı, kullanıcı yolculuğu — uçtan uca kalmalı.

Alttan üste Unit → Sözleşme → Entegrasyon → Uçtan uca; sözleşmede "arayüz uyumlu mu?", uçtan ucada "iş akışı doğru mu?

Pratikte beklenen etki, uçtan uca süitin tamamen yok olması değil; “alan değişti mi” tipindeki senaryoların oradan çekilip alt katmana inmesidir. Bu da süitin hem süresini hem de flaky test yüzeyini küçültür.

Ne zaman gerekir: kararı verecek beş sinyal

Sözleşme testi bedava değil: araç, broker, state kurulumu ve öğrenme maliyeti var. Gerekli olduğunu gösteren somut sinyaller şunlar:

  1. Servisler ayrı repo ve ayrı pipeline’larda deploy oluyor. Sağlayıcı, tüketiciden bağımsız olarak üretime çıkabiliyorsa arayüz kırılmasını yakalayacak hiçbir derleme adımı yok.
  2. Tüketici sayısı ikiden fazla. Tek tüketicide konuşarak çözülen şey, üç tüketicide kimsenin tam resmi görmediği bir duruma dönüşür.
  3. Sağlayıcı ekibi tüketicileri tanımıyor. “Bu alanı kim kullanıyor?” sorusuna cevap veremeyen bir ekip, hiçbir alanı silemez veya yanlış alanı siler.
  4. Entegrasyon hataları ancak staging’de veya prod’da görülüyor. Geri bildirim döngüsü commit’ten günler sonra kapanıyorsa kırılmanın maliyeti katlanır.
  5. Uçtan uca süit kırılganlıktan dolayı sürekli yeniden yazılıyor. Süitin en çok bakım yiyen testleri genelde arayüz uyumunu dolaylı yoldan kontrol eden testlerdir; onlar sözleşmeye taşınmak için hazır.

Üç veya daha fazlası varsa sözleşme testi net kazanç. Bir tanesi varsa acele etme.

Ne zaman gerekmez: aşırı mühendisliğin maliyeti

Tek deploy birimi, monorepo ve aynı ekip: sözleşme testi burada net kayıp. Aynı pipeline’da derlenen iki modül arasında derleyici veya tip sistemi zaten sözleşmeyi doğruluyor. TypeScript’te paylaşılan bir tip, Java’da paylaşılan bir interface, sözleşme dosyasından daha hızlı ve daha kesin geri bildirim verir.

İkinci kaçınma durumu: az sayıda tüketicisi olan ve senkron deploy edilen servisler. İki servis her zaman birlikte çıkıyorsa iyi yazılmış bir entegrasyon testi daha ucuzdur — broker yok, versiyonlama yok, state handler yok.

Üçüncüsü: sözleşmeyi kimsenin kırıcı hale getirmeyeceği ekipler. Doğrulama pipeline’da kırmıyorsa ürettiğin şey bakım maliyeti olan bir dokümandır. Bu tipik olarak şöyle sonuçlanır: sözleşme dosyaları birkaç sürüm sonra gerçekle uyuşmaz, ekip doğrulama adımını continue-on-error yapar ve araç sessizce ölür.

Tüketici tarafında sözleşme üretmek

Tüketici testinde bir mock sunucuya beklenti yazılır; test geçtiğinde beklenti bir pact dosyasına dökülür. Kritik nokta: sözleşmeye yalnızca tüketicinin gerçekten okuduğu alanları koymak. Tam JSON gövdesini kopyalamak sözleşmeyi kırılgan yapar ve sağlayıcıyı kullanmadığın alanlara hapseder.

İkinci kritik nokta: esnek matcher. Değerin kendisi değil, tipi ve şekli önemli. "total": 149.9 yerine number beklentisi yaz; yoksa sağlayıcının test verisi her değiştiğinde sözleşme kırılır.

// src/orderClient.js
const BASE_URL = process.env.ORDERS_API_URL;

async function getOrderSummary(orderId, baseUrl = BASE_URL) {
  const res = await fetch(`${baseUrl}/orders/${orderId}`, {
    headers: { Accept: 'application/json' },
  });

  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Orders API hatası: ${res.status}`);

  const body = await res.json();

  // Tüketici yalnızca bu üç alanı okuyor.
  return {
    id: body.id,
    total: body.total,
    status: body.status,
  };
}

module.exports = { getOrderSummary };
// test/contract/orders.consumer.spec.js
const path = require('path');
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { getOrderSummary } = require('../../src/orderClient');

const { integer, decimal, regex } = MatchersV3;

const provider = new PactV3({
  consumer: 'checkout-web',
  provider: 'orders-api',
  dir: path.resolve(process.cwd(), 'pacts'),
  logLevel: 'warn',
});

describe('orders-api sözleşmesi', () => {
  it('var olan siparişin özetini döner', async () => {
    provider
      .given('42 numaralı sipariş var ve ödendi')
      .uponReceiving('sipariş özeti isteği')
      .withRequest({
        method: 'GET',
        path: '/orders/42',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: integer(42),
          total: decimal(149.9),
          status: regex('PAID|PENDING|CANCELLED', 'PAID'),
        },
      });

    await provider.executeTest(async (mockServer) => {
      const summary = await getOrderSummary(42, mockServer.url);

      expect(summary.id).toBe(42);
      expect(typeof summary.total).toBe('number');
      expect(summary.status).toBe('PAID');
    });
  });

  it('olmayan siparişte null döner', async () => {
    provider
      .given('99 numaralı sipariş yok')
      .uponReceiving('olmayan sipariş isteği')
      .withRequest({
        method: 'GET',
        path: '/orders/99',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({ status: 404 });

    await provider.executeTest(async (mockServer) => {
      const summary = await getOrderSummary(99, mockServer.url);
      expect(summary).toBeNull();
    });
  });
});

given(...) çağrıları önemsiz görünür ama sağlayıcı tarafının tüm yükü oradadır. Bir sonraki bölüm bunun için.

Sağlayıcı tarafında doğrulama ve durum kurulumu

Sağlayıcı, yayımlanan sözleşmeyi kendi pipeline’ında gerçek uygulamaya karşı oynatır. Asıl zorluk provider state: sözleşmedeki “42 numaralı sipariş var ve ödendi” varsayımını sağlayıcının deterministik biçimde kurabilmesi gerekir.

Buradaki en yaygın hata state’i veritabanına doğrudan SQL ile yazmak. Şema değiştiğinde bu satırlar bozulur, kalan kayıtlar testler arasında sızar ve sözleşme doğrulaması kendi başına flaky bir teste dönüşür. State kurulumu uygulamanın kendi fixture/repository katmanı üzerinden yapılmalı ve her state kendi verisini üretmeli — test verisi yönetiminin kuralı burada da aynen geçerli.

// test/contract/orders.provider.spec.js
const path = require('path');
const { Verifier } = require('@pact-foundation/pact');
const { startServer } = require('../../src/server');
const { resetDatabase, seedOrder } = require('../fixtures/orderFixtures');

const PORT = 4001;
let server;

beforeAll(async () => {
  server = await startServer(PORT);
});

afterAll(async () => {
  await server.close();
});

describe('orders-api sözleşme doğrulaması', () => {
  it('yayımlanan tüm sözleşmeleri karşılar', async () => {
    const opts = {
      provider: 'orders-api',
      providerBaseUrl: `http://127.0.0.1:${PORT}`,
      pactBrokerUrl: process.env.PACT_BROKER_URL,
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      providerVersion: process.env.GIT_COMMIT,
      providerVersionBranch: process.env.GIT_BRANCH,
      consumerVersionSelectors: [
        { mainBranch: true },
        { deployedOrReleased: true },
      ],
      publishVerificationResult: process.env.CI === 'true',
      stateHandlers: {
        '42 numaralı sipariş var ve ödendi': async () => {
          await resetDatabase();
          await seedOrder({ id: 42, total: 149.9, status: 'PAID' });
          return 'sipariş 42 hazır';
        },
        '99 numaralı sipariş yok': async () => {
          await resetDatabase();
          return 'veritabanı boş';
        },
      },
    };

    const output = await new Verifier(opts).verifyProvider();
    console.log(output);
  });
});

consumerVersionSelectors kısmı gözden kaçmasın: sağlayıcıyı ana daldaki ve üretimde çalışan tüketici sürümlerine karşı doğrular. Yalnızca en son sözleşmeye bakmak, hâlâ prod’da duran eski tüketiciyi görmezden gelmek anlamına gelir.

Sözleşmeyi CI’da kırıcı hale getirmek

Sözleşme testi bir broker ile merkezîleştirilip can-i-deploy benzeri bir kapıya bağlanmadıkça dekoratif kalır. Kapının mantığı şu: sağlayıcı üretime çıkmadan önce üretimde bulunan tüm tüketici sürümlerine karşı doğrulanmış olmalı. Bu kararın girdisi versiyonlama (genelde commit SHA) ve ortam etiketleridir — hangi sürümün nerede deploy edildiğini broker’a bildirmezsen kapı doğru cevabı veremez.

# .github/workflows/orders-api.yml
name: orders-api
on: [push]

env:
  PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
  PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}

jobs:
  verify-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci

      - name: Sözleşme doğrulaması
        env:
          GIT_COMMIT: ${{ github.sha }}
          GIT_BRANCH: ${{ github.ref_name }}
          PACT_BROKER_URL: ${{ env.PACT_BROKER_BASE_URL }}
        run: npx jest test/contract/orders.provider.spec.js

      - name: Deploy edebilir miyim?
        run: |
          npx --yes @pact-foundation/pact-cli \
            pact-broker can-i-deploy \
            --pacticipant orders-api \
            --version ${{ github.sha }} \
            --to-environment production \
            --retry-while-unknown 6 \
            --retry-interval 10

      - name: Üretime çık
        if: github.ref_name == 'main'
        run: ./scripts/deploy.sh production

      - name: Deploy kaydını bildir
        if: github.ref_name == 'main'
        run: |
          npx --yes @pact-foundation/pact-cli \
            pact-broker record-deployment \
            --pacticipant orders-api \
            --version ${{ github.sha }} \
            --environment production

Tüketici tarafında da simetrik bir adım gerekir: sözleşme testini çalıştır, üretilen pact dosyasını pact-broker publish ile yayımla, sonra kendi can-i-deploy kapısından geç.

Tüketici CI pact yayınlar, Sağlayıcı CI doğrular, can-i-deploy geçerse deploy olur ve record-deployment broker'a döner.

Sözleşme testinin kapsamadığı şeyler

Sözleşme testi performansı ölçmez, yetkilendirme davranışını doğrulamaz, iş kuralının doğruluğunu kontrol etmez, ağ katmanı hatalarını (timeout, retry, circuit breaker) simüle etmez, servisler arası veri tutarlılığını görmez. Yalnızca mesaj biçimini ve beklenti uyumunu doğrular.

“Sözleşme testi kurduk, tüm entegrasyon testlerini attık” diyen ekipler bu boşlukları prod’da öğrenir. Doğru hamle kapsam sınırını baştan yazılı hale getirmek: hangi risk hangi test seviyesinde karşılanıyor, tek sayfada dursun. Bu belge olmadan strateji savunulamaz; her kırılmadan sonra tartışma “araç işe yaramadı” noktasına kayar. API test otomasyonunun geri kalanı — durum kodu davranışı, hata gövdeleri, yetki senaryoları — sözleşmeden sonra da yerinde kalır.

Asenkron mesajlaşmada sözleşme

Kafka veya RabbitMQ üzerinden akan event’lerde sözleşme testi HTTP’den daha değerlidir, çünkü orada uçtan uca test kurmanın maliyeti çok daha yüksek: broker ayağa kaldırmak, mesajın tüketilmesini beklemek, zamanlamaya bağlı doğrulama yazmak. Sonuç genelde yavaş ve kararsız bir süit olur.

Asenkron sözleşmede istek/cevap yoktur; konu mesaj payload’ı ve şema evrimidir. Üretici mesajı belirli bir şekilde yayınlar, tüketicinin handler’ı o mesajı okuyabilir mi — test edilen şey bu. Pact’te bu “message pact” olarak modellenir ve doğrulama, tüketicinin handler fonksiyonunu sözleşmedeki payload ile çağırmaktan oluşur.

Kritik ayrım, geriye dönük uyumlu alan ekleme ile alan silme arasındadır. Yeni bir opsiyonel alan eklemek mevcut tüketicileri kırmaz; bir alanı silmek veya tipini daraltmak kırar. Schema registry bunu şema seviyesinde yakalar ama “bu alanı gerçekten kim okuyor” sorusuna cevap vermez. Tüketici odaklı sözleşme testi, silinen alanın hangi tüketicinin handler’ında kullanıldığını ismiyle gösterir — registry’nin uyumluluk kontrolünden önce ve daha spesifik olarak.

İlk sözleşmeni bu hafta nasıl kurarsın

En çok kırılan tek entegrasyonu seç. Tüm servis ağını kapsamaya çalışmak, ikinci hafta terk edilen bir girişim üretir.

Sonra şu sırayı izle: o entegrasyonun tek bir uç noktası için tüketici tarafında sözleşme üret, dosyayı broker’a yayımla, sağlayıcının pipeline’ına doğrulama adımını ekle. Kapıyı iki hafta boyunca bilerek kırıcı yapma — continue-on-error ile çalıştır, sonuçları logla.

İki hafta sonra yakalanan kırılmaları say. Sıfırsa o entegrasyon sözleşme testine ihtiyaç duymuyordu; kurulumu sil ve daha kırılgan bir yere taşı. Bir veya daha fazlaysa kapıyı kırıcı yap ve ikinci entegrasyonu ekle. Bu ölçüm alışkanlığı olmadan sözleşme testi, bakımı kimsenin üstlenmediği bir klasöre dönüşür.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir

Bu site istenmeyenleri azaltmak için Akismet kullanır. Yorum verilerinizin nasıl işlendiğini öğrenin.