Kontrat Testi ve Spring Cloud Contract ile Kafka Kontrat Testleri

İçindekiler
- Giriş
- Kontrat Testi Nedir?
- Proje Senaryosu
- Üretici Mikroservisin Test Edilmesi
- Tüketici Mikroservisin Test Edilmesi
- Üretici Mikroserviste Stub Jar’larının Oluşturulması
- Stub Jar’larının Maven Deposuna Yayınlanması
- Tüketici Mikroservise Bağımlılıkların Eklenmesi
- MessageVerifierSender Arayüzü Gerçekleştirmesi
- BaseKafkaContractConsumerTest Sınıfının Yazılması
- Tüketici Kontrat Testi Konfigürasyonu
- Tüketici Testlerinin Tamamlanması
- Sonuç
- Kaynakça
Giriş
Bu çalışmada Kontrat (Contract) testinin ne olduğuna, neden ihtiyaç duyulduğuna, farklı test türlerinin kontrat testin yerini neden tutamadığına, Kafka kontrat testlerinin genel yapısına değinilmiş ve Spring Cloud Contract ile Kafka kontrat testlerini nasıl yazabileceğimiz; örnek bir proje üzerinden birebir gerçekleştirilerek anlatılmıştır.
Kontrat Testi Nedir?
Kontrat testi, birbiriyle iletişim kuran herhangi iki mikroservis için iletişim sırasında kullanılan Veri Transfer Objesi (Data Transfer Object - DTO)’nin her iki mikroserviste de geçerli olduğunu doğrulayan mikroservis bazlı testlerdir.
Kontrat; mikroservislerin birbirleriyle iletişim için kullandıkları DTO’ların ve iletişime dair bazı detay bilgilerin bulunduğu, mikroservislerin sadık kalması gereken sözleşmedir.
Bu iletişim HTTP (HyperText Transfer Protocol) istekleriyle Kafka’yla, Redis’le veya farklı yollarla gerçekleşiyor olabilir ve her türlü iletişim için kontrat testi yazmak mümkündür. Kontrat testleri farklı iletişim yolları için farklı şekilde gerçekleştirilse de altında yatan mantık aynıdır. Bu yazıda, kontrat testlerinin genel yapısını anladıktan sonra biraz daha karmaşık olması nedeniyle Kafka için kontrat testlerini Spring uygulamalarında nasıl yazabileceğimizi göreceğiz.
Neden İhtiyaç Duyulur?
İki mikroservisin iletişimi sırasında kullanılan DTO her iki mikroserviste de tanımlı olmak zorundadır. DTO’nun; mikroservislerin herhangi birinde diğerinden habersizce değişmesi, diğer mikroserviste hataya sebep olacaktır ve kontrat testleri olmadan bu durum mikroservis bazlı testlerle (integration veya unit) yakalanamaz.
Kontrat testler olmadan bu tarz hataları yakalamanın tek yolu, her iki mikroservisin de ayakta olduğu ve iletişimin tetiklendiği bir test ortamıdır ki bu da aslında Uçtan Uca (End to End - e2e) Test’lerine tekabül etmektedir. Fakat biz bu tarz bir yapıyı e2e testleriyle test etmek istemeyiz.
Çünkü:
- E2e testlerinin çalıştırması sistemdeki tüm bileşenlerin ayakta olmasını gerektirdiği için zahmetlidir,
- E2e testleri sırasında ilgili iletişimin tetiklendiği [mesajın (event’in) yayınlandığı veya servisler arası isteğin atıldığı] bir senaryo mevcut olmayabilir. E2e testler zaman zaman rastlantısal olarak hatalı çalışabilir (tutarsızlık barındırır),
- E2e testlerde hata ayıklamak zordur: Mesaj tüketen (consume) bir mikroserviste alınan bir deserialization (herhangi bir veri tipinde veri bulunduran bir byte array’i uygulama içi objeye dönüştürme işlemi) hatası; Üretici (Producer) mikroservisinden hatalı bir mesajın yayınlanıyor olmasından, Kafka’yla ilgili bir konfigürasyondan veya tüketici mikroservisindeki bir hatadan kaynaklanıyor olabilir.
Kontrat testleri iki mikroservis için de geçerli olarak kabul edilmiş, “üzerinde anlaşılmış” DTO’ya, yani kontrata sadık kalınıp kalınmadığını mikroservis bazlı testlerle anlamamızı sağlamaktadır.
Örnek bir proje ile Spring uygulamalarında Spring Cloud Contract’ı kullanarak Kafka kontrat testlerini nasıl yazabileceğimize bakalım.
Proje Senaryosu
Bir monorepo içerisinde multimodül bir gradle projesi oluşturulur. Her modül bir mikroservisi temsil etmektedir.
Senaryoda ReturnOrder’ı; iade taleplerini kabul eden ve stokları yeniden ayarlamak gibi görevlerden sorumlu mikroservis, Payment’ı ise ücret iadesini yapan mikroservis olarak düşünelim.
ReturnOrder mikroservisi Kafka aracılığıyla bir ReturnOrderEvent yayınlayarak payment mikroservisini bir iade işleminin gerçekleştiğinden haberdar eder. Payment mikroservisi ise ilgili iade işlemi için ücret iadesini gerçekleştirir.
Yazı genelinde;
- “üretici mikroservis” ifadesiyle ReturnOrder mikroservisi
- “tüketici mikroservis” ifadesiyle Payment mikroservisi
kastedilmiştir.
Yerel (Local) ortamda çalışması için bir docker-compose dosyası oluşturup içerisinde Kafka imajını belirtelim.
Proje hiyerarşisi ve docker-compose dosyası ekteki gibi görünecektir.


Üretici Mikroservisin Düzenlenmesi
ReturnOrderEvent ve bu sınıfı Kafka’ya yayınlamak için gerekli servis eklenir. Temel bir üretici konfigürasyonu yeterli olacaktır.
Test konfigürasyonu sırasında tüm Kafka topic’lerini (Kafka’da mesajların gruplandığı kanal) tek bir @KafkaListener
ile işaretli metottan dinlemeyi olanaklı kılabilmek adına; bu mikroserviste, Kafka topic’i oluşturan tüm servislerin AbstractProducer arayüzünü gerçekleştirmesi sağlanır.
1
public abstract class AbstractProducer { public abstract String getTopic();}
ReturnOrderEvent sınıfı oluşturulur.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ReturnOrderEvent implements Serializable {
@Serial
private static final long serialVersionUID = 1587253L;
private UUID orderId;
private UUID productId;
private String productName;
private BigDecimal price;
}
Üretici ve tüketici rolündeki mikroservislerin testleri farklı olacağından eklenmesi gereken bağımlılıklar ve test yapıları da farklıdır.
Üretici Mikroservisin Test Edilmesi

Şekil 1. Üretici Testleri için Akış Diyagramı
- Üretici mikroserviste kontrat tanımlanır. Kontrat; içerisinde, yayınlanacak olan mesajın uyması gereken kuralları barındırır.
- Mikroservisten mesaj yayınlamak için bir metot tanımlanır ve test sınıfı içerisinde erişilebilir hale getirilir. Test içerisinde bu metot ile bir mesaj Kafka’ya yollanır.
- Spring Cloud Contract’tan gelen MessageVerifierReceiver arayüzü gerçekleştirilir. İçerisinde; Spring Cloud Contract’ın Kafka’dan mesaj okuması için gerekli yapıyı barındırır.
- Spring Cloud Contract; MessageVerifierReceiver aracılığıyla okuduğu mesajın, kontratta belirtilmiş olan kurallara uyup uymadığını kontrol ederek testin başarılı veya başarısız sonuçlanmasını sağlar.
Bağımlılıkların Eklenmesi ve Konfigürasyon
Üretici mikroservisinde Spring Cloud Contract eklentisine ve Contract Verifier bağımlılığına ihtiyaç vardır.
1
2
3
4
5
plugins { id "org.springframework.cloud.contract" }
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-contract-verifier'
}
Bu eklenti (plugin) aynı zamanda kontratların tanımlanması için gerekli altyapıyı sağlamaktadır. Burada varsayılan olarak kontratları "src/contractTest/resources"
altında tanımlamamız beklenmektedir. "/src"
dizini altında yeni bir kaynak dizini oluşturmak istediğimizde bize contractTest
kaynak dizinini tanımlı bir gradle kaynak dizini olarak gösterir.

Daha temiz bir dosya hiyerarşisi için farklı bir konfigürasyona gidilebilir. Örnek projede kontratların "integration/resources/contracts"
dizini altında tanımlanması istendiğinden "build.gradle"
dosyasına ilgili konfigürasyon eklenir.
1
contracts { contractsDslDir = file("src/integration/resources/contracts") }
Kafka Mesajı için Kontrat Tanımlanması
Spring Cloud Contract; kontrat tanımı için JSON, Groovy, Yaml gibi birden fazla DSL (Domain Specific Language) alternatifi sunmaktadır, örnek projede groovy tercih edilmiştir. Kontratı Groovy ile tanımlayabilmek için Spec Groovy
bağımlılığının eklenmesi gerekmektedir.
1
2
3
dependencies {
testImplementation 'org.springframework.cloud:spring-cloud-contract-spec-groovy:4.0.0'
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package contracts
import org.springframework.cloud.contract.spec.Contract;
Contract.make {
description "Should publish ReturnOrderEvent into 'return-order' topic"
label 'returnOrderEvent'
input {
triggeredBy('publishReturnOrderEvent()')
}
outputMessage {
sentTo('return-order')
body([
orderId : $(consumer(regex(uuid())), producer(anyUuid())),
productId : $(consumer(regex(uuid())), producer(anyUuid())),
productName : $(consumer(regex('[A-Za-z0-9]{10,20}')), producer(regex('[A-Za-z0-9]{10,20}'))),
price : $(consumer(regex('[0-9]+(\\.[0-9]{1,2})?')), producer(anyNumber()))
])
}
}
- description: Geliştiricilere bırakılan bir not gibi düşünülebilir, doğrudan kullanılmamaktadır.
- label: Tüketici Odaklı Kontrat Testi (Consumer Driven Contract Test - CDC) sırasında StubTrigger ile Kafka topic’ine bir mesaj yayınlamak için kullanılır. Üretici kontrat testlerini değil tüketici kontrat testlerini ilgilendirmektedir. İlgili bölümde detaylı değinilmiştir.
- triggeredBy: Kontratta belirtilen Kafka topic’ine mesaj yayınlamak için çalıştırılması gereken metodu belirtir. Burada belirtilmiş olan metodun test sınıfı içerisinde tanımlı ve test içerisinde erişilebilir olması gerekmektedir.
- sentTo: Mesajın yayınlanacağı Kafka topic’inin ismini belirtir.
- body: Bu alan iki mikroservisi de ilgilendirmektedir. Mikroservislerin üzerinde anlaştıkları DTO için geçerli kabul edilmiş kuralları belirtir.
- consumer(): Üretici testleri sırasında üretici mikroservisinin yayınladığı mesajın uyması beklenilen sınırları tanımlar. Bu sınırlar regexp ile (Kurallı İfade - Regular Expression) veya bize sunulmuş olan diğer metotlar ile [anyUuid(), anyNumber()] tanımlanır.
- producer(): Tüketici testleri sırasında Spring Cloud Contract tarafından yayınlanacak olan mesajın alanlarının sınırlarını belirtir. Bu alanda anyUuid() metodu kullanılmış ise rastgele bir UUID (Universal Unique Identifier) değeri, anyNumber() metodu kullanılmış ise rastgele bir sayı değer; üretilen mesajın ilgili alanında bulunacaktır.
regex(uuid) üretici testleri sırasında üretilen mesajları kontrol ederken herhangi bir UUID’yi geçerli kabul edecektir. anyUUID() tüketici testleri sırasında ilgili alanda rastgele bir UUID değeri barındıran bir mesaj yayınlayacaktır.
productName: $(consumer("Product Name 1"), producer("Product Name 2"))
gibi sabit değerler veya anyNonBlankString(), anyDouble(), anyNumber() ve regex() gibi birçok alternatif bulunmaktadır.
Üretici Testlerinin Oluşturulması
"gradle apps:returnorder:contractTest"
komutu çalıştırıldığında Spring Cloud Contract tanımlanmış olan tüm kontratlar için üretici kontrat testlerini otomatik olarak oluşturacaktır.
Oluşturulan testler "$projectRoot/apps/returnorder/build/generated-test-sources"
dizini altında bulunur.
Kontrat hangi DSL ile tanımlanmış olursa olsun, Spring Cloud Contract arka planda varsayılan olarak kontratların Yaml versiyonlarını üretecek ve bunları kullanacaktır. Kontratın Yaml versiyonu aynı dizinde görülmektedir.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ContractVerifierTest extends BaseKafkaProducerContractTest {
@Inject ContractVerifierMessaging contractVerifierMessaging;
@Inject ContractVerifierObjectMapper contractVerifierObjectMapper;
@Test
public void validate_returnOrderKafkaContract() throws Exception {
// when:
publishReturnOrderEvent();
// then:
ContractVerifierMessage response = contractVerifierMessaging.receive("return-order",
contract(this, "returnOrderKafkaContract.yml"));
assertThat(response).isNotNull();
// and:
DocumentContext parsedJson = JsonPath.parse(contractVerifierObjectMapper.writeValueAsString(response.getPayload()));
assertThatJson(parsedJson).field("['orderId']").matches("[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}");
assertThatJson(parsedJson).field("['productId']").matches("[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}");
assertThatJson(parsedJson).field("['productName']").matches("[A-Za-z0-9]{10,20}");
assertThatJson(parsedJson).field("['price']").matches("-?(\\d*\\.\\d+|\\d+)");
}
}
Oluşan testin içeriği incelendiğinde kontratta triggeredBy() içerisinde belirtilmiş olan ve testler sırasında üretici mikroservisten mesaj yayınlamak için kullanılacak olan metodun çağrıldığı görülebilir. Bu metot test sınıfı içerisinde henüz tanımlı olmadığı için kırmızı yandığı ve tanımsız olarak algılandığı görülebilir.
- Testin içerisinde Yaml formatındaki kontratın okunduğu,
- Kafka topic’inden; üretici mikroservisten yayınlanmış olan mesajın alındığı,
- Mesajın her bir alanının kontratta belirtilen kurallara uyup uymadığının doğrulandığını görebiliriz.
Üretici Kontrat Testi Konfigürasyonu
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@ExtendWith(SpringExtension.class)
@AutoConfigureMessageVerifier
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.NONE,
classes = {KafkaProducerTestConfig.class}
)
@Testcontainers
@Import(ReturnOrderProducer.class)
public abstract class BaseKafkaProducerContractTest {
@Autowired
private ReturnOrderProducer returnOrderProducer;
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(DockerImageName
.parse("confluentinc/cp-kafka:latest"))
.withKraft();
static {
kafka.start();
System.setProperty("spring.kafka.bootstrap-servers", kafka.getBootstrapServers());
}
public void publishReturnOrderEvent() {
var returnOrderEvent = new ReturnOrderEvent(
UUID.randomUUID(), UUID.randomUUID(), "randomproduct1", BigDecimal.TEN
);
returnOrderProducer.publishReturnOrderEvent(returnOrderEvent);
}
}
BaseKafkaProducerContractTest sınıfı oluşturulacak olan testlerin tamamının miras alacağı BaseTestClass’ı olacaktır.
Test sırasında ayağa kalkması için bir Kafka konteyneri tanımlanır. @AutoConfigureMessageVerifier
anotasyonu, ContractVerifierMessaging
ve ContractVerifierObjectMapper
gibi Spring Cloud Contract’tan gelen ve test sırasında ihtiyaç duyulan bazı sınıflardan nesnelerin TestContext’ine (Spring tarafından test için özelleştirilmiş ApplicationContext) dahil edilmesini sağlayacaktır.
Kontratta belirtilmiş olan publishReturnOrderEvent()
metodu üretici mikroservisten ilgili mesajı yayınlama mekanizmasını barındıracak şekilde test sınıfı içerisinde tanımlı hale getirilir.
MessageVerifierReceiver Arayüzü Gerçekleştirmesi
MessageVerifierReceiver
test sırasında Spring Cloud Contract’ın, Kafka’da bulunan mesajları almak için ihtiyaç duyduğu sınıftır ve TestContext’ine bir nesne olarak dahil edilmesi gerekmektedir.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class KafkaMessageVerifier implements MessageVerifierReceiver<Message<?>> {
private final Map<String, BlockingQueue<Message<?>>> broker = new ConcurrentHashMap<>();
@Override
public Message<?> receive(String destination, YamlContract contract) {
return receive(destination, 15, TimeUnit.SECONDS, contract);
}
@Override
@SneakyThrows
public Message<?> receive(String destination, long timeout, TimeUnit timeUnit, YamlContract contract) {
broker.putIfAbsent(destination, new ArrayBlockingQueue<>(10));
BlockingQueue<Message<?>> messageQueue = broker.get(destination);
return messageQueue.poll(timeout, timeUnit);
}
@KafkaListener(topics = "#{@allTopics}", groupId = "random")
public void listen(Message<?> payload, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) {
broker.putIfAbsent(topic, new ArrayBlockingQueue<>(10));
BlockingQueue<Message<?>> messageQueue = broker.get(topic);
messageQueue.add(payload);
}
}
KafkaListener metodu Kafka’yı dinleyerek okuduğu mesajları thread-safe (eşzamanlı erişime uygun) bir Map gerçekleştirmesi olan ConcurrentHashMap içerisinde tutar. Daha sonra belli aralıklarla bu Map’ten verileri okuyarak mesajların Spring Cloud Contract’a iletilmesini sağlamış olur.
Burada her topic için ayrı bir
@KafkaListener
metodu eklemek yerine#@allTopics
ile allTopics isminde bir nesne çağrılmıştır.
AbstractProducer arayüzünün bu amaçla tanımlandığını ve daha sonra kullanılacağı belirtilmişti. Aşağıda KafkaProducerTestConfig sınıfında allTopics ismindeki nesne; AbstractProducer arayüzünü miras alan sınıflardan tüm topic isimlerini dinamik olarak toplayarak bu topic’ler için ortak bir KafkaListener metodu yazmayı olanaklı kılmıştır.
Aşağıda MessageVerifierReceiver ve allTopics nesnelerini oluşturup TestContext’ine dahil eden TestConfig sınıfı görülmektedir.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@TestConfiguration
public class KafkaProducerTestConfig {
private final ApplicationContext applicationContext;
public KafkaProducerTestConfig(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Bean
public String[] allTopics() {
Map<String, AbstractProducer> producerBeans = applicationContext.getBeansOfType(AbstractProducer.class);
return producerBeans.values().stream()
.map(AbstractProducer::getTopic).distinct().toArray(String[]::new);
}
@Bean
KafkaMessageVerifier kafkaTemplateMessageVerifier() {
return new KafkaMessageVerifier();
}
}
Test Konfigürasyonunun Üretici Testlerine Eklenmesi
Üretici Kontrat Testi Konfigürasyonu bölümünden buraya kadar eklenilmiş olan tüm konfigürasyonlar BaseKafkaProducerContractTest
sınıfı miras alınarak TestContext’ine dahil edilebilir.
Testler Spring Cloud Contract tarafından otomatik olarak oluşturulduğu için "build.gradle"
dosyasında yapılan bir konfigürasyonla Spring Cloud Contract’a, oluşturulacak olan tüm testlerin BaseKafkaProducerContractTest
sınıfını miras alması söylenir.
Ayrıca kontrat testlerinin JUnit’i kullanması ekteki gibi sağlanmalıdır.
1
2
3
4
5
6
7
8
contracts {
baseClassForTests = "com.bilge.returnorder.configuration.test.BaseKafkaProducerContractTest"
contractsDslDir = file("src/integration/resources/contracts")
}
tasks.named('contractTest') {
useJUnitPlatform()
}
Üretici Testlerinin Tamamlanması
"gradle apps:returnorder:contractTest"
komutu tekrar çalıştırılarak Spring Cloud Contract’ın testleri oluşturması sağlanır.

Artık oluşan testin BaseKafkaProducerContractTest
sınıfını miras aldığı, böylece publishReturnOrderEvent()
metodunun da tanımlı hale geldiğini görebiliyoruz.
Testi çalıştırarak bir Kafka konteynerinin ayağa kalktığını, ilgili mesajın yayınlandığını ve Spring Cloud Contract tarafından mesajın içeriğinin kontrattaki kurallara bakılarak doğrulandığını görebiliriz.
Böylece tüketici testleri tamamlanmış oldu. Artık ReturnOrder mikroservisinde
ReturnOrderEvent
sınıfında yapılacak herhangi bir değişiklik, eğer kontratta tanımlanmış olan ve geçerli kabul edilen kurallara uymazsa bu durum testler sırasında açığa çıkacak ve bu tip hataların canlıya kaçması engellenmiş olacaktır.
Tüketici Mikroservisin Test Edilmesi

Şekil 2. Tüketici Testleri için Akış Diyagramı
- Üretici mikroservisi, kontratı kullanarak Stub Jar’ını oluşturur. Stub Jar’ı, içerisinde kontratı ve kontratta belirtilen mesajı oluşturmak için gerekli yapıyı barındıran JAR’dır.
- Tüketici mikroserviste Spring Cloud Contract’ın sunduğu MessageVerifierSender arayüzü gerçekleştirilir. Bu iki yapı (Stub Jar’ı ve MessageVerifierSender) birlikte Kafka topic’ine ilgili mesajı yayınlar.
- Üretici mikroservis bir test içerisinde ilgili mesajı Kafka’dan okuyacak ve başarılı şekilde tüketip tüketemediğini doğrulayacak. Tüketici mikroserviste harici bir konfigürasyona ihtiyaç duyulmayacak çünkü uygulama zaten ilgili mesaj tipini tüketme mekanizmasını içerisinde bulunduracaktır.
Böylece; tüketici mikroserviste, okunacak mesajın çevrileceği sınıf olan ReturnOrderEvent’te üretici mikroservisten habersizce bir değişiklik yapılmış olsa dahi, kontrat; mesajları hala üretici mikroservisinin ürettiği şekilde yayınlayacak, kontrat testleri başarısız sonuçlanacak ve üretici mikroservisten habersizce bu sınıfta bir değişiklik yapılamayacağını bize bildirmiş olacak.
Tüketici testi akış diyagramında görülebileceği gibi Stub Jar’ları üretici mikroserviste oluşturuluyor ancak bu Jar’lara tüketici mikroserviste ihtiyaç duyuluyor. Burada üretilen Stub Jar’larınn tüketici mikroservisinden erişilebilir olması için, üretici mikroservisinde oluşturulduktan sonra nexus veya farklı bir maven deposuna atılması gerekir. Tüketici testleri, ancak bu şekilde CI( Sürekli Entegrasyon - Continuous Integration) sürecinin bir parçası haline getirilebilir.
Üretici Mikroserviste Stub Jar’larının Oluşturulması
Üretici mikroserviste "gradle apps:returnorder:verifierStubsJar"
komutu çalıştırılarak Stub Jar’ları oluşturulur. Oluşan Stub Jar’ları "$appRoot/build/libs"
altında returnorder-1.0-SNAPSHOT-stubs.jar
ismiyle görülebilir.

Daha önce değinildiği gibi CDC testlerini, CI sürecinin bir parçası haline getirebilmek için Stub Jar’larının, üretici mikroservisten merkezi bir maven deposuna yayınlanması ve tüketici mikroservisten erişilebilir hale getirilmesi gerekir. Yerel ortamda kullanmak için ise yerel maven deposuna atılabilir.
Stub Jar’larının Maven Deposuna Yayınlanması
Stub Jar’larının yerel maven deposuna yayınlanabilmesi için id 'maven-publish'
eklentisi eklenir ve MavenPublish komutu yapılandırılır.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
publishing {
publications {
mavenJava(MavenPublication) {
groupId = 'com.bilge'
artifactId = 'returnorder'
version = '1.0-SNAPSHOT'
def verifierTask = tasks.named('verifierStubsJar', Jar)
artifact(verifierTask.get())
}
}
repositories {
mavenLocal()
}
}
Konfigürasyonda "groupId, artifactid, version"
birlikte, belirtilen gradle komutunun(verifierStubsJar) çıktısının maven deposuna hangi isimle yayınlanacağını belirtir.
"gradle apps:returnorder:publishMavenJavaPublicationToMavenLocal"
komutu ile Stub Jar’ları maven deposuna atılır. Böylelikle Stub Jar’ı tüketici mikroservisi tarafından kullanılmaya hazır hale gelmiş olur.
Tüketici Mikroservise Bağımlılıkların Eklenmesi
Stub’ları çalıştırabilmek için Spring Cloud Contract Stub Runner bağımlılığının eklenmesi ve MessageVerifierSender arayüzünün gerçekleştirilebilmesi için Contract Verifier bağımlılığının eklenmesi gerekir.
1
2
3
4
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-contract-stub-runner:4.0.0'
implementation 'org.springframework.cloud:spring-cloud-starter-contract-verifier:4.0.0'
}
MessageVerifierSender Arayüzü Gerçekleştirmesi
MessageVerifierSender; Stub Jar’ının ürettiği mesajı, belirtilen Kafka topic’ine yayınlamak için gerekli yapıyı barındıracak olan sınıftır. Burada ihtiyaca göre farklı konfigürasyonlar yapılabilir ancak test özelinde düşünülürse:
- Topic mevcut değilse oluşturulması,
- İhtiyaca göre başlıkların(header) eklenmesi
ve bu halde mesajın yayınlanması yeterli olacaktır. Bunun için bir konfigürasyon sınıfı oluşturulur, KafkaTemplate ve topic oluşturmak icin ihtiyaç duyulan KafkaAdmin dahil edilir, ayrıca MessageVerifierSender arayüzü gerçekleştirilir. MessageVerifierSender gerçekleştirmesi bir nesne olarak TestContext’ine dahil edilir.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class KafkaContractConsumerTestConfig {
@Lazy
@Autowired
private KafkaTemplate<String, Object> kafkaTemplate;
@Autowired
@Lazy
private KafkaAdmin kafkaAdmin;
@Bean
MessageVerifierSender<?> kafkaMessageVerifierSender() {
return new MessageVerifierSender<>() {
@Override
public void send(final Object message, final String destination, @Nullable final YamlContract contract) {
send(message, Map.of(), destination, contract);
}
@SneakyThrows
@Override
public void send(Object payload, Map headers, String destination, @Nullable YamlContract contract) {
kafkaAdmin.createOrModifyTopics(TopicBuilder.name(destination).build());
ProducerRecord<String, Object> producerRecord = new ProducerRecord<>(destination, payload);
kafkaTemplate.send(producerRecord).get();
}
};
}
}
Kafka’ya ileteceğimiz mesajları ihtiyaca göre farklı şekillerde serialize (uygulama içi objeden herhangi bir veri yapısı şeklinde byte array’e dönüştürme işlemi) edebilmek için bir JsonMessageConverter’a ihtiyaç duyulur. Burada serializer ve deserializer değiştirilebilir, ihtiyaca göre ZonedDateTimeSerializer ve benzeri yapılar kullanılabilir. Daha basit mesajlar için sadece mesajın payload (mesajda gönderilmek istenen asıl içeriğin tutulduğu bölüm)’unu dönen bir çevirici (converter) yeterli olacaktır. Aynı konfigürasyon sınıfına dahil edilebilir.
1
2
3
4
5
6
7
8
9
10
@Bean
@Primary
JsonMessageConverter messageConverter() {
return new JsonMessageConverter(){
@Override
protected Object convertPayload(Message<?> message) {
return message.getPayload();
}
};
}
BaseKafkaContractConsumerTest Sınıfının Yazılması
Tüm üretici testlerinin bir BaseKafkaProducerContractTest sınıfını miras alarak test konfigürasyonunu alması gibi burada da tüm tüketici kontrat testlerinin miras alarak kullanabileceği bir BaseTest sınıfına ihtiyaç duyulur. Bu sınıf
- KafkaAutoConfiguration ile varsayılan Kafka konfigürasyonunu dahil etmek,
- MessageVerifierSender ve MessageConverter sınıflarından birer nesne taşıyan KafkaContractConsumerTestConfig sınıfını TestContext’ine dahil etmek,
- Stub’lardan mesaj yayınlamak için kullanilan StubTrigger nesnesini dahil etmek,
- Test sırasında bir Kafka konteyneri ayağa kaldırmak
‘tan sorumludur.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@ExtendWith(SpringExtension.class)
@BootstrapWith(SpringBootTestContextBootstrapper.class)
@ContextConfiguration(classes = {KafkaAutoConfiguration.class, KafkaContractConsumerTestConfig.class})
public abstract class BaseKafkaContractConsumerTest {
@Autowired
protected StubTrigger stubTrigger;
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:latest"))
.withKraft();
static {
kafka.start();
System.setProperty("spring.kafka.bootstrap-servers", kafka.getBootstrapServers());
}
}
Tüketici Kontrat Testi Konfigürasyonu
Her test sınıfında daha önce tanımladığımız BaseKafkaContractConsumerTest sınıfı miras alınarak barındırdığı konfigürasyonların testlere yansıması sağlanır. Ek olarak, test sınıflarında @AutoCongireStubRunner
anotasyonuna ihtiyaç vardır.
@AutoCongireStubRunner
anotasyonu mevcut TestContext’i içerisine, daha önce üretilmiş olan, yerel (local) veya merkezi bir maven deposunda tutulan Stub Jar’larının dahil edilmesini sağlayacaktır.
1
2
3
4
5
6
7
@AutoConfigureStubRunner(
ids = "com.bilge:returnorder:+:stubs:stubs",
repositoryRoot = "file:/path/to/local/m2/repository",
stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
@SpringBootTest(classes = ReturnOrderListener.class)
public class ReturnOrderEventConsumerTest extends BaseKafkaContractConsumerTest {
- ids: Stub’ın bulunduğu maven koordinatlarını belirtir. Burada
+
işareti belirtilen jar için en yüksek versiyonun kullanılacağı anlamına gelir. Bunun yerine sabit bir versiyon da belirtilebilir. - repositoryRoot: Maven deposunun adresini belirtir (StubsMode.Local seçiliyken verilen adresin hatalı olması durumunda ~/.m2/repository değeri varsayılan olarak kullanılmaktadır).
- stubsMode: Uzak (Remote) veya yerel maven deposunun kullanıldığını belirtmektedir.
Tüketici Testlerinin Tamamlanması
Örnek projede Payment mikroservisindeki ReturnOrderListener sınıfı; return-order topic’ini dinleyerek ReturnOrderEvent’lerini tüketen KafkaListener metodunu barındıran sınıftır. Bu metot tükettiği mesajları RefundService’in processReturnOrderAndRefund() metodunu çağırarak işlemektedir.
Öyleyse tüketici testinde Stub ve MessageVerifierSender tarafından Kafka’ya yayınlanmış olan her bir mesaj için processReturnOrderAndRefund() metodunun 1 kez çağrıldığını doğrulamak mesajların başarılı şekilde tüketildiğini gösterecektir.
Gerekli görüldüğü takdirde daha geniş bir TestContext’i ayağa kaldırılarak mesaj tüketildikten sonra veri tabanında da ilgili değişikliklerin görüldüğü doğrulanabilir.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@AutoConfigureStubRunner(
ids = "com.bilge:returnorder:+:stubs:stubs",
repositoryRoot = "file:/path/to/local/m2/repository",
stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
@SpringBootTest(classes = ReturnOrderListener.class)
public class ReturnOrderEventConsumerTest extends BaseKafkaContractConsumerTest {
@MockBean
RefundService refundService;
@Test
void returnOrderEventConsumesSuccessfully() {
stubTrigger.trigger("returnOrderEvent");
verify(refundService, timeout(10000)
.times(1))
.processReturnOrderAndRefund(any());
}
}
Burada stubTrigger; BaseKafkaContractConsumerTest sınıfının TestContext’ine dahil ettiği nesnedir. Kontratta tanımlanan label’ı belirterek, kontrattaki kurallara uygun bir mesajın üretilmesini sağlamaktadır. Stub Jar’ının içerisindeki kontratlarda mevcut olmayan bir label’ın belirtilmesi halinde test hata verecek ve hangi label’ların mevcut olduğunu bize söyleyecektir.
ReturnOrderListener servisinden bir nesne TestContext’ine dahil edilir. RefundService’in ise sadece çağrıldığını doğrulamaya ihtiyaç olduğundan bir MockBean’in dahil edilmesi yeterli olacaktır.
“trigger” metodu ile bir mesajın, test sırasında ayağa kalkan Kafka konteynerinin yayınlanması sağlanır ve mockito’dan faydalanılarak MockBean’den ilgili metodun çağrıldığı doğrulanır.
Böylece tüketici mikroservisinin de üretici tarafından üretilmiş olan ve merkezi bir maven deposunda tutulan kontrata (Stub Jar’ının içerisinde tutulmaktadır) sadık kaldığı doğrulanmış olur.
Üretici mikroservisinden habersizce, ReturnOrderEvent sınıfında yapılan bir değişiklik, tüketici mikroservisi kontrattan üretilen mesajları tüketemez hale getireceğinden; kontrat testlerimiz başarısız sonuçlanacak ve bize üretici mikroservisinden habersizce bu sınıfta bir değişiklik yapamayacağımızı hatırlatmış olacaktır.
Sonuç
Kafka kontrat testleri; mikroservislerin birbirleriyle olan entegrasyonlarını e2e’ler gibi yavaş bir test ortamına ihtiyaç duymadan, modül bazlı yapılarla test etmeye olanak sunar. Bu çalışmada kontrat testinin genel yapısına değinilmiş, Kafka kontrat testlerinin Spring uygulamaları için nasıl gerçekleştirilebileceği örnek bir proje üzerinden detaylı şekilde anlatılmıştır.
Kaynakça
Yazımızın teknik gözden geçirmesi için Özay Duman’a, editör desteği için ise Beyza Şenel’e teşekkür ederiz.