Недавно я написал статью о реализации E2E тестирования в CI среде с использованием Testcontainers. А сегодня я хочу дополнить ее небольшой деталью. Мы поговорим о контрактах и о том, почему важно писать интеграционные тесты для них. Примеры кода приведены на Java, но вы можете применить предложенное решение к любому языку программирования.
Если вы еще не читали мою статью о тестах E2E, я настоятельно рекомендую вам сделать это, прежде чем двигаться дальше. В этой статье будет много предложений и обсуждений, основанных на опыте предыдущей статьи.
Подводные камни E2E тестирования
Видите ли, E2E-тестирование — это невероятно. Это самая высокая планка обеспечения качества, которую можно поставить в рамках программного проекта. В то же время, есть несколько нюансов, которые следует учитывать:
- E2E-тестирование — это сложно. Написание содержательных и сопровождаемых E2E-тестов требует значительных навыков.
- Запустить E2E-тестирование автоматически тоже не так просто. Хотя я предложил решение в предыдущей статье, его нельзя назвать тривиальным.
- Чем больше у вас микросервисов, тем сложнее написать новый E2E-тест.
- Каждый раз, когда программный продукт пополняется новым микросервисом, необходимо соответствующим образом обновлять среду (например, конфигурацию Testcontainers), чтобы быть уверенным, что тесты все еще актуальны.
- Если продукт состоит из слишком большого количества микросервисов, становится практически невозможно написать надежный E2E-тест для всей системы.
Как видите, E2E-тесты работают до тех пор, пока программный проект не становится огромным и слишком сложным. Что же делать тогда?
Интеграционное тестирование по контракту
Во-первых, посмотрите еще раз на схему системы из предыдущей статьи.
Весь бизнес-сценарий можно описать следующими шагами:
- Пользователь отправляет сообщение через REST API на
API-Service
. - Затем
API-Service
передает сообщение в кластер RabbitMQ.
Какие контракты мы имеем здесь? Посмотрите на схему ниже.
Я выделил контракты бледно-зелеными овалами:
Зачем нам вообще нужны контракты? Хотя дополнительные слои добавляют дополнительную сложность, контракты также помогают нам избавиться от интеграционных тестов в API-Service
и Gain-Service
. Взгляните на пример ниже.
Как мы уже обсуждали, UserMsgPush
— это контракт. Есть две реализации: RabbitUserMsgPush
и InMemoryUserMsgPush
. И теперь в игру вступает принцип подстановки Лискова. Если упростить определение, то замена одной реализации интерфейса на другую не должна нарушать корректность программы. В данном случае нам не нужно привлекать RabbitMQ к тестированию API-Service
! Потому что InMemoryUserMsgPush
будет достаточно. Итак, вот преимущества:
- Мы можем покрыть
API-Service
только модульными тестами. - Интеграционные тесты инкапсулируются только в сценарии
RabbitUserMsgPush
. - Мы можем заменить одну реализацию
UserMsgPush
на другую, не трогая тесты вRabbitUserMsgPush
.
Похоже, что контрактное тестирование — это серебряная пуля. Во-первых, мы сократили количество интеграционных тестов и упростили сценарии качества в сервисах. Во-вторых, мы полностью избавились от тестов E2E, которые сложно писать, выполнять и поддерживать. К сожалению, у контрактного тестирования есть и недостатки.
Недостатки контрактов
Нежелательная сложность кода
Микросервисы обычно не содержат много кода. Они часто предоставляют всего одну операцию. Например, API-Service
принимает сообщение через REST API и отправляет его в RabbitMQ. И все. Поэтому дополнительные уровни, интерфейсы и абстракции могут показаться шумом. Почему я должен работать, используя фасад, если я могу просто действовать напрямую?
Хотя абстракции полезны, их обилие может сделать код более сложным, чем он должен быть.
Отсутствие локальных экспериментов
Основное преимущество интеграционного тестирования заключается в том, что оно позволяет нам проводить локальные эксперименты перед отправкой запроса на исправление. Например, предположим, вам нужно изменить некоторые свойства внешнего объекта, от которого зависит ваш сервис. Это могут быть:
- Уменьшение размера пула соединений с базой данных.
- Изменение стратегии сериализации в производителе Kafka.
- Изменение SQL-запроса для повышения его эффективности.
Если в сервисе, над которым вы работаете, есть интеграционные тесты, вы можете просто настроить необходимые параметры и запустить тесты локально, чтобы проверить поведение. Но если сервис опирается на контракты, задача становится интригующей. У вас есть несколько подходов:
- Изменить контракт таким образом, чтобы свойства, которые вы обновляете, передавались в качестве параметров. Таким образом, вы добавляете в контракт дополнительные интеграционные тесты, которые проверяют валидность сценария. Но в этом случае контракт раскрывает слишком много деталей о своей реализации. Это нарушает всю идею абстракции и усложняет замену одной реализации на другую.
- Инкапсулируйте изменения желаемых свойств в качестве сценариев интеграции внутри контракта. В этом случае вы не передаете детали реализации пользователю контракта, но при этом значительно усложняете свои тесты. Ведь вам придется проверять различные комбинации возможных значений свойств.
- Добавьте необходимые интеграционные тесты непосредственно в кодовую базу сервиса. Итак, вы не усложняете контракт, верно? Да, но теперь сервис напрямую зависит от конкретной реализации контракта. Мы ввели контракты, чтобы полностью убрать интеграционные тесты из сервисов. И вот опять.
Обратная совместимость
Как я уже писал в предыдущей статье, главное преимущество E2E-тестов в том, что они проверяют проблемы обратной совместимости. К сожалению, контракты не предоставляют такой возможности.
Например, посмотрите на реализацию контракта RabbitUserMsgPush
ниже.
public class RabbitUserMsgPush implements UserMsgPush {
private final RabbitTemplate rabbitTemplate;
private final ObjectMapper objectMapper;
private final String targetQueue;
@Override
@SneakyThrows
public void pushMessage(Object payload) {
rabbitTemplate.send(
targetQueue,
new Message(
objectMapper.writeValueAsString(payload)
.getBytes(UTF_8)
)
);
}
}
В этом случае переданная полезная нагрузка сериализуется в JSON. Предположим, что вместо этого мы решили использовать двоичный протокол (например, Protobuf). Посмотрите на модифицированный RabbitUserMsgPush
ниже.
public class RabbitUserMsgPush implements UserMsgPush {
private final RabbitTemplate rabbitTemplate;
private final ProtobufSerializer serializer;
private final String targetQueue;
@Override
public void pushMessage(Object payload) {
rabbitTemplate.send(
targetQueue,
new Message(
serializer.toBytesArray(payload)
)
);
}
}
Это удобно. Потому что мы можем изменить реализацию контракта, не изменяя бизнес-логику вообще. Ничего не может пойти не так, не так ли? Ну, каждый контракт определяет производителя и потребителя. Что если мы обновим контракт производителя до контракта потребителя? Это означает, что потребитель не сможет десериализовать сообщение и потерпит неудачу.
Как видите, мы не можем легко нарушить обратную совместимость в распределенной среде. Мы можем не знать о предыдущих версиях контрактов, которые все еще используются другими клиентами. E2E-тестирование отслеживает эти нарушения. Но из-за изолированности реализаций контракта последний подход не может дать такого преимущества.
Ниже вы можете ознакомиться с правильным способом применения таких изменений.
public class RabbitJSONUserMsgPush implements UserMsgPush {
private final RabbitTemplate rabbitTemplate;
private final ObjectMapper objectMapper;
private final String targetJsonQueue;
private final UserMsgPush next;
@Override
@SneakyThrows
public void pushMessage(Object payload) {
rabbitTemplate.send(
targetJsonQueue,
new Message(
objectMapper.writeValueAsString(payload)
.getBytes(UTF_8)
)
);
next.pushMessage(payload);
}
}
public class RabbitUserMsgPush implements UserMsgPush {
private final RabbitTemplate rabbitTemplate;
private final ProtobufSerializer protobufSerializer;
private final String targetProtobufQueue;
private final UserMshPush next;
@Override
public void pushMessage(Object payload) {
rabbitTemplate.send(
targetProtobufQueue,
new Message(
serializer.toBytesArray(payload)
)
);
next.pushMessage(payload);
}
}
public class NoOpUserMsgPush implements UserMsgPush {
@Override
public void pushMessage(Object payload) {
// no operations
}
}
Во-первых, мы помещаем полезную нагрузку в две очереди с разными стратегиями сериализации. Потребитель не застревает, поскольку продолжает читать предыдущую очередь до тех пор, пока его контракт не будет обновлен. Во-вторых, мы применяем паттерн проектирования Chain-of-Responsibility для различения различных стратегий сериализации. В этом случае мы можем тестировать их отдельно.
Для удобства я создал
NoOpUserMsgPush
. Это последний элемент цепочки, который не работает. Таким образом, нам не нужно проверять, присутствует ли элементnext
в каждой реализации.
Сохранить обратную совместимость изменений в контрактах возможно, но это требует дополнительных усилий. Вы также можете поставить счетчик метрик, описывающих количество вызовов определенных операций (т.е. проталкивание сообщений JSON и Protobuf), и отображать их в Grafana. Это поможет вам заметить, когда все потребители обновлены, а производители могут спокойно устранить избыточную функциональность.
Единый язык для микросервисов
Контракт — это не что иное, как обычный кусок кода. Поэтому вы не можете применить контракт, написанный на Java, в микросервисах, созданных с помощью Golang. Если вам необходимо использовать различные технологии и языки для разных частей вашей системы, то реализация статически типизированных контрактов может стать проблемой. На рынке есть несколько решений. Например, спецификации OpenAPI и AsyncAPI могут генерировать фрагменты кода на основе предоставленных конфигураций. В любом случае, эти подходы имеют свои ограничения.
Заключение
Контракты являются мощным инструментом. В некоторых случаях они могут облегчить тестирование вашего кода. С другой стороны, они также приводят к проблемам с сопровождаемостью, которые следует учитывать. Вы сами решаете, использовать контракты или нет. Но, на мой взгляд, они не могут полностью заменить E2E-тестирование.
Это все, что я хотел рассказать вам о контрактном интеграционном тестировании. Если у вас есть вопросы или предложения, пожалуйста, оставляйте свои комментарии ниже. Спасибо за прочтение!