All Downloads are FREE. Search and download functionalities are using the official Maven repository.

au.com.dius.pact.provider.junit5.PactJUnit5VerificationProvider.kt Maven / Gradle / Ivy

Go to download

# Pact Junit 5 Extension ## Overview For writing Pact verification tests with JUnit 5, there is an JUnit 5 Invocation Context Provider that you can use with the `@TestTemplate` annotation. This will generate a test for each interaction found for the pact files for the provider. To use it, add the `@Provider` and one of the pact source annotations to your test class (as per a JUnit 4 test), then add a method annotated with `@TestTemplate` and `@ExtendWith(PactVerificationInvocationContextProvider.class)` that takes a `PactVerificationContext` parameter. You will need to call `verifyInteraction()` on the context parameter in your test template method. For example: ```java @Provider("myAwesomeService") @PactFolder("pacts") public class ContractVerificationTest { @TestTemplate @ExtendWith(PactVerificationInvocationContextProvider.class) void pactVerificationTestTemplate(PactVerificationContext context) { context.verifyInteraction(); } } ``` For details on the provider and pact source annotations, refer to the [Pact junit runner](../pact-jvm-provider-junit/README.md) docs. ## Test target You can set the test target (the object that defines the target of the test, which should point to your provider) on the `PactVerificationContext`, but you need to do this in a before test method (annotated with `@BeforeEach`). There are three different test targets you can use: `HttpTestTarget`, `HttpsTestTarget` and `AmpqTestTarget`. For example: ```java @BeforeEach void before(PactVerificationContext context) { context.setTarget(HttpTestTarget.fromUrl(new URL(myProviderUrl))); // or something like // context.setTarget(new HttpTestTarget("localhost", myProviderPort, "/")); } ``` **Note for Maven users:** If you use Maven to run your tests, you will have to make sure that the Maven Surefire plugin is at least version 2.22.1 uses an isolated classpath. For example, configure it by adding the following to your POM: ```xml <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.1</version> <configuration> <useSystemClassLoader>false</useSystemClassLoader> </configuration> </plugin> ``` ## Provider State Methods Provider State Methods work in the same way as with JUnit 4 tests, refer to the [Pact junit runner](../pact-jvm-provider-junit/README.md) docs. ### Using multiple classes for the state change methods If you have a large number of state change methods, you can split things up by moving them to other classes. You will need to specify the additional classes on the test context in a `Before` method. Do this with the `withStateHandler` or `setStateHandlers` methods. See [StateAnnotationsOnAdditionalClassTest](src/test/java/au/com/dius/pact/provider/junit5/StateAnnotationsOnAdditionalClassTest.java) for an example. ## Modifying the requests before they are sent **Important Note:** You should only use this feature for things that can not be persisted in the pact file. By modifying the request, you are potentially modifying the contract from the consumer tests! Sometimes you may need to add things to the requests that can't be persisted in a pact file. Examples of these would be authentication tokens, which have a small life span. The Http and Https test targets support injecting the request that will executed into the test template method. You can then add things to the request before calling the `verifyInteraction()` method. For example to add a header: ```java @TestTemplate @ExtendWith(PactVerificationInvocationContextProvider.class) void testTemplate(PactVerificationContext context, HttpRequest request) { // This will add a header to the request request.addHeader("X-Auth-Token", "1234"); context.verifyInteraction(); } ``` ## Objects that can be injected into the test methods You can inject the following objects into your test methods (just like the `PactVerificationContext`). They will be null if injected before the supported phase. | Object | Can be injected from phase | Description | | ------ | --------------- | ----------- | | PactVerificationContext | @BeforeEach | The context to use to execute the interaction test | | Pact | any | The Pact model for the test | | Interaction | any | The Interaction model for the test | | HttpRequest | @TestTemplate | The request that is going to be executed (only for HTTP and HTTPS targets) | | ProviderVerifier | @TestTemplate | The verifier instance that is used to verify the interaction | ## Allowing the test to pass when no pacts are found to verify (version 4.0.7+) By default, the test will fail with an exception if no pacts were found to verify. This can be overridden by adding the `@IgnoreNoPactsToVerify` annotation to the test class. For this to work, you test class will need to be able to receive null values for any of the injected parameters.

The newest version!
package au.com.dius.pact.provider.junit5

import au.com.dius.pact.core.model.Interaction
import au.com.dius.pact.core.model.Pact
import au.com.dius.pact.core.model.ProviderState
import au.com.dius.pact.core.model.RequestResponseInteraction
import au.com.dius.pact.core.pactbroker.TestResult
import au.com.dius.pact.core.support.expressions.SystemPropertyResolver
import au.com.dius.pact.core.support.expressions.ValueResolver
import au.com.dius.pact.core.support.isNotEmpty
import au.com.dius.pact.provider.ConsumerInfo
import au.com.dius.pact.provider.DefaultTestResultAccumulator
import au.com.dius.pact.provider.IProviderVerifier
import au.com.dius.pact.provider.PactVerification
import au.com.dius.pact.provider.ProviderInfo
import au.com.dius.pact.provider.ProviderVerifier
import au.com.dius.pact.provider.TestResultAccumulator
import au.com.dius.pact.provider.junit.AllowOverridePactUrl
import au.com.dius.pact.provider.junit.Consumer
import au.com.dius.pact.provider.junit.IgnoreNoPactsToVerify
import au.com.dius.pact.provider.junit.JUnitProviderTestSupport
import au.com.dius.pact.provider.junit.JUnitProviderTestSupport.checkForOverriddenPactUrl
import au.com.dius.pact.provider.junit.JUnitProviderTestSupport.filterPactsByAnnotations
import au.com.dius.pact.provider.junit.MissingStateChangeMethod
import au.com.dius.pact.provider.junit.Provider
import au.com.dius.pact.provider.junit.State
import au.com.dius.pact.provider.junit.StateChangeAction
import au.com.dius.pact.provider.junit.VerificationReports
import au.com.dius.pact.provider.junit.loader.NoPactsFoundException
import au.com.dius.pact.provider.junit.loader.PactLoader
import au.com.dius.pact.provider.junit.loader.PactSource
import au.com.dius.pact.provider.reporters.ReporterManager
import mu.KLogging
import org.apache.http.HttpRequest
import org.junit.jupiter.api.extension.AfterTestExecutionCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
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.extension.TestTemplateInvocationContext
import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider
import org.junit.platform.commons.support.AnnotationSupport
import org.junit.platform.commons.support.HierarchyTraversalMode
import org.junit.platform.commons.support.ReflectionSupport
import java.io.File
import java.lang.reflect.Method
import java.util.stream.Stream
import kotlin.reflect.full.createInstance
import kotlin.reflect.full.findAnnotation

/**
 * The instance that holds the context for the test of an interaction. The test target will need to be set on it in
 * the before each phase of the test, and the verifyInteraction method must be called in the test template method.
 */
data class PactVerificationContext @JvmOverloads constructor(
  private val store: ExtensionContext.Store,
  private val context: ExtensionContext,
  var target: TestTarget = HttpTestTarget(port = 8080),
  var verifier: IProviderVerifier? = null,
  var valueResolver: ValueResolver = SystemPropertyResolver(),
  var providerInfo: ProviderInfo = ProviderInfo(),
  val consumerName: String,
  val interaction: Interaction,
  var testExecutionResult: TestResult = TestResult.Ok
) {
  val stateChangeHandlers: MutableList = mutableListOf()
  var executionContext: Map? = null

  /**
   * Called to verify the interaction from the test template method.
   *
   * @throws AssertionError Throws an assertion error if the verification fails.
   */
  fun verifyInteraction() {
    val store = context.getStore(ExtensionContext.Namespace.create("pact-jvm"))
    val client = store.get("client")
    val request = store.get("request")
    val testContext = store.get("interactionContext") as PactVerificationContext
    val failures = mutableMapOf()
    try {
      this.testExecutionResult = validateTestExecution(client, request, failures, testContext.executionContext ?: emptyMap())
      if (testExecutionResult is TestResult.Failed) {
        verifier!!.displayFailures(failures)
        throw AssertionError(JUnitProviderTestSupport.generateErrorStringFromMismatches(failures))
      }
    } finally {
      verifier!!.finaliseReports()
    }
  }

  private fun validateTestExecution(
    client: Any?,
    request: Any?,
    failures: MutableMap,
    context: Map
  ): TestResult {
    if (providerInfo.verificationType == null || providerInfo.verificationType == PactVerification.REQUEST_RESPONSE) {
      val interactionMessage = "Verifying a pact between $consumerName and ${providerInfo.name}" +
        " - ${interaction.description}"
      return try {
        val reqResInteraction = interaction as RequestResponseInteraction
        val expectedResponse = reqResInteraction.response.generatedResponse(context)
        val actualResponse = target.executeInteraction(client, request)

        verifier!!.verifyRequestResponsePact(expectedResponse, actualResponse, interactionMessage, failures,
          reqResInteraction.interactionId.orEmpty())
      } catch (e: Exception) {
        failures[interactionMessage] = e
        verifier!!.reporters.forEach {
          it.requestFailed(providerInfo, interaction, interactionMessage, e,
            verifier!!.projectHasProperty.apply(ProviderVerifier.PACT_SHOW_STACKTRACE))
        }
        TestResult.Failed(listOf(mapOf("message" to "Request to provider failed with an exception",
          "exception" to e, "interactionId" to interaction.interactionId)),
          "Request to provider failed with an exception")
      }
    } else {
      return verifier!!.verifyResponseByInvokingProviderMethods(providerInfo, ConsumerInfo(consumerName), interaction,
        interaction.description, failures)
    }
  }

  fun withStateChangeHandlers(vararg stateClasses: Any): PactVerificationContext {
    stateChangeHandlers.addAll(stateClasses)
    return this
  }

  fun addStateChangeHandlers(vararg stateClasses: Any) {
    stateChangeHandlers.addAll(stateClasses)
  }
}

/**
 * JUnit 5 test extension class used to inject parameters and execute the test for a Pact interaction.
 */
class PactVerificationExtension(
  private val pact: Pact,
  private val pactSource: au.com.dius.pact.core.model.PactSource,
  private val interaction: Interaction,
  private val serviceName: String,
  private val consumerName: String?
) : TestTemplateInvocationContext, ParameterResolver, BeforeEachCallback, BeforeTestExecutionCallback,
  AfterTestExecutionCallback {

  private val testResultAccumulator = DefaultTestResultAccumulator

  override fun getDisplayName(invocationIndex: Int): String {
    return "${pact.consumer.name} - ${interaction.description}"
  }

  override fun getAdditionalExtensions(): MutableList {
    return mutableListOf(PactVerificationStateChangeExtension(pact, interaction, testResultAccumulator), this)
  }

  override fun supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Boolean {
    val store = extensionContext.getStore(ExtensionContext.Namespace.create("pact-jvm"))
    val testContext = store.get("interactionContext") as PactVerificationContext
    return when (parameterContext.parameter.type) {
      Pact::class.java -> true
      Interaction::class.java -> true
      HttpRequest::class.java -> testContext.target is HttpTestTarget || testContext.target is HttpsTestTarget
      PactVerificationContext::class.java -> true
      ProviderVerifier::class.java -> true
      else -> false
    }
  }

  override fun resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Any? {
    val store = extensionContext.getStore(ExtensionContext.Namespace.create("pact-jvm"))
    return when (parameterContext.parameter.type) {
      Pact::class.java -> pact
      Interaction::class.java -> interaction
      HttpRequest::class.java -> store.get("httpRequest")
      PactVerificationContext::class.java -> store.get("interactionContext")
      ProviderVerifier::class.java -> store.get("verifier")
      else -> null
    }
  }

  override fun beforeEach(context: ExtensionContext) {
    val store = context.getStore(ExtensionContext.Namespace.create("pact-jvm"))
    store.put("interactionContext", PactVerificationContext(store, context, consumerName = pact.consumer.name,
      interaction = interaction))
  }

  override fun beforeTestExecution(context: ExtensionContext) {
    val store = context.getStore(ExtensionContext.Namespace.create("pact-jvm"))
    val testContext = store.get("interactionContext") as PactVerificationContext

    val providerInfo = testContext.target.getProviderInfo(serviceName, pactSource)
    testContext.providerInfo = providerInfo

    prepareVerifier(testContext, context, pactSource)
    store.put("verifier", testContext.verifier)

    val requestAndClient = testContext.target.prepareRequest(interaction, testContext.executionContext ?: emptyMap())
    if (requestAndClient != null) {
      val (request, client) = requestAndClient
      store.put("request", request)
      store.put("client", client)
      if (testContext.target.isHttpTarget()) {
        store.put("httpRequest", request)
      }
    }
  }

  private fun prepareVerifier(testContext: PactVerificationContext, extContext: ExtensionContext, pactSource: au.com.dius.pact.core.model.PactSource) {
    val consumer = ConsumerInfo(consumerName ?: pact.consumer.name)

    val verifier = ProviderVerifier()
    testContext.target.prepareVerifier(verifier, extContext.requiredTestInstance)

    setupReporters(verifier, serviceName, interaction.description, extContext, testContext.valueResolver)

    verifier.initialiseReporters(testContext.providerInfo)
    verifier.reportVerificationForConsumer(consumer, testContext.providerInfo, pactSource)

    if (interaction.providerStates.isNotEmpty()) {
      for ((name) in interaction.providerStates) {
        verifier.reportStateForInteraction(name.toString(), testContext.providerInfo, consumer, true)
      }
    }

    verifier.reportInteractionDescription(interaction)

    testContext.verifier = verifier
  }

  private fun setupReporters(
    verifier: IProviderVerifier,
    name: String,
    description: String,
    extContext: ExtensionContext,
    valueResolver: ValueResolver
  ) {
    var reportDirectory = "target/pact/reports"
    val reports = mutableListOf()
    var reportingEnabled = false

    val verificationReports = AnnotationSupport.findAnnotation(extContext.requiredTestClass, VerificationReports::class.java)
    if (verificationReports.isPresent) {
      reportingEnabled = true
      reportDirectory = verificationReports.get().reportDir
      reports.addAll(verificationReports.get().value)
    } else if (valueResolver.propertyDefined("pact.verification.reports")) {
      reportingEnabled = true
      reportDirectory = valueResolver.resolveValue("pact.verification.reportDir:$reportDirectory")!!
      reports.addAll(valueResolver.resolveValue("pact.verification.reports:")!!.split(","))
    }

    if (reportingEnabled) {
      val reportDir = File(reportDirectory)
      reportDir.mkdirs()
      verifier.reporters = reports
        .filter { r -> r.isNotEmpty() }
        .map { r ->
          val reporter = ReporterManager.createReporter(r.trim(), reportDir)
          reporter.reportFile = File(reportDir, "$name - $description${reporter.ext}")
          reporter
        }
    }
  }

  override fun afterTestExecution(context: ExtensionContext) {
    val store = context.getStore(ExtensionContext.Namespace.create("pact-jvm"))
    val testContext = store.get("interactionContext") as PactVerificationContext
    testResultAccumulator.updateTestResult(pact, interaction, testContext.testExecutionResult)
  }

  companion object : KLogging()
}

/**
 * JUnit 5 test extension class for executing state change callbacks
 */
class PactVerificationStateChangeExtension(
  private val pact: Pact,
  private val interaction: Interaction,
  private val testResultAccumulator: TestResultAccumulator
) : BeforeTestExecutionCallback, AfterTestExecutionCallback {
  override fun beforeTestExecution(extensionContext: ExtensionContext) {
    logger.debug { "beforeEach for interaction '${interaction.description}'" }
    val store = extensionContext.getStore(ExtensionContext.Namespace.create("pact-jvm"))
    val testContext = store.get("interactionContext") as PactVerificationContext

    try {
      val providerStateContext = invokeStateChangeMethods(extensionContext, testContext,
        interaction.providerStates, StateChangeAction.SETUP)
      testContext.executionContext = mapOf("providerState" to providerStateContext)
    } catch (e: Exception) {
      logger.error(e) { "Provider state change callback failed" }
      testContext.testExecutionResult = TestResult.Failed(description = "Provider state change callback failed",
        results = listOf(mapOf("exception" to e)))
      throw AssertionError("Provider state change callback failed", e)
    }
  }

  override fun afterTestExecution(context: ExtensionContext) {
    logger.debug { "afterEach for interaction '${interaction.description}'" }
    val store = context.getStore(ExtensionContext.Namespace.create("pact-jvm"))
    val testContext = store.get("interactionContext") as PactVerificationContext

    invokeStateChangeMethods(context, testContext, interaction.providerStates, StateChangeAction.TEARDOWN)
  }

  private fun invokeStateChangeMethods(
    context: ExtensionContext,
    testContext: PactVerificationContext,
    providerStates: List,
    action: StateChangeAction
  ): Map {
    val errors = mutableListOf()

    val providerStateContext = mutableMapOf()
    providerStates.forEach { state ->
      val stateChangeMethods = findStateChangeMethods(context.requiredTestInstance,
        testContext.stateChangeHandlers, state)
      if (stateChangeMethods.isEmpty()) {
        errors.add("Did not find a test class method annotated with @State(\"${state.name}\")")
      } else {
        stateChangeMethods.filter { it.second.action == action }.forEach { (method, _, instance) ->
          logger.debug { "Invoking state change method ${method.name} for state '${state.name}' on $instance" }
          val stateChangeValue = if (method.parameterCount > 0) {
            ReflectionSupport.invokeMethod(method, instance, state.params)
          } else {
            ReflectionSupport.invokeMethod(method, instance)
          }

          if (stateChangeValue is Map<*, *>) {
            providerStateContext.putAll(stateChangeValue as Map)
          }
        }
      }
    }

    if (errors.isNotEmpty()) {
      throw MissingStateChangeMethod(errors.joinToString("\n"))
    }

    return providerStateContext
  }

  private fun findStateChangeMethods(
    testClass: Any,
    stateChangeHandlers: List,
    state: ProviderState
  ): List> {
    val stateChangeClasses =
      AnnotationSupport.findAnnotatedMethods(testClass.javaClass, State::class.java, HierarchyTraversalMode.TOP_DOWN)
        .map { it to testClass }
        .plus(stateChangeHandlers.flatMap { handler ->
          AnnotationSupport.findAnnotatedMethods(handler.javaClass, State::class.java, HierarchyTraversalMode.TOP_DOWN)
            .map { it to handler }
        })
    return stateChangeClasses
      .map { Triple(it.first, it.first.getAnnotation(State::class.java), it.second) }
      .filter { it.second.value.any { s -> state.name == s } }
  }

  companion object : KLogging()
}

/**
 * Main TestTemplateInvocationContextProvider for JUnit 5 Pact verification tests. This class needs to be applied to
 * a test template method on a test class annotated with a @Provider annotation.
 */
open class PactVerificationInvocationContextProvider : TestTemplateInvocationContextProvider {

  override fun provideTestTemplateInvocationContexts(context: ExtensionContext): Stream {
    logger.debug { "provideTestTemplateInvocationContexts called" }
    val tests = resolvePactSources(context)
    return when {
      tests.first.isNotEmpty() -> tests.first.stream() as Stream
      AnnotationSupport.isAnnotated(context.requiredTestClass, IgnoreNoPactsToVerify::class.java) ->
        listOf(DummyTestTemplate).stream() as Stream
      else -> throw NoPactsFoundException("No Pact files were found to verify\n${tests.second}")
    }
  }

  private fun resolvePactSources(context: ExtensionContext): Pair, String> {
    var description = ""
    val providerInfo = AnnotationSupport.findAnnotation(context.requiredTestClass, Provider::class.java)
    if (!providerInfo.isPresent) {
      throw UnsupportedOperationException("Provider name should be specified by using @${Provider::class.java.name} annotation")
    }
    val serviceName = providerInfo.get().value
    description += "Provider: $serviceName"

    val consumerInfo = AnnotationSupport.findAnnotation(context.requiredTestClass, Consumer::class.java)
    val consumerName = consumerInfo.orElse(null)?.value
    if (consumerName.isNotEmpty()) {
      description += "\nConsumer: $consumerName"
    }

    validateStateChangeMethods(context.requiredTestClass)

    logger.debug { "Verifying pacts for provider '$serviceName' and consumer '$consumerName'" }

    val pactSources = findPactSources(context).flatMap {
      val valueResolver = getValueResolver(context)
      if (valueResolver != null) {
        it.setValueResolver(valueResolver)
      }
      description += "\nSource: ${it.description()}"
      val pacts = it.load(serviceName)
      filterPactsByAnnotations(pacts, context.requiredTestClass).map { pact -> pact to it.pactSource }
    }.filter { p -> consumerName == null || p.first.consumer.name == consumerName }

    return Pair(pactSources.flatMap { pact ->
      pact.first.interactions.map { PactVerificationExtension(pact.first, pact.second, it, serviceName, consumerName) }
    }, description)
  }

  protected open fun getValueResolver(context: ExtensionContext): ValueResolver? = null

  private fun validateStateChangeMethods(testClass: Class<*>) {
    val errors = mutableListOf()
    AnnotationSupport.findAnnotatedMethods(testClass, State::class.java, HierarchyTraversalMode.TOP_DOWN).forEach {
      if (it.parameterCount > 1) {
        errors.add("State change method ${it.name} should either take no parameters or a single Map parameter")
      } else if (it.parameterCount == 1 && !Map::class.java.isAssignableFrom(it.parameterTypes[0])) {
        errors.add("State change method ${it.name} should take only a single Map parameter")
      }
    }

    if (errors.isNotEmpty()) {
      throw UnsupportedOperationException(errors.joinToString("\n"))
    }
  }

  private fun findPactSources(context: ExtensionContext): List {
    val pactSource = context.requiredTestClass.getAnnotation(PactSource::class.java)
    logger.debug { "Pact source on test class: $pactSource" }
    val pactLoaders = context.requiredTestClass.annotations.filter { annotation ->
      annotation.annotationClass.findAnnotation() != null
    }
    logger.debug { "Pact loaders on test class: $pactLoaders" }

    if (pactSource == null && pactLoaders.isEmpty()) {
      throw UnsupportedOperationException("At least one pact source must be present on the test class")
    }

    return pactLoaders.plus(pactSource).filterNotNull().map {
      if (it is PactSource) {
        val pactLoaderClass = pactSource.value
        try {
          // Checks if there is a constructor with one argument of type Class.
          val constructorWithClass = pactLoaderClass.java.getDeclaredConstructor(Class::class.java)
          if (constructorWithClass != null) {
            constructorWithClass.isAccessible = true
            constructorWithClass.newInstance(context.requiredTestClass)
          } else {
            pactLoaderClass.createInstance()
          }
        } catch (e: NoSuchMethodException) {
          logger.error(e) { e.message }
          pactLoaderClass.createInstance()
        }
      } else {
        it.annotationClass.findAnnotation()!!.value.java
          .getConstructor(it.annotationClass.java).newInstance(it)
      }
    }.map {
      checkForOverriddenPactUrl(it,
        context.requiredTestClass.getAnnotation(AllowOverridePactUrl::class.java),
        context.requiredTestClass.getAnnotation(Consumer::class.java))
      it
    }
  }

  override fun supportsTestTemplate(context: ExtensionContext): Boolean {
    return AnnotationSupport.isAnnotated(context.requiredTestClass, Provider::class.java)
  }

  companion object : KLogging()
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy