В предыдущем блоге я представил концепцию контрактных тестов. Сейчас мы рассмотрим написание контрактных тестов для сервисов, которые взаимодействуют через Http. Они написаны с использованием:

  • Pact-jvm

  • Junit5

  • Gradle

Код доступен на github.

Потребители управляют контрактами. Они создают и передают их провайдеру. Он должен убедиться, что ожидания потребителя удовлетворены. Провайдер не может внедрить изменение, если ожидания потребителя не оправдаются. Pact поможет нам протестировать его.

Потребительский тест

Тесты потребителей обычно пишутся для классов, которые отвечают за взаимодействие с другими сервисами. Давайте начнем с первого теста. Цель этого теста - проверить взаимодействие между сервисом выдачи кредитов и службы по борьбе с мошенничеством. Кредитный портал и сервис по борьбе с мошенничеством - это компоненты, которые я представил в предыдущем блоге. Кредитный шлюз передает идентификатор клиента в службу по борьбе с мошенничеством и ожидает ответа, содержащего информацию о том, имеет ли данный клиент статус мошенника или нет. Ниже приведен фрагмент кода FraudCheckService в службе кредитного шлюза.

@Service
class FraudCheckService(
   @Value("\${loan-gateway.fraud-service-url}")
   private val fraudProviderUrl: String,
   private val restTemplate: RestTemplate,
   private val eventPublisher: ApplicationEventPublisher
) {
   fun isFraudulent(customerId: String): FraudCheck {
       val fraudCheck = try {
           val responseBody = restTemplate.getForEntity(
               fraudProviderUrl.replace("{customerId}", customerId),
               FraudCheck::class.java
           ).body
           FraudCheck(responseBody?.status ?: true)
       } catch (e: HttpClientErrorException) {
           FraudCheck(true)
       }
       publishEvent(customerId, fraudCheck)
       return fraudCheck
   }
   private fun publishEvent(customerId: String, fraudCheck: FraudCheck) {
       val loanApplicationEvent = LoanApplicationEvent(LoanApplication(customerId, fraudCheck))
       eventPublisher.publishEvent(loanApplicationEvent)
   }
}

Давайте рассмотрим пошаговый подход к созданию контрактных тестов.

1.Напишите тест spring boot, который утверждает, что для клиента id "1" статус мошенничества является ложным.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
class FraudCheckServiceConsumerTest {

   @Autowired
   private lateinit var fraudCheckService: FraudCheckService

   @Test
   fun `should return fraud details with status as false for customerId 1`() {
       fraudCheckService.isFraudulent("1").status shouldBe false
   }
}

2. Этот тест будет провален, так как не существует доступной службы по борьбе с мошенничеством, которая могла бы ответить. Тест пройден после установки имитационной службы проверки на мошенничество.

@Test
fun `should return fraud details with status as false for customerId 1`() {
   val mockResponse = MockResponse()
       .setBody("""{ "status": false }""")
       .addHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)
   mockWebServer.enqueue(mockResponse)
   
   fraudCheckService.isFraudulent("1").status shouldBe false
}
// Using okhttp3.mockwebserver.MockWebServer

3. Давайте обратим внимание на метод, который определяет контракт.

@Pact(provider = "fraud_service", consumer = "loan_gateway")
fun nonFraudulentCustomer(pactDsl: PactDslWithProvider): RequestResponsePact {
   return pactDsl
       .given("Customer with id 1 is setup to return false fraudulent status")
       .uponReceiving("When fraud status is requested for nonFraudulent customer")
       .method("GET")
       .path("/fraud/1")
       .willRespondWith()
       .status(200)
       .body(JSONObject("""{ "status": false }"""))
       .toPact()
}

Этот метод не требует пояснений. В основном мы описываем, что такое запрос и ответ. Давайте разложим это по полочкам.

@Pact(provider = "fraud_service", consumer = "loan_gateway")

Имена провайдера и потребителя, они будут опубликованы вместе с контрактом

given("Customer with id 1 is setup to return false fraudulent status")

Данное поле является необязательным, оно позволяет провайдеру выполнить тестовую настройку. Мы рассмотрим это более подробно в тесте провайдера

uponReceiving("When fraud status is requested for nonFraudulent customer")

Это описание взаимодействия, в основном оно раскрывает сценарий.

method("GET")

Http-метод

path("/fraud/1")

Uri-идентификатор

status(200)

Код состояния ответа

body(JSONObject("""{ "status": false }"""))

Тело ответа

4. Снабдите метод тестирования аннотацией PactTestFor. Таким образом тест подключается к методу pact.

5. Снабдите класс теста аннотацией PactTestFor. Расширьте тест с помощью класса PactConsumerTestExt.

6. Запустите тест, он закончится ошибкой с исключением  java.net.BindException: Address already in use. Это происходит потому, что Pact сам запускает имитационный сервер. Давайте удалим имитатор сервера. Тест выглядит следующим образом.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@ExtendWith(PactConsumerTestExt::class)
@PactTestFor(port = "1234")
class FraudCheckServiceConsumerTest {

   @Autowired
   private lateinit var fraudCheckService: FraudCheckService

   @Pact(provider = "fraud_service", consumer = "loan_gateway")
   fun nonFraudulentCustomer(pactDsl: PactDslWithProvider): RequestResponsePact {
       return pactDsl
           .given("Customer with id 1 is setup to return false fraudulent status")
           .uponReceiving("When fraud status is requested for nonFraudulent customer")
           .method("GET")
           .path("/fraud/1")
           .willRespondWith()
           .status(200)
           .body(JSONObject("""{ "status": false }"""))
           .toPact()
   }

   @Test
   @PactTestFor(pactMethod = "nonFraudulentCustomer")
   fun `should return fraud details with status as false for customerId 1`() {
       fraudCheckService.isFraudulent("1").status shouldBe false
   }
}

7. Запустите тест. Теперь он проходит. В качестве замечательного побочного эффекта он создает контракт в папке /target/pacts.

{
 "provider": {
   "name": "fraud_service"
 },
 "consumer": {
   "name": "loan_gateway"
 },
 "interactions": [
   {
     "description": "When fraud status is requested for nonFraudulent customer",
     "request": {
       "method": "GET",
       "path": "/fraud/1"
     },
     "response": {
       "status": 200,
       "headers": {
         "Content-Type": "application/json; charset=UTF-8"
       },
       "body": {
         "status": false
       },
       "matchingRules": {
         "header": {
           "Content-Type": {
             "matchers": [
               {
                 "match": "regex",
                 "regex": "application/json(;\\s?charset=[\\w\\-]+)?"
               }
             ],
             "combine": "AND"
           }
         }
       }
     },
     "providerStates": [
       {
         "name": "Customer with id 1 is setup to return false fraudulent status"
       }
     ]
   }
 ],
 "metadata": {
   "pactSpecification": {
     "version": "3.0.0"
   },
   "pact-jvm": {
     "version": "4.0.10"
   }
 }
}

8. Этот контракт передается провайдеру, и он убеждается, что договор соответствует ожиданиям потребителя. Для удобства обмена контрактами pact предоставляет брокера, который хранит опубликованные соглашения. Мы рассмотрим тему брокера отдельно.

Тест провайдера

Тесты провайдера пишутся для уровня контроллера. Остальные компоненты сервиса используются в виде стабов/моков. Это связано с тем, что взаимодействие между контроллером и другими компонентами уже проверено интеграционными тестами.

Давайте начнем с теста провайдера.

  1. Напишите тест spring boot для контроллера службы провайдера. В нашем случае это FraudServiceController. Остальные компоненты пропустим.

@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(FraudControllerProviderTest.TestConfiguration::class)
class FraudControllerProviderTest {
   @org.springframework.boot.test.context.TestConfiguration
   class TestConfiguration {
       @Bean
       fun fraudService(): FraudService {
           return mockk() {
               every { fraudStatus("1") } returns FraudCheck(false)
           }
       }
   }
}

2. Давайте преобразуем тест spring boot в тест провайдера. Настроим контекст с хостом и портом. И проверим взаимодействие.

@LocalServerPort
private val port = 0

@BeforeEach
fun before(context: PactVerificationContext) {
   context.target = HttpTestTarget("localhost", port)
}

@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider::class)
fun pactVerificationTestTemplate(context: PactVerificationContext) {
   context.verifyInteraction()
}

3. Сейчас pact не знает какие взаимодействия тестировать из этих pact-файлов. Давайте предоставим эту информацию с помощью аннотаций.

@PactFolder("target/pacts")
@Provider("fraud_service")

4. Запустите тест. Тест завершается с исключением au.com.dius.pact.provider.junit.MissingStateChangeMethod: Не найден метод тестового класса, аннотированный @State("Customer with id 1 is setup to return false fraudulent status").

5. Взглянув на строку внутри аннотации state, мы понимаем, что упомянули эту строку в контракте в пункте given. Давайте вспомним ее назначение. Она необязательна, однако позволяет провайдеру провести тестовую настройку. Эта информация фиксируется в узле providerStates json контракта.

6. Давайте добавим метод настройки, хотя в идеале он не нужен, поскольку о настройке уже позаботился мок. Однако, поскольку он присутствует в контракте, его определение является обязательным. Потребители могут пропустить его, однако я рекомендую его оставить, поскольку они могут не знать, что необходимо настроить поставщику данных. Пока что добавим пустой метод, как показано ниже.

@State(
   value =
   [
       "Customer with id 1 is setup to return false fraudulent status"
   ]
)
fun setupIfAny() {
   // Nothing to setup as it is taken care by the mock FraudService
}

7.Весь тест выглядит следующим образом.

@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(FraudControllerProviderTest.TestConfiguration::class)
@PactFolder("target/pacts")
@Provider("fraud_service")
class FraudControllerProviderTest {
   @LocalServerPort
   private val port = 0
   @BeforeEach
   fun before(context: PactVerificationContext) {
       context.target = HttpTestTarget("localhost", port)
   }

   @TestTemplate
   @ExtendWith(PactVerificationInvocationContextProvider::class)
   fun pactVerificationTestTemplate(context: PactVerificationContext) {
       context.verifyInteraction()
   }

   @State(
       value =
       [
           "Customer with id 1 is setup to return false fraudulent status"
       ]
   )
   fun setupIfAny() {
       // Nothing to setup as it is taken care by the mock FraudService
   }

   @org.springframework.boot.test.context.TestConfiguration
   class TestConfiguration {
       @Bean
       fun fraudService(): FraudService {
           return mockk {
               every { fraudStatus("1") } returns FraudCheck(false)
           }
       }
   }
}

8.Запустите тест. Теперь он пройдет и выдаст результат, как показано ниже. Последняя строка в выводе говорит о том, что результаты проверки не опубликованы, хотя тест пройден.

Verifying a pact between loan_gateway and fraud_service
  [Using Directory target/pacts]
  Given Customer with id 1 is setup to return false fraudulent status
  When fraud status is requested for nonFraudulent customer
  ...
    returns a response which
      has status code 200 (OK)
      has a matching body (OK)
... Skipping publishing of verification results as it has been disabled (pact.verifier.publishResults is not 'true')

Публикация результатов верификации

  1. Pact ищет переменную окружения  pact.verifier.publishResults, и если она имеет значение true, то результаты публикуются.

  2. Это свойство можно установить в методе настройки тестов провайдера. Однако это необходимо повторить для каждого класса теста провайдера.

  3. В качестве альтернативы мы можем реализовать хук BeforeAllCallback, с помощью которого мы получим хэндл для установки этого свойства.

class PactVerificationResultExtension: BeforeAllCallback {
   override fun beforeAll(context: ExtensionContext?) {
       System.setProperty("pact.verifier.publishResults", "true")
       System.setProperty("pact.provider.version", "0.0.3")
   }
}

4. Расширьте тест провайдера с помощью PactVerificationResultExtension, как показано ниже

@ExtendWith(PactVerificationResultExtension::class)

5. Вот и все, теперь результаты проверки будут опубликованы.

6. Проблема этого подхода в том, что мы жестко закодировали версию провайдера. Теперь давайте сделаем еще один шаг и используем версию проекта, указанную в build.gradle.

Ссылка на версию приложения из build.gradle

1.Давайте воспользуемся задачей Gradle ProcessResources. Она позволяет нам получить доступ к свойствам gradle в файле свойств приложения. Добавьте приведенный ниже код в build.gradle.

processResources {
   expand(project.properties)
}

2. Укажите версию проекта в файле application.yaml, как показано ниже.

app.version: ${version}

3.Получите доступ к app.version в файле PactVerificationResultExtension и установите его на pact.provider.version

class PactVerificationResultExtension: BeforeAllCallback {
   override fun beforeAll(context: ExtensionContext?) {
       System.setProperty("pact.verifier.publishResults", "true")
       System.setProperty("pact.provider.version", getVersion())
   }

   private fun getVersion(): String {
       return PropertiesLoaderUtils.loadAllProperties("application.yaml")
           .getProperty("app.version")
   }
}

На этом пока все. В следующем блоге мы рассмотрим написание контрактных тестов между сервисами, которые взаимодействуют асинхронно.


Материал подготовлен в рамках курса «Разработчик на Spring Framework».

Всех желающих приглашаем на двухдневный онлайн-интенсив «Работа с реляционными БД с помощью Spring». На занятиях вы узнаете, как работать с БД помощью разных технологий: JDBC, JPA + Hiberante. Также разберемся, какую помощь предлагают в этом различные проекты Spring: Spring JDBC, Spring ORM, Spring Data JPA и Spring Data JDBC.

→ РЕГИСТРАЦИЯ

Комментарии (0)