com.trendyol.stove.testing.e2e.kafka.TestSystemInterceptor.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of stove-spring-testing-e2e-kafka Show documentation
Show all versions of stove-spring-testing-e2e-kafka Show documentation
The easiest way of e2e testing in Kotlin
package com.trendyol.stove.testing.e2e.kafka
import arrow.core.Option
import arrow.core.firstOrNone
import arrow.core.getOrElse
import arrow.core.toOption
import com.fasterxml.jackson.databind.ObjectMapper
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.apache.kafka.clients.consumer.Consumer
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.clients.producer.ProducerRecord
import org.apache.kafka.clients.producer.RecordMetadata
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.kafka.listener.CompositeRecordInterceptor
import org.springframework.kafka.listener.ListenerExecutionFailedException
import org.springframework.kafka.support.ProducerListener
import org.springframework.stereotype.Component
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
import kotlin.reflect.KClass
import kotlin.time.Duration
@Component
class TestSystemKafkaInterceptor(private val objectMapper: ObjectMapper) :
CompositeRecordInterceptor(),
ProducerListener {
data class Failure(
val message: Any,
val reason: Throwable
)
private val logger: Logger = LoggerFactory.getLogger(javaClass)
private val consumedRecords: ConcurrentMap> = ConcurrentHashMap()
private val producedRecords: ConcurrentMap> = ConcurrentHashMap()
private val exceptions: ConcurrentMap = ConcurrentHashMap()
override fun success(
record: ConsumerRecord,
consumer: Consumer
): Unit = runBlocking {
consumedRecords.putIfAbsent(UUID.randomUUID(), record)
logger.info(
"""SUCCESSFULLY CONSUMED:
Consumer: ${consumer.groupMetadata().memberId()}
Topic: ${record.topic()}
Record: ${record.value()}
Key: ${record.key()}
Headers: ${record.headers().map { Pair(it.key(), String(it.value())) }}
TestCase: ${record.headers().firstOrNone { it.key() == "testCase" }.map { String(it.value()) }.getOrElse { "" }}
""".trimIndent()
)
}
override fun onSuccess(
record: ProducerRecord,
recordMetadata: RecordMetadata
): Unit = runBlocking {
producedRecords.putIfAbsent(UUID.randomUUID(), record)
logger.info(
"""SUCCESSFULLY PUBLISHED:
Topic: ${record.topic()}
Record: ${record.value()}
Key: ${record.key()}
Headers: ${record.headers().map { Pair(it.key(), String(it.value())) }}
TestCase: ${record.headers().firstOrNone { it.key() == "testCase" }.map { String(it.value()) }.getOrElse { "" }}
""".trimIndent()
)
}
override fun onError(
record: ProducerRecord,
recordMetadata: RecordMetadata?,
exception: Exception
): Unit = runBlocking {
exceptions.putIfAbsent(UUID.randomUUID(), Failure(record.value(), extractCause(exception)))
logger.error(
"""PRODUCER GOT AN ERROR:
Topic: ${record.topic()}
Record: ${record.value()}
Key: ${record.key()}
Headers: ${record.headers().map { Pair(it.key(), String(it.value())) }}
TestCase: ${record.headers().firstOrNone { it.key() == "testCase" }.map { String(it.value()) }.getOrElse { "" }}
Exception: $exception
""".trimIndent()
)
}
override fun failure(
record: ConsumerRecord,
exception: Exception,
consumer: Consumer
): Unit = runBlocking {
exceptions.putIfAbsent(UUID.randomUUID(), Failure(record.value(), extractCause(exception)))
logger.error(
"""CONSUMER GOT AN ERROR:
Topic: ${record.topic()}
Record: ${record.value()}
Key: ${record.key()}
Headers: ${record.headers().map { Pair(it.key(), String(it.value())) }}
TestCase: ${record.headers().firstOrNone { it.key() == "testCase" }.map { String(it.value()) }.getOrElse { "" }}
Exception: $exception
""".trimIndent()
)
}
suspend fun waitUntilConsumed(
atLeastIn: Duration,
clazz: KClass,
condition: (Option) -> Boolean
) {
val getRecords = { consumedRecords.map { it.value.value() } }
getRecords.waitUntilConditionMet(atLeastIn, "While CONSUMING ${clazz.java.simpleName}") {
val outcome = readCatching(it, clazz)
outcome.isSuccess && condition(outcome.getOrNull().toOption())
}
throwIfFailed(clazz, condition)
}
suspend fun waitUntilFailed(
atLeastIn: Duration,
clazz: KClass,
condition: (Option, Throwable) -> Boolean
) {
val getRecords = { exceptions.map { Pair(it.value.message.toString(), it.value.reason) } }
getRecords.waitUntilConditionMet(atLeastIn, "While WAITING FOR FAILURE ${clazz.java.simpleName}") {
val outcome = readCatching(it.first, clazz)
outcome.isSuccess && condition(outcome.getOrNull().toOption(), it.second)
}
throwIfSucceeded(clazz, condition)
}
private fun extractCause(listenerException: Throwable): Throwable = when (listenerException) {
is ListenerExecutionFailedException ->
listenerException.cause
?: AssertionError("No cause found: Listener was not able to capture the cause")
else -> listenerException
}
suspend fun waitUntilPublished(
atLeastIn: Duration,
clazz: KClass,
condition: (Option) -> Boolean
) {
val getRecords = { producedRecords.map { it.value.value() } }
getRecords.waitUntilConditionMet(atLeastIn, "While PUBLISHING ${clazz.java.simpleName}") {
val outcome = readCatching(it.toString(), clazz)
outcome.isSuccess && condition(outcome.getOrNull().toOption())
}
throwIfFailed(clazz, condition)
}
private fun readCatching(
json: String,
clazz: KClass
) = runCatching { objectMapper.readValue(json, clazz.java) }
fun reset() {
exceptions.clear()
producedRecords.clear()
consumedRecords.clear()
}
private fun throwIfFailed(
clazz: KClass,
selector: (Option) -> Boolean
) = exceptions
.filter { selector(readCatching(it.value.message.toString(), clazz).getOrNull().toOption()) }
.forEach { throw it.value.reason }
private fun throwIfSucceeded(
clazz: KClass,
selector: (Option, Throwable) -> Boolean
): Unit = consumedRecords
.filter { selector(readCatching(it.value.value(), clazz).getOrNull().toOption(), getExceptionFor(selector, clazz)) }
.forEach { throw AssertionError("Expected to fail but succeeded: $it") }
private fun getExceptionFor(
selector: (Option, Throwable) -> Boolean,
clazz: KClass
): Throwable = exceptions
.map { Pair(it.value.message.toString(), it.value.reason) }
.first { selector(readCatching(it.first, clazz).getOrNull().toOption(), it.second) }
.second
private suspend fun (() -> Collection).waitUntilConditionMet(
duration: Duration,
subject: String,
condition: (T) -> Boolean
): Collection = runCatching {
val collectionFunc = this
withTimeout(duration) { while (!collectionFunc().any { condition(it) }) delay(50) }
return collectionFunc().filter { condition(it) }
}.recoverCatching {
when (it) {
is TimeoutCancellationException -> throw AssertionError("GOT A TIMEOUT: $subject. ${dumpMessages()}")
is ConcurrentModificationException ->
Result.success(waitUntilConditionMet(duration, subject, condition))
else -> throw it
}.getOrThrow()
}.getOrThrow()
private fun dumpMessages(): String = """Messages in the KafkaSystem so far:
PUBLISHED MESSAGES:
${producedRecords.map { it.value.value() }.joinToString("\n")}
------------------------
CONSUMED MESSAGES:
${consumedRecords.map { it.value.value() }.joinToString("\n")}
""".trimIndent()
}