За всё ответишь! Consumer Driven Contracts глазами разработчика

В этой статье мы расскажем про проблемы, которые решает Consumer Driven Contracts, покажем как это применять на примере Pact с Node.js и Spring Boot. И расскажем про ограничения этого подхода.

guvgzadja02vflw5bi8mgh_wbbo.png

Проблематика


При тестировании продуктов часто используют сценарные тесты, в которых проверяется интеграция различных компонент системы на специально выделенном окружении. Такие тесты на живых сервисах дают самый достоверный результат (не считая тестов на бою). Но в то же время они — одни из самых дорогих.

  • Часто ошибочно считают, что интеграционная среда не должна быть отказоустойчивой. SLA, гарантии для таких сред редко проговаривают, но если она недоступна — командам приходится либо задерживать релизы, либо надеяться на лучшее и идти в бой без тестов. Хотя все знают, что надежда — это не стратегия. А новомодные инфраструктурные технологии только усложняют работу с интеграционными средами.
  • Отдельная боль — работа с тестовыми данными. Многие сценарии требуют определенного состояния системы, фикстуры. Насколько близкими они должны быть к боевым данным? Как приводить их к актуальному состоянию перед тестом и чистить после завершения?
  • Тесты слишком нестабильные. И не только из-за инфраструктуры, которую мы упомянули в первом пункте. Тест может упасть, потому что соседняя команда запустила свои проверки, которые сломали ожидаемое состояние системы! Многие false negative проверки, flaky-тесты заканчивают свою жизнь в @Ignored. Также разные части интеграции могут поддерживаться разными командами. Выкатили новый release candidate с ошибками — сломали всех потребителей. Кто-то решает эту проблему выделенными тестовыми контурами. Но ценой умножения стоимости поддержки.
  • Такие тесты занимают много времени. Даже с учетом автоматизации, результаты можно ждать часами.
  • А в довершении, если тест действительно справедливо упал, то далеко не всегда получается сразу найти причину проблемы. Она может скрываться глубоко за слоями интеграций. Или может быть следствием неожиданной комбинации состояний множества компонентов системы.


Стабильные тесты в интеграционной среде требуют серьезного вложения со стороны QA, dev и даже ops. Недаром они находятся на самой верхушке тестовой пирамиды. Такие тесты полезны, но экономика ресурсов не позволяет проверять ими все подряд. Основной источник их стоимости — это окружение.

Ниже по той же пирамиде находятся другие тесты, в которых мы размениваем достоверность на меньшие головные боли поддержки — с помощью изоляции проверок. Чем гранулярнее, меньше по масштабу проверка, тем меньше зависимость от внешней среды. На самом низу пирамиды находятся unit-тесты. Мы проверяем отдельные функции, классы, мы оперируем уже не столько бизнес-семантикой, сколько конструкциями конкретной реализации. Эти тесты дают быструю обратную связь.

Но как только мы спускаемся ниже по пирамиде, приходится чем-то замещать окружение. Появляются заглушки — как целых сервисов, так и отдельных сущностей языка программирования. Именно с помощью заглушек мы можем тестировать компоненты в изоляции. Но также они уменьшают достоверность проверок. Как убедиться, что заглушка отдаёт правильные данные? Как обеспечивать ее качество?

Решением может быть исчерпывающая документация, которая описывает различные сценарии и возможные состояния компонентов системы. Но любые формулировки все равно оставляют свободу интерпретации. Поэтому хорошая документация — это живой артефакт, который постоянно улучшается по мере понимания командой проблемной области. Как тогда обеспечивать соответствие заглушек документации?

На многих проектах можно наблюдать ситуацию, когда заглушки пишут те же ребята, которые разрабатывали тестируемый артефакт. К примеру, разработчики мобильных приложений делают заглушки для своих тестов сами. В итоге — программисты могут понять документацию по-своему (что совершенно нормально), заглушку делают с неправильным ожидаемым поведением, пишут в соответствии с ним код (с зелеными тестами), а при реальной интеграции сыпятся ошибки.

Более того, документация обычно движется downstream — клиенты используют спеки сервисов (при этом клиентом сервиса может быть и другой сервис). Она не выражает того, как потребители используют данные, какие данные вообще нужны, какие допущения они делают для этих данных. Следствием такого незнания является закон Хайрама.

ekbhhofe0ox95w50drdg8tybsbi.png

Хайрам Райт долгое время разрабатывал инструменты общего пользования внутри Google и наблюдал, как самые незначительные изменения могут вызвать поломки у клиентов, которые использовали неявные (недокументированные) особенности его библиотек. Такая скрытая связность усложняет эволюцию API.

Перечисленные проблемы в некоторой степени можно решить с помощью Consumer Driven Contracts. Как и у любого подхода и инструмента, у него есть область применимости и стоимость, которые мы тоже рассмотрим. Реализации этого подхода достигли достаточного уровня зрелости, чтобы пробовать на своих проектах.

Что такое CDC?


Три ключевых элемента:

  • Контракт. Описывается с помощью некоторого DSL, зависит от реализации. Он содержит в себе описание API в виде сценариев взаимодействия: если пришел определенный запрос, то клиент должен получить определенный ответ.
  • Тесты клиентов. Причем в них используется заглушка, которая автоматически формируется из контракта.
  • Тесты для API. Они также генерируются из контракта.


Таким образом, контракт — исполняемый. И основная особенность подхода заключается в том, что требования к поведению API идут upstream, от клиента к серверу.

Контракт фокусируется на том поведении, которое действительно важно потребителю. Делает явными его допущения относительно API.

Главная задача CDC — сблизить понимание поведения API его разработчиками и разработчиками его клиентов. Этот подход хорошо сочетается с BDD, на встречах трёх амиго можно набрасывать заготовки для контракта. В конечном счёте, этот контракт так же служит улучшению коммуникаций; разделению общего понимания проблемной области и реализации решения внутри и между командами.

Pact


Рассмотрим применение CDC на примере Pact, одной из его реализации. Допустим, мы делаем web-приложение для участников конференций. В очередной итерации команда разрабатывает отображение расписания выступлений — пока без каких либо историй вроде голосования или заметок, только вывод сетки докладов. Исходники примера лежат здесь.

На встрече трёх четырёх амиго встречаются продакт, тестировщик, разработчики бэкенда и мобильного приложения. Они проговаривают, что

  • В UI будет отображаться список с текстом: Название доклада + Докладчики + Дата и время.
  • Для этого бэкенд должен возвращать данные как в примере ниже.
{
   "talks":[
      {
         "title":"Изготовление качественных шерстяных изделий в условиях невесомости",
         "speakers":[
            {
               "name":"Фубар Базов"
            }
         ],
         "time":"2019-05-27T12:00:00+03:00"
      }
   ]
}


После чего разработчик фронтенда идёт писать код клиента (backend for frontend). Он устанавливает в проекте библиотеку для работы с pact-контрактом:

yarn add --dev @pact-foundation/pact


И начинает писать тест. В нем настраивается локальный сервер заглушек, который будет моделировать работу сервиса с расписаниями докладов:

const provider = new Pact({
  // название потребителя и поставщика данных
  consumer: "schedule-consumer",
  provider: "schedule-producer",

  // порт, на котором поднимется заглушка
  port: pactServerPort,

  // сюда pact будет писать отладочную информацию
  log: path.resolve(process.cwd(), "logs", "pact.log"),

  // директория, в которой сформируется контракт
  dir: path.resolve(process.cwd(), "pacts"),

  logLevel: "WARN",

  // версия DSL контракта
  spec: 2
});


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

provider.setup().then(() =>
  provider
    .addInteraction({
      uponReceiving: "a request for schedule",
      withRequest: {
        method: "GET",
        path: "/schedule"
      },
      willRespondWith: {
        status: 200,
        headers: {
          "Content-Type": "application/json;charset=UTF-8"
        },
        body: {
          talks: [
            {
              title: "Изготовление качественных шерстяных изделий в условиях невесомости",
              speakers: [
                {
                  name: "Фубар Базов"
                }
              ],
              time: "2019-05-27T12:00:00+03:00"
            }
          ]
        }
      }
    })
    .then(() => done())
);


Здесь в примере мы указали конкретный ожидаемый запрос к сервису, но pact-js также поддерживает несколько способов определения совпадений.

И наконец, программист пишет тест той части кода, которая использует эту заглушку. В следующем примере мы для простоты вызовем её напрямую.

it("fetches schedule", done => {
  fetch(`http://localhost:${pactServerPort}/schedule`)
    .then(response => response.json())
    .then(json => expect(json).toStrictEqual({
      talks: [
        {
          title: "Изготовление качественных шерстяных изделий в условиях невесомости",
          speakers: [
            {
              name: "Фубар Базов"
            }
          ],
          time: "2019-05-27T12:00:00+03:00"
        }
      ]
    }))
    .then(() => done());
});


В реальном проекте это может быть как быстрый unit-тест отдельной функции интерпретации ответа, так и медленный UI-тест отображения полученных от сервиса данных.

Во время прогона теста, pact проверяет, что заглушка получила заданный в тестах запрос. Расхождения можно посмотреть в виде diff в файле pact.log.

E, [2019-05-21T01:01:55.810194 #78394] ERROR -- : Diff with interaction: "a request for schedule"
Diff
--------------------------------------
Key: - is expected 
     + is actual 
Matching keys and values are not shown

 {
   "headers": {
-    "Accept": "application/json"
+    "Accept": "*/*"
   }
 }

Description of differences
--------------------------------------
* Expected "application/json" but got "*/*" at $.headers.Accept

Если тест проходит успешно, то формируется контракт в формате JSON. В нём описано ожидаемое поведение API.

{
  "consumer": {
    "name": "schedule-consumer"
  },
  "provider": {
    "name": "schedule-producer"
  },
  "interactions": [
    {
      "description": "a request for schedule",
      "request": {
        "method": "GET",
        "path": "/schedule",
        "headers": {
          "Accept": "application/json"
        }
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": "application/json;charset=UTF-8"
        },
        "body": {
         "talks":[
            {
               "title":"Изготовление качественных шерстяных изделий в условиях невесомости",
               "speakers":[
                  {
                     "name":"Фубар Базов"
                  }
               ],
               "time":"2019-05-27T12:00:00+03:00"
            }
         ]
       }}}
  ],
  "metadata": {
    "pactSpecification": {
      "version": "2.0.0"
    }
  }
}


Он отдаёт этот контракт разработчику бэкенда. Допустим, API будет на Spring Boot. У Pact есть библиотека pact-jvm-provider-spring, которая умеет работать с MockMVC. Но мы расмотрим на Spring Cloud Contract, который реализует CDC в экосистеме Spring. Он использует свой формат контрактов, но также имеет точку расширения для подключения конвертеров из других форматов. Его родной формат контрактов поддерживается только самим Spring Cloud Contract — в отличие от Pact, у которого есть библиотеки для JVM, Ruby, JS, Go, Python и т.д.

Допустим, в нашем примере разработчик бэкенда использует Gradle для сборки сервиса. Он подключает следующие зависимости:

buildscript {
	// ...

	dependencies {
		classpath "org.springframework.cloud:spring-cloud-contract-pact:2.1.1.RELEASE"
	}
}

plugins {
	id "org.springframework.cloud.contract" version "2.1.1.RELEASE"
       // ...
}

// ...

dependencies {
    // ...
    testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-verifier'
}


И кладет полученный от фротэндера Pact-контракт в директорию src/test/resources/contracts.

Из неё по-умолчанию плагин spring-cloud-contract вычитывает контракты. При сборке исполняется gradle-задача generateContractTests, которая формирует в директории build/generated-test-sources следующий тест.

public class ContractVerifierTest extends ContractsBaseTest {

    @Test
    public void validate_aggregator_client_aggregator_service() throws Exception {
        // given:
        MockMvcRequestSpecification request = given()
            .header("Accept", "application/json");

        // when:
        ResponseOptions response = given().spec(request)
            .get("/scheduler");

        // then:
        assertThat(response.statusCode()).isEqualTo(200);
        assertThat(response.header("Content-Type")).isEqualTo("application/json;charset=UTF-8");
        // and:
        DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
        assertThatJson(parsedJson).array("['talks']").array("['speakers']").contains("['name']").isEqualTo( /*...*/ );
        assertThatJson(parsedJson).array("['talks']").contains("['time']").isEqualTo( /*...*/ );
        assertThatJson(parsedJson).array("['talks']").contains("['title']").isEqualTo( /*...*/ );
    }
}

При запуске этого теста мы увидим ошибку:

java.lang.IllegalStateException: You haven't configured a MockMVC instance. You can do this statically


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

public abstract class ContractsBaseTest {

    private ScheduleController scheduleController = new ScheduleController();


    @Before
    public void setup() {
        RestAssuredMockMvc.standaloneSetup(scheduleController);
    }
}

Чтобы этот базовый класс использовался при генерации, нужно донастроить gradle-плагин spring-cloud-contract.

contracts {
	baseClassForTests = 'ru.example.schedule.ContractsBaseTest'
}

Теперь у нас генерируется такой тест:

public class ContractVerifierTest extends ContractsBaseTest {
	@Test
	public void validate_aggregator_client_aggregator_service() throws Exception {
		// ...
	}
}


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

Разработчик сервиса знает через контракт, что ему нужно сделать, какое поведение реализовать.

Pact можно интегрировать глубже в процесс разработки. Можно развернуть Pact-broker, который агрегирует такие контракты, поддерживает их версионность и может показывать граф зависимостей.

dqpdv0c4qk89kboewjxrafys1iq.png

Загрузку нового сгенерированного контракта в брокер можно сделать шагом CI при сборке клиента. А в коде сервера указать динамическую загрузку контракта по URL. Spring Cloud Contract это также поддерживает.

Применимость CDC


Какие ограничения есть у Consumer Driven Contracts?

За использование такого подхода приходится платить дополнительными инструментами вроде pact. Сами по себе контракты — это дополнительный артефакт, ещё одна абстракция, которую нужно аккуратно поддерживать, осознанно применять к ней инженерные практики.

Они не заменяют e2e тесты, так как заглушки всё равно остаются заглушками — моделями реальных компонентов системы, которые может и чуть-чуть, но не соответствуют действительности. Через них не проверить сложные сценарии.

Также CDC не заменяют функциональные тесты API. Они дороже в поддержке, чем Plain Old Unit Tests. Разработчики Pact рекомендуют пользоваться следующей эвристикой — если убрать контракт и это не вызовет ошибки или неправильную интерпретацию со стороны клиента — значит он не нужен. К примеру, не нужно через контракт описывать абсолютно все коды ошибок API, если клиент обрабатывает их одинаково. Другими словами, контракт описывает для сервиса только то, что важно его клиенту. Не больше, но и не меньше.

Слишком большое количество контрактов также усложняет эволюцию API. Каждый дополнительный контракт — это повод для красных тестов. Нужно проектировать CDC таким образом, чтобы каждый fail теста нес в себе полезную смысловую нагрузку, которая перевешивает стоимость его поддержки. К примеру, если в контракте зафиксировать минимальную длину некоторого текстового поля, которая безразлична потребителю (он применяет технику Toleran Reader), то каждое изменение этого минимального значения будет ломать контракт и нервы окружающих. Такую проверку нужно переносить на уровень самого API и реализовывать в зависимости от источника ограничений.

Заключение


CDC улучшает качество продукта за счет явного описания интеграционного поведения. Он помогает разработчикам клиентов и сервисов достичь общего понимания, позволяет разговаривать через код. Но делает это ценой добавления инструментария, введением новых абстракций и дополнительными действиями членов команды.

В то же время, инструменты и фреймворки CDC активно разрабатываются и уже достигли зрелости для пробы на ваших проектах. Тестируйте:)

На конференции QualityConf 27–28 мая Андрей Маркелов расскажет про техники тестирования на проде, а Артур Хинельцев — про мониторинг высоконагруженного фронтенда, когда цена даже маленькой ошибки — это десятки тысяч грустных пользователей.

Приходите пообщаться за качество!

© Habrahabr.ru