Contract Testing Nedir: Pact ile Servisler Arası Sözleşme Testi
Mikroservis mimarisine geçen her ekibin er ya da geç karşılaştığı bir problem var: servisler birbirinden bağımsız deploy edilebilir olduğunda, entegrasyon testleri ya çok pahalı hale geliyor ya da tamamen ihmal ediliyor. “Ben kendi servisimi test ettim, karşı taraf da kendi servisini test etti, ne sorun olabilir ki?” diye düşünüyorsunuz ve production’da bir endpoint şeması değişikliği yüzünden saatler harcıyorsunuz. İşte tam bu noktada contract testing devreye giriyor.
Contract Testing Nedir?
Contract testing, iki servis arasındaki iletişim sözleşmesini test eden bir yaklaşım. Buradaki “sözleşme” kavramı kritik: consumer (tüketen servis) ve provider (sağlayan servis) arasındaki beklentiler yazılı hale getiriliyor ve her iki taraf da bu sözleşmeye uygun davranıp davranmadığını bağımsız olarak test edebiliyor.
Klasik entegrasyon testinden farkı şu: entegrasyon testinde her iki servisin aynı anda çalışıyor olması gerekiyor. Contract testing’de ise consumer kendi beklentilerini tanımlıyor, provider bu beklentileri karşılayıp karşılamadığını kendi ortamında doğruluyor. Birbirlerine bağımlı olmadan, CI/CD pipeline’larında ayrı ayrı çalışabiliyorlar.
Consumer-Driven Contract Testing bu yaklaşımın en yaygın versiyonu. Adından da anlaşılacağı gibi sözleşmeyi consumer taraf yazıyor. Mantığı basit: provider’ın ne sağlaması gerektiğine en iyi bilen consumer’dır, çünkü o consumer kendi iş gereksinimlerine göre ne kullandığını biliyor.
Pact Neden Öne Çıktı?
Pact, contract testing için fiilen standart haline gelmiş bir framework. Ruby’de başlamış, zamanla Java, JavaScript, Go, Python, .NET ve daha birçok dile yayılmış. Pact Broker denen merkezi bir bileşenle sözleşmeleri saklayıp yönetebiliyorsunuz.
Rakiplerine kıyasla birkaç avantajı var:
- Olgun ekosistem: Çok dilli destek sayesinde polyglot mimarilerde sorun çıkmıyor
- Pact Broker: Sözleşmeleri versiyonlayıp takip edebileceğiniz merkezi bir hub
- Can-I-Deploy: “Bu versiyonu deploy edebilir miyim?” sorusunu yanıtlayan araç
- Pendingpacts ve WIP pacts: Yeni sözleşmelerin provider tarafını bloke etmemesi için akıllı mekanizmalar
Şimdi gerçek bir senaryo üzerinden gidelim.
Gerçek Dünya Senaryosu: E-Ticaret Platformu
Diyelim ki bir e-ticaret platformu üzerinde çalışıyorsunuz. order-service siparişleri yönetiyor, product-service ürün bilgilerini sağlıyor. order-service her sipariş oluşturulduğunda product-service‘ten ürün detaylarını çekiyor.
Bu iki servis farklı ekiplerin elinde. Product ekibi API’lerini geliştirirken order ekibinin ne beklediğini tam olarak bilmiyor, ya da biliyor ama gözden kaçırıyor. Klasik sorun.
Consumer Tarafı: Order Service
İlk olarak order-service tarafında Pact kurulumunu yapalım. Node.js kullanıyoruz.
npm install --save-dev @pact-foundation/pact
Şimdi consumer testini yazalım:
// order-service/tests/product.pact.spec.js
const { Pact } = require('@pact-foundation/pact');
const { like, term } = require('@pact-foundation/pact').Matchers;
const ProductClient = require('../../src/clients/productClient');
const path = require('path');
const provider = new Pact({
consumer: 'OrderService',
provider: 'ProductService',
port: 1234,
log: path.resolve(process.cwd(), 'logs', 'pact.log'),
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'warn',
});
describe('ProductService Pact', () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
afterEach(() => provider.verify());
describe('Ürün detayı alma', () => {
beforeEach(() => {
return provider.addInteraction({
state: 'product with ID 42 exists',
uponReceiving: 'a request for product details',
withRequest: {
method: 'GET',
path: '/products/42',
headers: {
Accept: 'application/json',
},
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: {
id: like(42),
name: like('Laptop'),
price: like(1500.00),
stock: like(10),
},
},
});
});
it('ürün bilgilerini doğru şekilde parse etmeli', async () => {
const client = new ProductClient('http://localhost:1234');
const product = await client.getProduct(42);
expect(product.id).toBeDefined();
expect(product.name).toBeDefined();
expect(product.price).toBeDefined();
});
});
});
Bu test çalıştığında Pact, pacts/ klasörüne bir JSON dosyası üretiyor. Bu dosya sözleşmenin ta kendisi.
# Testi çalıştır ve pact dosyasını üret
npx jest tests/product.pact.spec.js
# Üretilen pact dosyasını inceleyelim
cat pacts/OrderService-ProductService.json
Pact Dosyasının İçeriği
Üretilen JSON şöyle görünüyor:
{
"consumer": {
"name": "OrderService"
},
"provider": {
"name": "ProductService"
},
"interactions": [
{
"description": "a request for product details",
"providerState": "product with ID 42 exists",
"request": {
"method": "GET",
"path": "/products/42",
"headers": {
"Accept": "application/json"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json; charset=utf-8"
},
"body": {
"id": 42,
"name": "Laptop",
"price": 1500.00,
"stock": 10
}
}
}
],
"metadata": {
"pactSpecification": {
"version": "2.0.0"
}
}
}
Provider Tarafı: Product Service
Şimdi product-service tarafında bu sözleşmeyi doğrulayalım. Java/Spring Boot kullanan bir servis olsun:
<!-- pom.xml -->
<dependency>
<groupId>au.com.dius.pact.provider</groupId>
<artifactId>junit5spring</artifactId>
<version>4.4.0</version>
<scope>test</scope>
</dependency>
// product-service/src/test/java/com/example/ProductPactVerificationTest.java
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Provider("ProductService")
@PactFolder("pacts")
public class ProductPactVerificationTest {
@LocalServerPort
private int port;
@Autowired
private ProductRepository productRepository;
@BeforeEach
void setUp(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context) {
context.verifyInteraction();
}
@State("product with ID 42 exists")
void productWithId42Exists() {
// Test state'ini hazırla
Product product = new Product();
product.setId(42L);
product.setName("Laptop");
product.setPrice(new BigDecimal("1500.00"));
product.setStock(10);
productRepository.save(product);
}
}
Provider verification çalıştırıldığında Pact, consumer’ın belirlediği sözleşmeye göre gerçek servisi test ediyor. @State annotation’ı kritik bir noktayı çözüyor: consumer “ID 42’li ürün var” state’ini tanımlamış, provider bu state’i gerçeğe yakın bir şekilde hazırlıyor.
# Provider testini çalıştır
mvn test -Dtest=ProductPactVerificationTest
# Başarılı çıktı şöyle görünmeli:
# Verifying a pact between OrderService and ProductService
# Given product with ID 42 exists
# a request for product details
# returns a response which
# has status code 200 (OK)
# has a matching body (OK)
Pact Broker Kurulumu
Pact dosyalarını git reposunda saklamak kısa vadede işe yarıyor ama scale olmuyor. Pact Broker bu sorunu çözüyor. Docker ile hızlıca ayağa kaldıralım:
# docker-compose.yml
version: '3'
services:
postgres:
image: postgres:14
environment:
POSTGRES_USER: pact
POSTGRES_PASSWORD: pact
POSTGRES_DB: pact
volumes:
- postgres-data:/var/lib/postgresql/data
pact-broker:
image: pactfoundation/pact-broker:latest
ports:
- "9292:9292"
environment:
PACT_BROKER_DATABASE_URL: "postgres://pact:pact@postgres/pact"
PACT_BROKER_BASIC_AUTH_USERNAME: admin
PACT_BROKER_BASIC_AUTH_PASSWORD: gizliSifre123
PACT_BROKER_ALLOW_PUBLIC_READ: "true"
depends_on:
- postgres
volumes:
postgres-data:
docker-compose up -d
# Broker çalışıyor mu kontrol et
curl http://localhost:9292/diagnostic/status
# Pact dosyasını broker'a yükle
npx pact-broker publish ./pacts
--broker-base-url http://localhost:9292
--broker-username admin
--broker-password gizliSifre123
--consumer-app-version $(git rev-parse HEAD)
--tag main
Can-I-Deploy: Deploy Kararını Otomatikleştirmek
Bu özellik Pact’in en değerli araçlarından biri. Bir servisi deploy etmeden önce, deploy edeceğiniz versiyonun production’daki diğer servislerle uyumlu olup olmadığını sorguluyor.
# Order service'in bu versiyonunu production'a deploy edebilir miyiz?
npx pact-broker can-i-deploy
--pacticipant OrderService
--version $(git rev-parse HEAD)
--to-environment production
--broker-base-url http://localhost:9292
--broker-username admin
--broker-password gizliSifre123
Eğer provider verification başarısız olduysa veya henüz çalıştırılmadıysa bu komut hata döndürüyor ve deployment’ı bloke ediyor. CI/CD pipeline’ınıza şöyle entegre edebilirsiniz:
#!/bin/bash
# deploy.sh
VERSION=$(git rev-parse HEAD)
SERVICE_NAME="OrderService"
echo "Deployment öncesi contract uyumluluğu kontrol ediliyor..."
npx pact-broker can-i-deploy
--pacticipant $SERVICE_NAME
--version $VERSION
--to-environment production
--broker-base-url $PACT_BROKER_URL
--broker-username $PACT_BROKER_USER
--broker-password $PACT_BROKER_PASS
if [ $? -ne 0 ]; then
echo "HATA: Contract uyumsuzluğu tespit edildi. Deployment iptal edildi."
exit 1
fi
echo "Contract kontrolü geçti, deployment başlıyor..."
kubectl apply -f k8s/order-service.yaml
Gelişmiş Matcher’lar: Sadece Değil, Yapı da Test Edin
Pact’in sunduğu matcher’lar basit eşitlik kontrolünün ötesine geçiyor. Gerçek senaryolarda bunlar çok işe yarıyor:
const { like, term, eachLike, integer, decimal } = require('@pact-foundation/pact').Matchers;
// Sipariş listesi endpoint'i için daha kapsamlı bir sözleşme
provider.addInteraction({
state: 'orders exist for customer 100',
uponReceiving: 'a request for customer orders',
withRequest: {
method: 'GET',
path: term({
generate: '/customers/100/orders',
matcher: '/customers/\d+/orders'
}),
headers: {
Authorization: term({
generate: 'Bearer eyJhbGciOiJSUzI1NiJ9...',
matcher: 'Bearer .+'
})
}
},
willRespondWith: {
status: 200,
body: {
orders: eachLike({
id: integer(1),
status: term({
generate: 'PENDING',
matcher: 'PENDING|CONFIRMED|SHIPPED|DELIVERED|CANCELLED'
}),
totalAmount: decimal(150.00),
items: eachLike({
productId: integer(42),
quantity: integer(2),
unitPrice: decimal(75.00)
})
}),
totalCount: integer(5),
page: integer(1)
}
}
});
Buradaki term matcher’ı regex kullanıyor. eachLike ise dizinin en az bir eleman içerdiğini ve her elemanın verilen şemaya uyduğunu doğruluyor. Bu sayede provider’ın dönüş değerlerindeki gerçek verilerden bağımsız bir sözleşme tanımlıyorsunuz.
Message Pacts: Event-Driven Mimarilerde Contract Testing
Mikroservisler sadece HTTP üzerinden değil, Kafka veya RabbitMQ gibi message broker’lar üzerinden de haberleşiyor. Pact bunu da kapsıyor:
// order-service/tests/orderCreated.message.pact.spec.js
const { MessageConsumerPact, asynchronousBodyHandler } = require('@pact-foundation/pact');
const { like, integer } = require('@pact-foundation/pact').Matchers;
const messagePact = new MessageConsumerPact({
consumer: 'InventoryService',
provider: 'OrderService',
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'warn',
});
describe('OrderCreated mesajı', () => {
it('sipariş oluşturulduğunda doğru formatta mesaj gönderilmeli', () => {
return messagePact
.given('bir sipariş oluşturuldu')
.expectsToReceive('order created event')
.withContent({
orderId: integer(123),
customerId: integer(100),
items: like([
{
productId: integer(42),
quantity: integer(2),
}
]),
createdAt: like('2024-01-15T10:30:00Z'),
})
.withMetadata({
contentType: 'application/json',
})
.verify(
asynchronousBodyHandler(async (message) => {
// Consumer'ın mesajı işleyebildiğini doğrula
const inventoryUpdate = processOrderCreatedEvent(message);
expect(inventoryUpdate.productIds).toHaveLength(1);
expect(inventoryUpdate.productIds[0]).toBe(42);
})
);
});
});
Sık Karşılaşılan Sorunlar ve Çözümleri
Provider state yönetimi karmaşıklaşıyor: Çok sayıda consumer çok sayıda state tanımladığında provider taraftaki setup kodu şişiyor. Bunun için ayrı bir state handler servisi oluşturmak mantıklı. Test ortamında ayağa kalkan küçük bir HTTP handler, Pact’ten gelen state setup isteklerini karşılıyor.
Flaky testler yaşıyorsunuz: Genellikle sebebi async operasyonlar veya shared state’ten kaynaklanıyor. Her test interaction’ı izole tutun, test sonrası database’i temizleyin, afterEach hook’larını ihmal etmeyin.
Sözleşme çok kısıtlayıcı hale geliyor: Consumer bazen fazla spesifik matcher kullanıyor, bu provider’ın esnekliğini kısıtlıyor. Kural şu: consumer sadece gerçekten kullandığı alanları sözleşmeye dahil etmeli. Provider’ın ek alanlar döndürmesi sorunu değil. like() matcher’ı burada çok işe yarıyor, exact match yerine yapı kontrolü yapıyor.
CI/CD entegrasyonunda ordering problemi: Consumer önce pact’i yayımlamak, provider önce verify etmek istiyor. Pact Broker’ın “pending pacts” özelliği bunu çözüyor. Yeni bir pact yayımlandığında provider’ın pipeline’ını bloke etmiyor, pending olarak işaretliyor ve provider onu doğrulamak için zaman kazanıyor.
# Provider verification'da pending pacts desteğini aktifleştir
# pom.xml veya build dosyasında
# enablePending = true
# includeWipPactsSince = "2024-01-01"
Takım İçi Kültür ve Süreç
Contract testing’in teknik kısmı aslında kolay kısmı. Asıl zorluk ekip kültüründe. Birkaç pratik öneri:
Sözleşme değişikliği sürecini tanımlayın. Consumer yeni bir field eklediğinde provider’a haber vermeden pact’i güncelleyip push etmeli mi? Genellikle evet, çünkü consumer-driven yaklaşımın mantığı bu. Ama provider ekibinin de bu değişikliği zamanında görmesi için Pact Broker’ın webhook özelliğini kullanın. Yeni bir pact geldiğinde Slack’e bildirim gönderebilir veya provider’ın CI’ını tetikleyebilirsiniz.
Breaking change’leri erkenden yakalayın. Provider ekibi bir field’ı kaldırmadan önce can-i-deploy çalıştırsın. Eğer herhangi bir consumer o field’ı sözleşmesinde kullanıyorsa deployment bloke olacak.
Pact’i tek test stratejisi olarak görmeyin. Contract testing entegrasyon testinin yerini tutmuyor, tamamlıyor. Consumer-provider iletişiminin şemasını doğruluyor ama business logic’i test etmiyor. End-to-end testlerinizi de küçük ölçekte tutun.
Sonuç
Contract testing, özellikle birden fazla ekibin bağımsız çalıştığı mikroservis mimarilerinde ciddi bir acıyı çözüyor. Servislerin birbirinden bağımsız deploy edilebilmesini sağlarken entegrasyon hatalarını production öncesinde yakalıyorsunuz.
Pact ile başlamak için büyük bir yatırım gerekmiyor. En kritik consumer-provider çiftinizi alın, birkaç temel interaction için consumer testini yazın, provider verification’ı CI’a ekleyin. İlk kontrat yazıldığında ve bir breaking change’i otomatik olarak yakaladığınızda geriye kalan servisleri de kapsama almak için motivasyon kendiliğinden geliyor.
Pact Broker olmadan da başlayabilirsiniz, pact dosyalarını shared bir git reposunda saklayarak. Ama ekip büyüdükçe ve servis sayısı arttıkça Broker’ın getirdiği görünürlük ve can-i-deploy özelliği vazgeçilmez hale geliyor. Özellikle production deployment’larında “acaba bu versiyonu deploy etsem ne olur?” kaygısını ortadan kaldırması tek başına yeterli sebep.
