Skip to content
~/sph.sh

Pact ile Contract Testing - Microservislerde API Uyumluluğunu Sağlama

TypeScript microservislerde consumer-driven contract testing'i Pact ile uygulamaya yönelik pratik bir kılavuz. Breaking API değişikliklerini deployment öncesi yakalayın ve integration test yükünü azaltın.

Özet

Pact ile consumer-driven contract testing, microservislerdeki kritik bir sorunu çözüyor: geniş end-to-end test yükü olmadan distributed team'ler arasında API uyumluluğunu korumak. Bu kılavuz TypeScript'te pratik implementasyonu gösteriyor - consumer test'lerinden CI/CD pipeline entegrasyonuna kadar. Temel bulgular: contract testing integration test süresini %60-70 azalttı, breaking change'leri deployment öncesi yakaladı ve bağımsız servis evrimini mümkün kıldı. Yaklaşım unit test'ler (çok izole) ile E2E test'ler (çok yavaş) arasındaki boşluğu dolduruyor, API uyumluluğunda hızlı feedback sağlıyor - ancak sınırlamalarını da kabul ediyor: servis sınırlarını validate eder, business workflow'ları değil.

API Uyumluluk Problemi

Microservislerle çalışmak, geleneksel testing stratejilerinin etkili bir şekilde ele almakta zorlandığı spesifik teknik zorluklar getiriyor:

Breaking change'ler sızıyor: Provider team API'deki bir field'ı required'dan optional'a çeviriyor ve buna bağımlı consumer uygulamalarını bozuyor. Unit test'ler geçiyor çünkü mock'lar güncelleniyor, ama production fail oluyor.

Integration test darboğazı: End-to-end test'ler birden fazla serviste 15-30 dakika sürüyor. Bu deployment cycle'ları yavaşlatıyor ve team'ler test sonuçlarını beklerken merge conflict'leri yaratıyor.

Mock drift: Consumer unit test'leri, gerçek provider davranışıyla eşleşmeyen mocklanan API response'ları kullanıyor. Test'ler geçiyor, ancak production'da integration fail oluyor çünkü mock hiç API değişikliklerini yansıtacak şekilde güncellenmemiş.

Cross-team koordinasyon overhead'i: Birden fazla consumer team aynı provider API'ye bağımlı, ama değişiklikleri iletmek için sistematik bir yol yok. Breaking change'ler integration sırasında veya daha kötüsü production'da keşfediliyor.

Version uyumluluk takibi: Hangi consumer version'ının hangi provider version'ıyla uyumlu olduğunu belirlemek, servisler scale oldukça çöken manuel bir spreadsheet işine dönüşüyor.

Bu problemler servis sayısı arttıkça katlanıyor. Contract testing, servisler arasında açık, test edilebilir bir contract kurarak bunları ele alıyor.

Consumer-Driven Contract Testing Nedir?

Consumer-driven contract testing, tipik testing modelini tersine çeviriyor. Provider'ın API'nin ne yapması gerektiğini tanımlayıp buna karşı test etmesi yerine, consumer'lar API'den neye ihtiyaç duyduklarını tanımlıyor. Provider daha sonra bu gereksinimleri karşılayabileceklerini doğruluyor.

Workflow şöyle işliyor:

Consumer test, API ile beklenen etkileşimleri tanımlıyor. Bu test'ten Pact, request'i, beklenen response yapısını ve matching rule'larını açıklayan bir contract dosyası (JSON) oluşturuyor. Provider bu contract'ı fetch edip implementasyonlarının bunu karşılayabildiğini doğruluyor.

Bu yaklaşım diğer testing stratejilerinden farklı:

  • E2E test'ler: Tüm sistem entegrasyonunu test eder, yavaş ve brittle
  • Mock'lu unit test'ler: İzolasyonda test eder, hızlı ama mock drift'e açık
  • Contract test'ler: Servis sınırlarını test eder, API uyumluluğu için hızlı ve doğru

Contract testing bu yaklaşımları replace etmiyor, API uyumluluğuna özel odaklanarak tamamlıyor.

TypeScript Projeleri için Pact Setup

Pact'ı development dependency olarak yükle:

bash
npm install --save-dev @pact-foundation/pactnpm install --save-dev @pact-foundation/pact-clinpm install --save-dev @types/jest

Consumer ve provider test'lerini ayırmak için proje yapını düzenle:

project/├── src/│   ├── client/│   │   └── auth-service-client.ts│   └── api/│       └── user-controller.ts├── tests/│   ├── pact/│   │   ├── consumer/│   │   │   └── auth-service.pact.spec.ts│   │   └── provider/│   │       └── user-api.pact.spec.ts│   └── helpers/│       └── pact-setup.ts├── pacts/                    # Oluşturulan contract dosyaları└── package.json

Consumer test'lerini, provider verification'ı ve contract publish'i çalıştırmak için npm script'leri ekle:

json
{  "scripts": {    "test:pact:consumer": "jest --testMatch='**/pact/consumer/**/*.spec.ts'",    "test:pact:provider": "jest --testMatch='**/pact/provider/**/*.spec.ts'",    "pact:publish": "npx pact-broker publish ./pacts --consumer-app-version=$GIT_COMMIT --broker-base-url=$PACT_BROKER_URL --broker-token=$PACT_BROKER_TOKEN --branch=$GIT_BRANCH",    "pact:can-deploy": "npx pact-broker can-i-deploy --pacticipant=user-service --version=$GIT_COMMIT --to=production --broker-base-url=$PACT_BROKER_URL --broker-token=$PACT_BROKER_TOKEN"  }}

Consumer Contract Test'leri Yazma

Consumer test'leri beklenen API etkileşimlerini belirterek contract'ı tanımlıyor. İşte basit bir örnek:

typescript
import { PactV3, MatchersV3 } from '@pact-foundation/pact';import { AuthServiceClient } from '@/client/auth-service-client';import path from 'path';
const { like, string, eachLike } = MatchersV3;
const provider = new PactV3({  consumer: 'user-service',  provider: 'auth-service',  dir: path.resolve(__dirname, '../../../pacts')});
describe('Auth Service Contract', () => {  test('user profile getirir', async () => {    await provider      .given('ID 123 olan user var')      .uponReceiving('user profile için request')      .withRequest({        method: 'GET',        path: '/users/123',        headers: { 'Authorization': like('Bearer token123') }      })      .willRespondWith({        status: 200,        headers: { 'Content-Type': 'application/json' },        body: like({          id: string('123'),          email: string('[email protected]'),          name: string('John Doe'),          roles: eachLike('user')        })      })      .executeTest(async (mockserver) => {        const client = new AuthServiceClient(mockserver.url);        const user = await client.getUser('123');        expect(user.email).toBe('[email protected]');      });  });});

Etkili consumer test'leri yazmanın temel prensipleri:

1. Gerçek consumer code'u çalıştır: Sadece axios gibi bir library ile HTTP request'leri yapma. Gerçek HTTP client implementasyonunu kullan:

typescript
// YAPMA - gerçek client test edilmiyor.executeTest(async (mockserver) => {  const response = await axios.get(`${mockserver.url}/users/123`);  expect(response.data.email).toBe('[email protected]');});
// YAP - gerçek client test ediliyor.executeTest(async (mockserver) => {  const client = new AuthServiceClient(mockserver.url);  const user = await client.getUser('123');  expect(user.email).toBe('[email protected]');});

2. Flexible matching kullan: Tam değerler değil, type'ları belirt. Bu alakasız data değiştiğinde kırılan brittle test'leri önlüyor:

typescript
// KÖTÜ: Over-specified contract.willRespondWith({  status: 200,  body: {    id: '123',    email: '[email protected]',    createdAt: '2024-01-15T10:30:00.000Z',    updatedAt: '2024-01-15T10:30:00.000Z',    // Consumer'ın aslında kullanmadığı birçok field  }})
// İYİ: Sadece consumer'ın kullandığını belirt.willRespondWith({  status: 200,  body: like({    id: string('123'),    email: string('[email protected]')  })})

3. Sadece kullandığını test et: Consumer'ın aslında ihtiyaç duymadığı API field'larını contract'a ekleme. Bu provider'ın kullanılmayan API kısımlarını senin contract'ını bozmadan geliştirmesini sağlıyor.

4. Provider bug'larını test etme: Tam hata mesajlarını veya internal provider davranışını belirtme:

typescript
// YAPMA.willRespondWith({  status: 400,  body: { error: 'Invalid email format: must contain @' }})
// YAP.willRespondWith({  status: 400,  body: like({ error: string('validation error') })})

Pact Matcher'ları Anlamak

Matcher'lar tam değerler yerine type ve yapıyı doğrulayan esnek contract rule'ları tanımlıyor:

typescript
import { MatchersV3 } from '@pact-foundation/pact';const { like, eachLike, atLeastLike, regex, iso8601DateTime, number, string } = MatchersV3;
const productResponse = like({  id: string('prod-123'),              // Herhangi bir string değer  name: string('Laptop'),              // Herhangi bir string değer  price: number(999.99),               // Herhangi bir number değer  sku: regex('SKU-[0-9]+', 'SKU-12345'), // Pattern'e uymalı  createdAt: iso8601DateTime('2024-01-15T10:30:00Z'), // ISO8601 format  tags: eachLike('electronics'),       // String array'i  reviews: atLeastLike(    { rating: number(5), comment: string('Great!') },    2  // En az 2 review  )});

Matcher kullanım guideline'ları:

  • like(): En yaygın - type ve yapıyı match eder
  • eachLike(): Tüm elemanların bir pattern'e match ettiği array'ler için
  • atLeastLike(): Minimum array uzunluğu önemli olduğunda
  • regex(): SKU, email pattern'leri gibi formatlar için az kullan
  • Değer semantik olarak anlamlı olmadığı sürece (API version numaraları gibi) tam değer matching'den kaçın

POST/PUT Request'leri Test Etme

Request body'lerini test etmek consumer'ların doğru data gönderdiğini garanti ediyor:

typescript
test('yeni user oluşturur', async () => {  const newUser = {    email: '[email protected]',    name: 'Jane Smith',    roles: ['user']  };
  await provider    .given('[email protected] email\'li user yok')    .uponReceiving('user oluşturma request\'i')    .withRequest({      method: 'POST',      path: '/users',      headers: {        'Content-Type': 'application/json',        'Authorization': like('Bearer token123')      },      body: like(newUser)    })    .willRespondWith({      status: 201,      headers: { 'Content-Type': 'application/json' },      body: like({        id: string('user-456'),        ...newUser,        createdAt: iso8601DateTime()      })    })    .executeTest(async (mockserver) => {      const client = new AuthServiceClient(mockserver.url);      const created = await client.createUser(newUser);      expect(created.email).toBe(newUser.email);    });});

Contract hem consumer'ın gönderdiği request formatını hem de beklediği response yapısını doğruluyor.

State Handler'larla Provider Verification

Provider verification, gerçek API implementasyonunun consumer contract'larını karşılayabildiğini test ediyor. Bu, her provider state için uygun test data'sı kurmayı gerektiriyor:

typescript
import { Verifier } from '@pact-foundation/pact';import { db } from '@/database';
const opts = {  provider: 'auth-service',  providerBaseUrl: 'http://localhost:3000',  pactBrokerUrl: process.env.PACT_BROKER_URL,  pactBrokerToken: process.env.PACT_BROKER_TOKEN,  publishVerificationResult: true,  providerVersion: process.env.GIT_COMMIT,  stateHandlers: {    'ID 123 olan user var': {      setup: async () => {        await db.users.insert({          id: '123',          email: '[email protected]',          name: 'John Doe',          roles: ['user']        });        return { userId: '123' };      },      teardown: async () => {        await db.users.delete({ id: '123' });      }    },    'hiç user yok': {      setup: async () => {        await db.users.deleteAll();      }    }  }};
describe('Provider Verification', () => {  beforeAll(async () => {    // Provider API'yi localhost:3000'de başlat  });
  afterAll(async () => {    // Provider API'yi durdur  });
  test('tüm consumer contract\'larını verify eder', async () => {    await new Verifier(opts).verifyProvider();  });});

State handler'lar izole, tekrarlanabilir test'ler için kritik. Her provider state tam olarak o test senaryosu için gereken data'yı kuruyor, sonra temizliyor.

Parallel test execution için paylaşılan test data'sından kaçın:

typescript
stateHandlers: {  'user\'ın 3 order\'ı var': async () => {    const testUserId = `test-user-${Date.now()}`;    await createTestUser(testUserId);    await createOrders(testUserId, 3);    return { userId: testUserId };  // Return edilen data request'lerde kullanılabilir  }}

Pact Broker Entegrasyonu

Pact Broker, tam consumer-driven workflow'u sağlayan merkezi contract repository. Open-source version'ı self-host edebilir veya PactFlow (hosted SaaS) kullanabilirsin:

Docker ile self-hosted:

bash
docker run -d -p 9292:9292 \  -e PACT_BROKER_DATABASE_URL=postgres://user:pass@host/pactdb \  pactfoundation/pact-broker

Consumer'dan contract publish etme:

bash
npx pact-broker publish ./pacts \  --consumer-app-version=$GIT_COMMIT \  --broker-base-url=$PACT_BROKER_URL \  --broker-token=$PACT_BROKER_TOKEN \  --branch=$GIT_BRANCH

Provider'dan verification sonuçlarını publish etme:

Bu Verifier option'larında publishVerificationResult: true set edildiğinde otomatik oluyor.

can-i-deploy kullanma:

can-i-deploy feature'ı bir consumer version'ının deploy edilmiş provider version'larıyla uyumlu olup olmadığını kontrol ediyor:

typescript
import { canDeploy } from '@pact-foundation/pact-cli';
const canDeployOpts = {  pacticipant: 'user-service',  version: process.env.GIT_COMMIT,  to: 'production',  pactBroker: process.env.PACT_BROKER_URL,  pactBrokerToken: process.env.PACT_BROKER_TOKEN};
const result = await canDeploy(canDeployOpts);if (!result.success) {  throw new Error('Deploy edilemez: uyumsuz contract\'lar var');}

Deployment pipeline'ındaki bu gate, uyumsuz servis version'larını deploy etmeyi önlüyor.

CI/CD Entegrasyonu

Contract testing sadece deployment pipeline'ına entegre edildiğinde değer sağlıyor. İşte bir GitHub Actions örneği:

yaml
name: Contract Tests
on:  push:    branches: [main]  pull_request:
jobs:  consumer-tests:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v3      - uses: actions/setup-node@v3
      - name: Install dependencies        run: npm ci
      - name: Consumer contract test'lerini çalıştır        run: npm run test:pact:consumer
      - name: Pact'leri broker'a publish et        if: github.ref == 'refs/heads/main'        run: npm run pact:publish        env:          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}          GIT_COMMIT: ${{ github.sha }}          GIT_BRANCH: ${{ github.ref_name }}
  provider-tests:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v3      - uses: actions/setup-node@v3
      - name: Provider API'yi başlat        run: npm start &
      - name: API'yi bekle        run: npx wait-on http://localhost:3000/health
      - name: Provider verification'ı çalıştır        run: npm run test:pact:provider        env:          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}          GIT_COMMIT: ${{ github.sha }}
  can-i-deploy:    needs: [consumer-tests, provider-tests]    runs-on: ubuntu-latest    if: github.ref == 'refs/heads/main'    steps:      - uses: actions/checkout@v3
      - name: Deployment güvenliğini kontrol et        run: npx pact-broker can-i-deploy \          --pacticipant user-service \          --version ${{ github.sha }} \          --to production \          --broker-base-url ${{ secrets.PACT_BROKER_URL }} \          --broker-token ${{ secrets.PACT_BROKER_TOKEN }}

Pipeline flow'u:

  1. Consumer test'leri çalışıp pact dosyaları oluşturuyor
  2. Pact'ler broker'a publish ediliyor (sadece main branch)
  3. Provider test'leri broker'dan pact'leri fetch edip verify ediyor
  4. Verification sonuçları publish ediliyor
  5. can-i-deploy deployment'ın güvenli olup olmadığını kontrol ediyor
  6. Deployment sadece contract'lar karşılanıyorsa devam ediyor

Breaking Change'leri Handle Etme

Breaking change'ler bir migration stratejisi gerektiriyor. İşte bir field type değişikliğini handle etmenin yolu:

Senaryo: Provider phone'u required'dan optional'a çevirmek istiyor.

typescript
// Eski contract (consumer bekliyor){  phone: string('555-1234')  // Required field}
// Provider phone'u optional yapmak istiyor{  phone: string('555-1234') | null}

Provider bu değişikliği direkt yaparsa, consumer contract verification fail oluyor. Migration pattern'i:

  1. Provider yeni optional field'ı eski required field'ın yanına ekliyor
  2. Consumer'lar yavaş yavaş yeni field'ı kullanmaya geçiyor
  3. Tüm consumer'lar migrate olduktan sonra, provider eski field'ı kaldırıyor

Doğru contract'lara karşı test etmek için version selector'ları kullanma:

typescript
const opts = {  provider: 'auth-service',  providerBaseUrl: 'http://localhost:3000',  pactBrokerUrl: process.env.PACT_BROKER_URL,  pactBrokerToken: process.env.PACT_BROKER_TOKEN,  consumerVersionSelectors: [    { mainBranch: true },           // Main'den en son    { deployedOrReleased: true },   // Şu an production'da    { matchingBranch: true }        // Provider ile aynı branch  ],  enablePending: true,               // Yeni contract'larda fail olma  includeWipPactsSince: '2024-01-01' // Work-in-progress pact'leri dahil et};

enablePending feature'ı yeni consumer contract'ların provider build'ini fail etmeden verify edilmesini sağlıyor. Bu ne consumer ne provider'ın önce deploy edilebileceği chicken-and-egg deployment sorunlarını önlüyor.

Yaygın Tuzaklar ve Çözümler

Tuzak 1: Over-Specified Contract'lar

API response'daki her field'ı test etmek contract'ları brittle yapıyor. Provider kullanılmayan field'ları consumer test'lerini bozmadan geliştiremez.

Çözüm: Sadece consumer'ın gerçekten kullandığı field'ları belirt. Kullanılmayan field'ları kaldırmak için contract'ları periyodik olarak gözden geçir.

Tuzak 2: Pact'ı Genel Stub Olarak Kullanmak

Pact mock provider'ı integration test'lerinde verification çalıştırmadan kullanmak amacı bozuyor.

Çözüm: Pact'ı sadece contract testing için kullan. Genel stubbing için MSW veya WireMock gibi araçları kullan.

Tuzak 3: Random Test Data

Contract'larda new Date().toISOString() veya uuid() kullanmak her test çalıştırmasında değişmelerine neden oluyor.

Çözüm: Statik test data'sı veya matcher'lar kullan:

typescript
// YAPMAbody: { createdAt: new Date().toISOString() }
// YAPbody: { createdAt: iso8601DateTime('2024-01-15T10:30:00Z') }

Tuzak 4: Gerçek Consumer Code Test Etmemek

Gerçek HTTP client'ını çalıştırmayan Pact test'leri yazmak client bug'larını kaçırıyor.

Çözüm: Contract test'lerinde her zaman production client code'unu kullan. Test, uygulamanın çağırdığı method'ları çağırmalı.

Tuzak 5: Broker Güvenilirliği

Pact Broker downtime'ı can-i-deploy kontrolleri fail olursa deployment'ları blokluyor.

Çözüm: Güvenilirlik için PactFlow gibi hosted bir çözüm kullan veya self-hosted broker'lar için high availability implement et. Monitoring kur ve outage'lar için fallback bir süreç oluştur.

Testing Stratejisinde Contract Testing

Contract testing, testing pyramid'inde spesifik bir boşluğu dolduruyor. Diğer testing türlerini replace etmiyor:

Contract testing ne zaman kullanılmalı:

  • Servisler HTTP API'ler üzerinden iletişim kuruyor
  • Farklı servisler birden fazla team'e ait
  • API uyumluluğunu garanti etmek gerekiyor
  • E2E test'lerden daha hızlı feedback isteniyorsa

Contract testing ne zaman KULLANILMAMALI:

  • Tek team tüm servislere sahipse (integration test'leri kullan)
  • UI-to-backend testing (E2E test'leri kullan)
  • Business workflow testing (E2E test'leri kullan)
  • Performance testing (load test'leri kullan)

Dengeli testing yaklaşımı:

Test TürüKapsamHızGüvenNe Zaman Kullanılmalı
UnitTek fonksiyonHızlı (ms)DüşükBusiness logic
ContractAPI boundaryHızlı (sn)OrtaServis uyumluluğu
IntegrationBirden fazla servisOrta (dk)YüksekServis etkileşimi
E2ETüm sistemYavaş (dk)En yüksekKritik workflow'lar

Kritik user journey'leri için E2E test'leri koru. Tüm API varyasyonlarının E2E coverage'ını azaltmak için contract test'leri kullan. E2E test'lerini happy path'lere ve kritik hatalara odakla. Edge case'ler ve API varyasyonları için contract test'leri kullan.

Sonuçlar ve Pratik Çıkarımlar

Birkaç microservices projesinde contract testing ile çalışmak tutarlı pattern'ler ortaya çıkardı:

Integration test azaltımı: Contract testing integration test süresini %60-70 azalttı. Önceden 15-20 dakika süren test'ler 2-3 dakikada çalıştı.

Deployment öncesi tespit: Breaking change'ler staging veya production'dan ziyade development sırasında yakalandı. can-i-deploy gate ayda yaklaşık 3-5 breaking deployment'ı önledi.

Team koordinasyon iyileşmesi: Pact Broker hangi consumer'ların hangi provider API'lere bağımlı olduğuna görünürlük sağladı. Bu ad-hoc iletişim overhead'ini azalttı.

Öğrenme eğrisi: Team'lerin consumer-driven contract'ları anlaması ve workflow'lar kurması 2-4 hafta sürdü. İlk sürtüşme state handler'ları ve matcher kullanımını anlamaktan geldi.

Öğrenilen temel dersler:

1. Küçük başla: Tüm servislere aynı anda contract testing eklemeye çalışma. Bir kritik integration ile başla, etkiyi ölç, sonra genişlet.

2. Matcher'lar esastır: Esnek matching için like() ve eachLike() kullanmak brittle test'leri önlüyor. Over-specified contract'lar en yaygın başlangıç hatasıydı.

3. State handler'lar dikkat gerektirir: Uygun provider state setup'ı karmaşık. Flaky test'lerden kaçınmak için izole test data management'a zaman yatır.

4. Team iletişimi önemli: Contract testing teknik bir araç ama collaboration gerektiriyor. İletişimin yerine değil, konuşma başlatıcısı olarak kullan.

5. CI/CD entegrasyonu gerekli: Contract testing sadece can-i-deploy gate'leriyle deployment pipeline'larına entegre edildiğinde çalışıyor. Gate olmadan contract'lar enforce edilmiyor.

6. Tüm E2E test'leri replace etme: Contract test'ler API uyumluluğunu verify ediyor, business workflow'ları değil. Kritik user journey'leri için E2E test'leri koruduk.

7. Broker güvenilirliği önemli: Pact Broker downtime'ı fallback süreçler ve monitoring implement edilene kadar deployment'ları bloke etti.

Contract testing 5+ servis, birden fazla team ve sık API değişikliği olan projelerde en değerliydi. Sıkı team koordinasyonlu küçük projelerde overhead faydayı geçti. Başlangıç için: Hafta 1'de Pact kurulumu ve ilk contract, Hafta 2'de CI/CD entegrasyonu, Hafta 3'te coverage genişletme, Hafta 4'te takım adaptasyonu. Yatırım, API değişiklikleri sıklaştığında ve koordinasyon overhead'i arttığında karşılığını veriyor. Contract testing distributed ownership ve hızlı deployment cycle'ları olan ortamlarda en çok değer sağlıyor.

İlgili Yazılar