Интеграционное тестирование по контракту

Недавно я написал статью о реализации E2E тестирования в CI среде с использованием Testcontainers. А сегодня я хочу дополнить ее небольшой деталью. Мы поговорим о контрактах и о том, почему важно писать интеграционные тесты для них. Примеры кода приведены на Java, но вы можете применить предложенное решение к любому языку программирования.

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

Подводные камни E2E тестирования

Видите ли, E2E-тестирование — это невероятно. Это самая высокая планка обеспечения качества, которую можно поставить в рамках программного проекта. В то же время, есть несколько нюансов, которые следует учитывать:

  1. E2E-тестирование — это сложно. Написание содержательных и сопровождаемых E2E-тестов требует значительных навыков.
  2. Запустить E2E-тестирование автоматически тоже не так просто. Хотя я предложил решение в предыдущей статье, его нельзя назвать тривиальным.
  3. Чем больше у вас микросервисов, тем сложнее написать новый E2E-тест.
  4. Каждый раз, когда программный продукт пополняется новым микросервисом, необходимо соответствующим образом обновлять среду (например, конфигурацию Testcontainers), чтобы быть уверенным, что тесты все еще актуальны.
  5. Если продукт состоит из слишком большого количества микросервисов, становится практически невозможно написать надежный E2E-тест для всей системы.

Как видите, E2E-тесты работают до тех пор, пока программный проект не становится огромным и слишком сложным. Что же делать тогда?

Интеграционное тестирование по контракту

Во-первых, посмотрите еще раз на схему системы из предыдущей статьи.

Весь бизнес-сценарий можно описать следующими шагами:

  1. Пользователь отправляет сообщение через REST API на API-Service.
  2. Затем API-Service передает сообщение в кластер RabbitMQ.

Какие контракты мы имеем здесь? Посмотрите на схему ниже.

Я выделил контракты бледно-зелеными овалами:

Зачем нам вообще нужны контракты? Хотя дополнительные слои добавляют дополнительную сложность, контракты также помогают нам избавиться от интеграционных тестов в API-Service и Gain-Service. Взгляните на пример ниже.

Как мы уже обсуждали, UserMsgPush — это контракт. Есть две реализации: RabbitUserMsgPush и InMemoryUserMsgPush. И теперь в игру вступает принцип подстановки Лискова. Если упростить определение, то замена одной реализации интерфейса на другую не должна нарушать корректность программы. В данном случае нам не нужно привлекать RabbitMQ к тестированию API-Service! Потому что InMemoryUserMsgPush будет достаточно. Итак, вот преимущества:

  1. Мы можем покрыть API-Service только модульными тестами.
  2. Интеграционные тесты инкапсулируются только в сценарии RabbitUserMsgPush.
  3. Мы можем заменить одну реализацию UserMsgPush на другую, не трогая тесты в RabbitUserMsgPush.

Похоже, что контрактное тестирование — это серебряная пуля. Во-первых, мы сократили количество интеграционных тестов и упростили сценарии качества в сервисах. Во-вторых, мы полностью избавились от тестов E2E, которые сложно писать, выполнять и поддерживать. К сожалению, у контрактного тестирования есть и недостатки.

Недостатки контрактов

Нежелательная сложность кода

Микросервисы обычно не содержат много кода. Они часто предоставляют всего одну операцию. Например, API-Service принимает сообщение через REST API и отправляет его в RabbitMQ. И все. Поэтому дополнительные уровни, интерфейсы и абстракции могут показаться шумом. Почему я должен работать, используя фасад, если я могу просто действовать напрямую?

Хотя абстракции полезны, их обилие может сделать код более сложным, чем он должен быть.

Отсутствие локальных экспериментов

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

  1. Уменьшение размера пула соединений с базой данных.
  2. Изменение стратегии сериализации в производителе Kafka.
  3. Изменение SQL-запроса для повышения его эффективности.

Если в сервисе, над которым вы работаете, есть интеграционные тесты, вы можете просто настроить необходимые параметры и запустить тесты локально, чтобы проверить поведение. Но если сервис опирается на контракты, задача становится интригующей. У вас есть несколько подходов:

  1. Изменить контракт таким образом, чтобы свойства, которые вы обновляете, передавались в качестве параметров. Таким образом, вы добавляете в контракт дополнительные интеграционные тесты, которые проверяют валидность сценария. Но в этом случае контракт раскрывает слишком много деталей о своей реализации. Это нарушает всю идею абстракции и усложняет замену одной реализации на другую.
  2. Инкапсулируйте изменения желаемых свойств в качестве сценариев интеграции внутри контракта. В этом случае вы не передаете детали реализации пользователю контракта, но при этом значительно усложняете свои тесты. Ведь вам придется проверять различные комбинации возможных значений свойств.
  3. Добавьте необходимые интеграционные тесты непосредственно в кодовую базу сервиса. Итак, вы не усложняете контракт, верно? Да, но теперь сервис напрямую зависит от конкретной реализации контракта. Мы ввели контракты, чтобы полностью убрать интеграционные тесты из сервисов. И вот опять.

Обратная совместимость

Как я уже писал в предыдущей статье, главное преимущество 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-тестирование.

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

Оцените статью
devanswers.ru
Добавить комментарий