diff --git a/consumer/junit5/src/main/kotlin/au/com/dius/pact/consumer/junit5/AsynchronousMessageContext.kt b/consumer/junit5/src/main/kotlin/au/com/dius/pact/consumer/junit5/AsynchronousMessageContext.kt new file mode 100644 index 000000000..762ef73ed --- /dev/null +++ b/consumer/junit5/src/main/kotlin/au/com/dius/pact/consumer/junit5/AsynchronousMessageContext.kt @@ -0,0 +1,12 @@ +package au.com.dius.pact.consumer.junit5 + +import au.com.dius.pact.core.model.V4Interaction +import org.junit.jupiter.api.extension.TestTemplateInvocationContext + +class AsynchronousMessageContext( + val message: V4Interaction.AsynchronousMessage +): TestTemplateInvocationContext { + override fun getDisplayName(invocationIndex: Int): String { + return message.description + } +} \ No newline at end of file diff --git a/consumer/junit5/src/main/kotlin/au/com/dius/pact/consumer/junit5/PactConsumerTestExt.kt b/consumer/junit5/src/main/kotlin/au/com/dius/pact/consumer/junit5/PactConsumerTestExt.kt index 719b2676f..938980509 100644 --- a/consumer/junit5/src/main/kotlin/au/com/dius/pact/consumer/junit5/PactConsumerTestExt.kt +++ b/consumer/junit5/src/main/kotlin/au/com/dius/pact/consumer/junit5/PactConsumerTestExt.kt @@ -24,23 +24,17 @@ import au.com.dius.pact.core.model.annotations.PactFolder import au.com.dius.pact.core.model.messaging.MessagePact import au.com.dius.pact.core.support.Annotations import au.com.dius.pact.core.support.BuiltToolConfig -import au.com.dius.pact.core.support.Json import au.com.dius.pact.core.support.MetricEvent import au.com.dius.pact.core.support.Metrics import au.com.dius.pact.core.support.expressions.DataType import au.com.dius.pact.core.support.expressions.ExpressionParser import au.com.dius.pact.core.support.isNotEmpty import io.github.oshai.kotlinlogging.KLogging +import org.apache.hc.core5.util.ReflectionUtils import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.extension.AfterAllCallback -import org.junit.jupiter.api.extension.AfterTestExecutionCallback -import org.junit.jupiter.api.extension.BeforeAllCallback -import org.junit.jupiter.api.extension.BeforeTestExecutionCallback -import org.junit.jupiter.api.extension.Extension -import org.junit.jupiter.api.extension.ExtensionContext -import org.junit.jupiter.api.extension.ParameterContext -import org.junit.jupiter.api.extension.ParameterResolver +import org.junit.jupiter.api.TestTemplate +import org.junit.jupiter.api.extension.* import org.junit.platform.commons.support.AnnotationSupport import org.junit.platform.commons.support.HierarchyTraversalMode import org.junit.platform.commons.support.ReflectionSupport @@ -48,10 +42,11 @@ import org.junit.platform.commons.util.AnnotationUtils.isAnnotated import java.lang.reflect.Method import java.util.Optional import java.util.concurrent.ConcurrentHashMap +import java.util.stream.Stream import kotlin.reflect.full.findAnnotation class PactConsumerTestExt : Extension, BeforeTestExecutionCallback, BeforeAllCallback, ParameterResolver, - AfterTestExecutionCallback, AfterAllCallback { + AfterTestExecutionCallback, AfterAllCallback, TestTemplateInvocationContextProvider { private val ep: ExpressionParser = ExpressionParser() @@ -103,6 +98,21 @@ class PactConsumerTestExt : Extension, BeforeTestExecutionCallback, BeforeAllCal return false } + override fun supportsTestTemplate(extensionContext: ExtensionContext): Boolean { + val testTemplate = extensionContext + .testClass.get() + .methods + .find { AnnotationSupport.isAnnotated(it, TestTemplate::class.java) } + + return testTemplate != null && testTemplate.parameters[0].type == AsynchronousMessageContext::class.java + } + + override fun provideTestTemplateInvocationContexts(extensionContext: ExtensionContext): Stream { + val providerInfo = this.lookupProviderInfo(extensionContext) + val pact = setupPactForTest(providerInfo[0].first, providerInfo[0].second, extensionContext) + return pact.asV4Pact().unwrap().interactions.map { AsynchronousMessageContext(it.asAsynchronousMessage()!!) }.stream() as Stream + } + override fun resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Any { val type = parameterContext.parameter.type val providers = lookupProviderInfo(extensionContext) @@ -259,36 +269,38 @@ class PactConsumerTestExt : Extension, BeforeTestExecutionCallback, BeforeAllCal ): BasePact { val store = context.getStore(NAMESPACE) val key = "pact:${providerInfo.providerName}" + var methods = pactMethods + if (methods.isEmpty()) { + methods = AnnotationSupport.findAnnotatedMethods(context.requiredTestClass, Pact::class.java, HierarchyTraversalMode.TOP_DOWN) + .map { m -> m.name} + } + return when { store[key] != null -> store[key] as BasePact else -> { - val pact = if (pactMethods.isEmpty()) { - lookupPact(providerInfo, "", context) - } else { - val head = pactMethods.first() - val tail = pactMethods.drop(1) - val initial = lookupPact(providerInfo, head, context) - tail.fold(initial) { acc, method -> - val pact = lookupPact(providerInfo, method, context) - - if (pact.provider != acc.provider) { - // Should not really get here, as the Pacts should have been sorted by provider - throw IllegalArgumentException("You are using different Pacts with different providers for the same test" + - " ('${acc.provider}') and '${pact.provider}'). A separate test (and ideally a separate test class)" + - " should be used for each provider.") - } + val head = methods.first() + val tail = methods.drop(1) + val initial = lookupPact(providerInfo, head, context) + val pact = tail.fold(initial) { acc, method -> + val pact = lookupPact(providerInfo, method, context) + + if (pact.provider != acc.provider) { + // Should not really get here, as the Pacts should have been sorted by provider + throw IllegalArgumentException("You are using different Pacts with different providers for the same test" + + " ('${acc.provider}') and '${pact.provider}'). A separate test (and ideally a separate test class)" + + " should be used for each provider.") + } - if (pact.consumer != acc.consumer) { - logger.warn { - "WARNING: You are using different Pacts with different consumers for the same test " + - "('${acc.consumer}') and '${pact.consumer}'). The second consumer will be ignored and dropped from " + - "the Pact and the interactions merged. If this is not your intention, you need to create a " + - "separate test for each consumer." - } + if (pact.consumer != acc.consumer) { + logger.warn { + "WARNING: You are using different Pacts with different consumers for the same test " + + "('${acc.consumer}') and '${pact.consumer}'). The second consumer will be ignored and dropped from " + + "the Pact and the interactions merged. If this is not your intention, you need to create a " + + "separate test for each consumer." } - - acc.mergeInteractions(pact.interactions) as BasePact } + + acc.mergeInteractions(pact.interactions) as BasePact } store.put(key, pact) pact @@ -541,7 +553,7 @@ class PactConsumerTestExt : Extension, BeforeTestExecutionCallback, BeforeAllCal ProviderType.ASYNCH -> { if (method.parameterTypes[0].isAssignableFrom(Class.forName("au.com.dius.pact.consumer.MessagePactBuilder"))) { ReflectionSupport.invokeMethod( - method, context.requiredTestInstance, + method, context.testInstance, MessagePactBuilder(providerInfo.pactVersion ?: PactSpecVersion.V3) .consumer(pactConsumer).hasPactWith(providerNameToUse) ) as BasePact @@ -550,7 +562,7 @@ class PactConsumerTestExt : Extension, BeforeTestExecutionCallback, BeforeAllCal if (providerInfo.pactVersion != null) { pactBuilder.pactSpecVersion(providerInfo.pactVersion) } - ReflectionSupport.invokeMethod(method, context.requiredTestInstance, pactBuilder) as BasePact + ReflectionSupport.invokeMethod(method, context.testInstance, pactBuilder) as BasePact } } ProviderType.SYNCH_MESSAGE -> { diff --git a/consumer/junit5/src/test/kotlin/au/com/dius/pact/consumer/junit5/TestTemplateTest.kt b/consumer/junit5/src/test/kotlin/au/com/dius/pact/consumer/junit5/TestTemplateTest.kt new file mode 100644 index 000000000..e25a048c8 --- /dev/null +++ b/consumer/junit5/src/test/kotlin/au/com/dius/pact/consumer/junit5/TestTemplateTest.kt @@ -0,0 +1,85 @@ +package au.com.dius.pact.consumer.junit5 + +import au.com.dius.pact.consumer.dsl.LambdaDsl.newJsonBody +import au.com.dius.pact.consumer.dsl.PactBuilder +import au.com.dius.pact.core.model.PactSpecVersion +import au.com.dius.pact.core.model.V4Interaction +import au.com.dius.pact.core.model.V4Pact +import au.com.dius.pact.core.model.annotations.Pact +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.notNullValue +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.TestTemplate +import org.junit.jupiter.api.extension.ExtendWith +import java.lang.IllegalStateException + +@ExtendWith(value = [PactConsumerTestExt::class]) +@PactTestFor(providerName = "checkout-service", providerType = ProviderType.ASYNCH, pactVersion = PactSpecVersion.V4) +class TestTemplateTest { + companion object { + private var reservationRan = false + private var cancellationRan = false + + private val reservationBody = newJsonBody { o -> + o.stringType("purchaseId", "111") + o.stringType("name", "PURCHASE_STARTED") + o.eachLike("products", 1) { items -> + items.stringType("productID", "1") + items.stringType("productType", "FLIGHT") + items.stringType("availabilityId", "28e80c5987c6a242516ccdc004235b5e") + } + }.build() + + private val cancelationBody = newJsonBody { o -> + o.stringType("purchaseId", "111") + o.stringType("reason", "user canceled") + }.build() + + @JvmStatic + @Pact(consumer = "reservation-service", provider = "checkout-service") + fun pactForReservationBooking(builder: PactBuilder): V4Pact { + return builder + .usingLegacyMessageDsl() + .hasPactWith("checkout-service") + .expectsToReceive("a purchase started message to book a reservation") + .withContent(reservationBody) + .toPact() + } + + @JvmStatic + @Pact(consumer = "reservation-service", provider = "checkout-service") + fun pactForCancellationBooking(builder: PactBuilder): V4Pact { + return builder + .usingLegacyMessageDsl() + .hasPactWith("checkout-service") + .expectsToReceive("a cancellation message to cancel a reservation") + .withContent(cancelationBody) + .toPact() + } + + @JvmStatic + @AfterAll + fun makeSureAllRan() { + if(!reservationRan || !cancellationRan) { + throw IllegalStateException("Not all messages were tested.\nReservation: $reservationRan\nCancellation: $cancellationRan") + } + } + } + + @TestTemplate + fun testPactForReservationBooking(context: AsynchronousMessageContext) { + assertThat(context.message, `is`(notNullValue())) + when (context.message.description) { + "a purchase started message to book a reservation" -> { + reservationRan = true + } + "a cancellation message to cancel a reservation" -> { + cancellationRan = true + } + else -> { + throw IllegalArgumentException("Unknown message description") + } + } + } +}