Русский
Русский
English
Статистика
Реклама

Pipe

Как я сделал веб-фреймворк без MVC Pipe Framework

23.02.2021 14:15:47 | Автор: admin

Проработав фулстек разработчиком около 10 лет, я заметил одну странность.
Я ни разу не встретил не MVC веб-фреймворк. Да, периодически встречались вариации, однако общая структура всегда сохранялась:


  • Codeigniter мой первый фреймворк, MVC
  • Kohana MVC
  • Laravel MVC
  • Django создатели слегка подменили термины, назвав контроллер View, а View Template'ом, но суть не изменилась
  • Flask микрофреймворк, по итогу все равно приходящий к MVC паттерну

Конечно, с моим мнением можно поспорить, можно продолжить перечислять, однако суть не в этом.


Меня беспокоило то, что за все время существования веб-разработки, MVC является, по сути, монополистом в проектировании приложений. Я не говорю что это плохо,
просто это казалось мне странным.

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


  1. REST (порой GraphQL или другие варианты) бэкенд, выполняющий роль провайдера данных.
  2. Frontend, написаный на каком-либо из фреймворков большой тройки.

Задачи, которые сейчас стоят перед бэкендом (если сильно упростить) это взять данные из базы, преобразовать в JSON (возможно дополнительно преобразовав структуру) и отправить в браузер.

В ходе этих размышлений, мой взгляд упал на ETL паттерн, и в определенный момент я понял, что он идеально подходит для всех задач, которые на данный момент стоят перед бэкендом.
Осознав это, я решил провести эксперимент, и результатом этого эксперимента стал Pipe Framework.


О фреймворке


В Pipe Framework (далее PF) нет понятий модель-представление-контроллер, но я буду использовать их для демонстрации его принципов.


Весь функционал PF строится с помощью "шагов" (далее Step).


Step это самодостаточная и изолированная единица, призванная выполнять только одну функцию, подчиняясь принципу единственной ответственности (single responsibility principle).


Более детально объясню на примере. Представим, у вас есть простая задача создать API ендпоинт для todo приложения.


При традиционном подходе, вам необходимо создать Todo модель, которая представляет собой таблицу в базе данных.
В контроллере, привязанном к роуту, вы будете использовать экземпляр модели, чтобы извлечь данные о todo тасках, трансформировать их в https ответ, и отправить пользователю.


Я выделил извлечь и трансформировать чтобы вы могли ассоциировать MVC концепты с концептами, которые я использую в PF.

То есть, мы можем провести аналогию между MVC (Модель-Представление-Контроллер) и ETL (Извлечение-Преобразование-Загрузка):


Model Extractor / Loader


Controller Transformer


View Loader


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


Как видите, я обозначил View как Loader. Позже станет понятно, почему я так поступил.

Первый роут


Давайте выполним поставленную задачу используя PF.


Первое, на что необходимо обратить внимание, это три типа шагов:


  • Extractor
  • Transformer
  • Loader

Как определиться с тем, какой тип использовать?


  1. Если вам надо извлечь данные из внешнего ресурса: extractor.
  2. Если вам надо передать данные за пределы фреймворка: loader.
  3. Если вам надо внести изменения в данные: transformer.

Именно поэтому я ассоциирую View с Loader'ом в примере выше. Вы можете воспринимать это как загрузку данных в браузер пользователя.

Любой шаг должен наследоваться от класса Step, но в зависимости от назначения реализовывать разные методы:


class ESomething(Step):    def extract(self, store):        ...class TSomething(Step):    def transform(self, store):        ...class LSomething(Step):    def load(self, store):        ...

Как вы можете заметить, названия шагов начинаются с заглавных E, T, L.
В PF вы работаете с экстракторами, трансформерами, и лоадерами, названия которых слишком длинные, если использовать их как в примере:


class ExtractTodoFromDatabase(Extractor):    pass

Именно поэтому, я сокращаю названия типа операции до первой буквы:


class ETodoFromDatabase(Extractor):    pass

E значит экстрактор, T трансформер, и L лоадер.
Однако, это просто договоренность и никаких ограничений со стороны фреймворка нет, так что можете использовать те имена, которые захотите :)


Для того что бы выполнить задачу, прежде всего нам нужно декомпозировать функционал на более мелкие операции:


  1. Извлекаем данные из базы
  2. Преобразовываем данные в JSON
  3. Отправляем данные в браузер посредством HTTP.

Итак, нам нужен будет 1 экстратор, 1 трансформер, и 1 лоадер.
К счастью, в PF есть набор предопределенных шагов, и они полностью покрывают описаные выше операции. Но, тем не менее, нам все-таки придется создать экстрактор, потому что нужно будет прописать данные доступа к базе данных.


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


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


Для этих целей, в PF предусмотрен @configure декоратор. То есть, вы просто перечисляете настройки, которые хотите добавить в шаг, следующим образом:


DATABASES = {    'default': {        'driver': 'postgres',        'host': 'localhost',        'database': 'todolist',        'user': 'user',        'password': '',        'prefix': ''    }}DB_STEP_CONFIG = {    'connection_config': DATABASES}

и потом передаете как аргумент декоратору, примененному к классу:


@configure(DB_STEP_CONFIG)class EDatabase(EDBReadBase):    pass

Итак, давайте создадим корневую папку проекта:


pipe-sample/


Затем папку src внутри pipe-sample:


pipe-sample/    src/

Все шаги, связанные с базой данных, будут находится в db пакете, давайте создадим и его тоже:


pipe-sample/    src/        db/            __init__.py

Создайте config.py файл с настройками для базы данных:


pipe-sample/src/db/config.py


DATABASES = {    'default': {        'driver': 'postgres',        'host': 'localhost',        'database': 'todolist',        'user': 'user',        'password': '',        'prefix': ''    }}DB_STEP_CONFIG = {    'connection_config': DATABASES}

Затем, extract.py файл для сохранения нашего экстрактора и его концигурации:


pipe-sample/src/db/extract.py


from src.db.config import DB_STEP_CONFIG # наша конфигурация"""PF включает в себя несколько дженериков для базы данных,которые вы можете посмотреть в API документации"""from pipe.generics.db.orator_orm.extract import EDBReadBase@configure(DB_STEP_CONFIG) # применяем конфигурацию к шагу class EDatabase(EDBReadBase):    pass     # нам не надо ничего добавлять внутри класса    # вся логика уже имплементирована внутри EDBReadBase

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

Теперь мы готовы к созданию первого пайпа.


Добавьте app.py в корневую папку проекта. Затем скопируйте туда этот код:


pipe-sample/app.py


from pipe.server import HTTPPipe, appfrom src.db.extract import EDatabasefrom pipe.server.http.load import LJsonResponse from pipe.server.http.transform import TJsonResponseReady@app.route('/todo/') # декоратор сообщает WSGI приложению, что этот пайп обслуживает данный маршрутclass TodoResource(HTTPPipe):     """    мы расширяем HTTPPipe класс, который предоставляет возможность описывать схему пайпа с учетом типа HTTP запроса    """    """    pipe_schema это словарь с саб пайпами для каждого HTTP метода.     'in' и 'out' это направление внутри пайпа, когда пайп обрабатывает запрос,    он сначала проходит через 'in' и затем через 'out' пайпа.    В этом случае, нам ничего не надо обрабатывать перед получением ответа,     поэтому опишем только 'out'.    """    pipe_schema = {         'GET': {            'out': (                # в фреймворке нет каких либо ограничений на порядок шагов                # это может быть ETL, TEL, LLTEETL, как того требует задача                # в этом примере просто так совпало                EDatabase(table_name='todo-items'),                TJsonResponseReady(data_field='todo-items_list'), # при извлечении данных EDatabase всегда кладет результат запроса в поле {TABLE}_item для одного результата и {TABLE}_list для нескольких                LJsonResponse()            )        }    }"""Пайп фреймворк использует Werkzeug в качестве WSGI-сервера, так что аргументы должны быть знакомы тем кто работал, например, с Flask. Выделяется только 'use_inspection'. Inspection - это режим дебаггинга вашего пайпа.Если установить параметр в True до начала воспроизведения шага, фреймворк будет выводить название текущего шага и содержимое стор на этом этапе."""if __name__ == '__main__':    app.run(host='127.0.0.1', port=8080,            use_debugger=True,            use_reloader=True,            use_inspection=True            )

Теперь можно выполнить $ python app.py и перейти на http://localhost:8000/todo/.


Из примера выше довольно сложно понять как выглядит реализация шага, поэтому ниже я приведу пример из исходников:


class EQueryStringData(Step):    """    Generic extractor for data from query string which you can find after ? sign in URL    """    required_fields = {'+{request_field}': valideer.Type(PipeRequest)}    request_field = 'request'    def extract(self, store: frozendict):        request = store.get(self.request_field)        store = store.copy(**request.args)        return store

Стор


На данный момент, стор в PF это инстанс frozendict.
Изменить его нельзя, но можно создать новый инстанс используя frozendict().copy() метод.


Валидация


Мы помним, что шаги являются самостоятельными единицами функционала, но иногда они могут требовать наличия определенных данных в сторе для выполнения каких-либо операций (например id пользователя из URL). В этом случае, используйте поле required_fields в конфигурации шага.


PF использует Valideer для валидации. На данный момент, я рассматриваю альтернативы, однако в случае смены библиотеки принцип останется тот же.


Пример


Все, что нам надо сделать это написать dict с необходимыми полями в теле шага (здесь вы найдете больше информации о доступных валидаторах: Valideer).


class PrettyImportantTransformer(Step):    required_fields = {'+some_field': valideer.Type(dict)} # `+` значит обязательное поле

Динамическая валидация


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


class EUser(Step):    pk_field = 'id' # EUser будет обращаться к полю 'id' в сторе    required_fields = {'+{pk_field}': valideer.Type(dict)} # все остальное так же

Пайп фреймворк заменит это поле на значение pk_field автоматически, и затем валидирует его.


Объединение шагов


Вы можете объединить два или более шага в случае, если вам необходимо контролировать порядок выполнения.


В этом примере я использую оператор | (OR)


    pipe_schema = {        'GET': {            'out': (                # В случае если EDatabase бросает любое исключение                 # выполнится LNotFound, которому в сторе передастся информация об исключении                EDatabase(table_name='todo-items') | LNotFound(),                 TJsonResponseReady(data_field='todo-items_item'),                LJsonResponse()            )        },

Так же есть оператор & (AND)


    pipe_schema = {        'GET': {            'out': (                # В этом случае оба шага должны выполниться успешно, иначе стор без изменений перейдет к следующему шагу                 EDatabase(table_name='todo-items') & SomethingImportantAsWell(),                 TJsonResponseReady(data_field='todo-items_item'),                LJsonResponse()            )        },

Хуки


Чтобы выполнить какие-либо операции до начала выполнения пайпа, можно переопределить метод: before_pipe


class PipeIsAFunnyWord(HTTPPipe):    def before_pipe(self, store): # в аргументы передается initial store. В случае HTTPPipe там будет только объект PipeRequest        pass

Также есть хук after_pipe и я думаю нет смысла объяснять, для чего он нужен.


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


Пример использования из исходников фреймворка:


class HTTPPipe(BasePipe):    """Pipe structure for the `server` package."""    def interrupt(self, store) -> bool:        # If some step returned response, we should interrupt `pipe` execution        return issubclass(store.__class__, PipeResponse) or isinstance(store, PipeResponse)

Потенциальные преимущества


Разрабатывая Pipe Framework, я ничего от него не ожидал, однако в ходе работы я смог выделить довольно большое количество преимуществ такого подхода:


  1. Принудительная декомпозиция: разработчик вынужден разделять задачу на атомарные шаги. Это приводит к тому, что сначала надо подумать, а потом делать, что всегда лучше, чем наоборот.
  2. Абстрактность: фреймворк подразумевает написание шагов, которые можно применить в нескольких местах, что позволяет уменьшить количество кода.
  3. Прозрачность: любая, пусть даже и сложная логика, спрятанная в шагах, призвана выполнять понятные для любого человека задачи. Таким образом, гораздо проще объяснить даже нетехническому персоналу о том, что происходит внутри через преобразование данных.
  4. Самотестируемость: даже без написаных юнит тестов, фреймворк подскажет вам что именно и в каком месте сломалось за счет валидации шагов.
  5. Юнит-тестирование осуществляется гораздо проще, нужно только задать начальные данные для шага или пайпа и проверить, что получается на выходе.
  6. Разработка в команде тоже становится более гибкой. Декомпозировав задачу, можно легко распределить различные шаги между разработчиками, что практически невозможно сделать при традиционном подходе.
  7. Постановка задачи сводится к предоставлению начального набора данных и демонстрации необходимого набора данных на выходе.

Фреймворк на данный момент находится в альфа-тестировании, и я рекомендую экспериментировать с ним, предварительно склонировав с Github репозитория. Установка через pip так же доступна


pip install pipe-framework


Планы по развитию:


  1. Django Pipe: специальный тип Pipe, который можно использовать как Django View.
  2. Смена Orator ORM на SQL Alchemy для Database Generics (Orator ORM библиотека с приятным синтаксисом, но слабой поддержкой, парой багов, и недостаточным функционалом в стабильной версии).
  3. Асинхронность.
  4. Улучшеный Inspection Mode.
  5. Pipe Builder специальный веб-дашбоард, в котором можно составлять пайпы посредством визуальных инструментов.
  6. Функциональные шаги на данный момент шаги можно писать только в ООП стиле, в дальнейшем планируется добавить возможность использовать обычные функции

В целом, планируется двигать фреймворк в сторону упрощения, без потери функциональности. Буду рад вопросам и контрибьюшнам.


Хорошего дня!

Подробнее..

Jenkins Pipeline. Что это и как использовать в тестировании

08.02.2021 16:12:50 | Автор: admin

Меня зовут Александр Михайлов, я работаю в команде интеграционного тестирования компании ЮMoney.

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

Надеюсь, что эта статья будет интересна как новичкам, так и тем, кто съел собаку в автоматизации тестирования. Мы рассмотрим базовый синтаксис Jenkins Pipeline, разберемся, как создать джобу на основе пайплайна, а также я расскажу про опыт внедрения неочевидной функциональности в CI запуска и дожатия автотестов по условию.

Запуск автотестов на Jenkins инструкция

Не новость, что автотесты эффективнее всего проводить после каждого изменения системы. Запускать их можно локально, но мы рекомендуем делать это только при отладке автотестов. Больший профит автотесты принесут при запуске на CI. В качестве CI-сервера у нас в компании используется Jenkins, в качестве тестового фреймворка JUnit, а для отчетов Allure Report.

Чтобы запускать тесты на Jenkins, нужно создать и сконфигурировать джобу.

Для этого достаточно выполнить несколько несложных шагов.

1) Нажать Создать, выбрать задачу со свободной конфигурацией и назвать ее, например, TestJob.

Естественно, для этого у вас должны быть права в Jenkins. Если их нет, нужно обратиться к администратору Jenkins.

2) Указать репозиторий, откуда будет выкачиваться код проекта: URL, credentials и branch, с которого все будет собираться.

3) Добавить нужные параметры, в этом примере количество потоков (threadsCount) и список тестов для запуска (testList).

Значение *Test для JUnit означает Запустить все тесты.

4) Добавить команду для запуска тестов.

Наш вариант запускается на Gradle: мы указываем таску теста и передаем параметры в тесты.

./gradlew test -PthreadsCount=$threadsCount -PtestList=$testList

Можно выполнить шаг сборки Выполнить команду shell, либо через Gradle Plugin использовать шаг Invoke Gradle Script.

5) Нужно добавить Allure-report (должен быть установлен https://plugins.jenkins.io/allure-jenkins-plugin/) в Послесборочные операции, указав путь к артефактам Allure после прогона (по умолчанию allure-result).

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

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

Несложно заметить, что тесты у нас падают.

Почему падают тесты

Падения могут случаться по разным причинам. В нашем случае на это влияют:

  • ограниченные ресурсы тестового стенда,

  • большое число микросервисов (~140); если при запуске интеграционных тестов какой-то один микросервис подтормаживает, тесты начинают валиться,

  • большое число интеграционных тестов (>3000 E2E),

  • врожденная нестабильность UI-тестов.

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

Что такое дожим

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

Дожимать? Опасно же!

Возразите вы и будете полностью правы. Безусловно, дожим автотестов может спровоцировать пропуск дефекта на продакшн. Баг может быть плавающим при повторном запуске успешно просочиться и попасть на продакшн.

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

Как решать задачу с дожимами

Мы пробовали разные решения: использовали модификацию поведения JUnit 4, JUnit 5, писали обертки на Kotlin. И, к сожалению, каждый раз реализация завязывалась на фичах языка или фреймворка.

Если процесс запускался с помощью JUnit 4 или JUnit 5, возможность перезапустить тесты была только сразу при падении. Тест упал, перезапустили его несколько раз подряд и если сбоил какой-то микросервис из-за нагрузки, либо настройки тестовой среды были некорректные, то тест все три раза падал.

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

Мы взглянули на проблему шире, решили убрать зависимость от тестового фреймворка или языка и реализовали перезапуск на более высоком уровне на уровне CI. И сделали это с помощью Jenkins Pipeline.

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

Что такое Jenkins Pipeline

Jenkins Pipeline набор плагинов, позволяющий определить жизненный цикл сборки и доставки приложения как код. Он представляет собой Groovy-скрипт с использованием Jenkins Pipeline DSL и хранится стандартно в системе контроля версий.

Существует два способа описания пайплайнов скриптовый и декларативный.

1. Scripted:

node {stage('Example') {try {sh 'exit 1'}catch (exc) { throw exc}}}

2. Declarative

pipeline {agent anystages {stage("Stage name") {steps {}}}}

Они оба имеют структуру, но в скриптовом она вольная достаточно указать, на каком слейве запускаться (node), и стадию сборки (stage), а также написать Groovy-код для запуска атомарных степов.

Декларативный пайплайн определен более жестко, и, соответственно, его структура читается лучше.

Рассмотрим подробнее декларативный пайплайн.

  1. В структуре должна быть определена директива pipeline.

  2. Также нужно определить, на каком агенте (agent) будет запущена сборка.

  3. Дальше идет определение stages, которые будут содержаться в пайплайне, и обязательно должен быть конкретный стейдж с названием stage(name). Если имени нет, тест упадет в runtime с ошибкой Добавьте имя стейджа.

  4. Обязательно должна быть директива steps, в которой уже содержатся атомарные шаги сборки. Например, вы можете вывести в консоль Hello.

pipeline { // определение декларативного pipelineagent any // определяет, на каком агенте будет запущена сборкаstages { // содержит стейджи сборкиstage("Stage name") { // отдельный стейдж сборкиsteps { // набор шагов в рамках стейджаecho "Hello work" // один из шагов сборки}}}}

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

pipeline {stages {stage("Post stage") {post { // определяет действия по завершении стейджаsuccess { // триггером исполнения секции является состояние сборки archiveArtifacts artifacts: '**/target/*'}}}}post { // после всей сборкиcleanup {cleanWs()}}}

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

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

Сначала я даже растерялся и не знал, с чего начать настолько там много информации. Если вы первый раз сталкиваетесь с написанием пайплайна, начать лучше со знакомства с генераторами фрагментов пайплайна из UI-интерфейса.

Если к URL вашего веб-интерфейса Jenkins добавить ендпойнт /pipelines-syntax, откроется страница, в которой есть ссылки на документацию и два сниппет-генератора, позволяющие генерировать пайплайн даже без знания его синтаксиса:

  • Declarative sections generator

  • Snippet Generator

Генераторы фрагментов помощники в мире Jenkins

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

  • Declarative sections generator (JENKINS-URL/directive-generator) генератор фрагментов для декларативного описания пайплайна.

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

stage(start tests){steps{ //One or more steps needs to be included within the steps block}}

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

  • В Sample Step выбрать build: Build a job.

  • (Дальше функционал подсказывает) необходимо определить параметры, которые будут переданы в джобу (для примера задано branch, project).

  • Нажать кнопку Generate в результате сформируется готовый рабочий код.

Изменим параметры джобы на те, которые определили при ее создании.

build job QA/TestJob, parameters: [                        string(name: 'threadsCount', value: 16),                         string(name: 'testList', value: *Test),                        string(name: 'runId', value: runId)]

где threadsCount - кол-во потоков для распараллеливания тестов, testList - список тестов для запуска, runId - идентификатор прогона тестов. Для чего нужны эти параметры, расскажу далее.

Snippet Generator удобен тем, что подсвечивает шаги в зависимости от установленных плагинов. Если у вас есть, например, Allure-report, то плагин сразу покажет наличие расширения и поможет сгенерировать код, который будет работать.

Вставим сгенерированный код степа в пайплайн на следующем шаге.

Запуск тестов с помощью Pipeline инструкция

Итак, давайте с помощью Declarative sections generator создадим пайплайн. В нем нужно указать директивы: pipeline, agent (агент, на котором будет запускаться пайплайн), а также stages и steps (вставка ранее сгенерированного кода).

Так получится пайплайн, который запустит джобу, созданную на предыдущем шаге через UI.

pipeline {agent {label any}stages {stage("start test") {steps{build job: '/QA/TestJob',parameters: [string(name: 'threadsCount', value: threadsCount),string(name: 'runId', value: runId),string(name: 'testList', value: testList)]}}} }

Напомню, что в параметры для запуска тестов мы передавали количество потоков и список тестов. Теперь к этому добавляем параметр runId (идентификатор прогона тестов) он понадобится позднее для перезапуска конкретного сьюта тестов.

Чтобы запустить пайплайн, нужно создать проект.

  1. New Item -> Pipeline.

Для этого нужно нажать на кнопку Создать проект и выбрать не джобу со свободной конфигурацией, а джобу Pipeline осталось указать ей имя.

  1. Добавить параметры runId, threadsCount, testList.

  1. Склонировать из Git.

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

Готово, джобу можно запускать.

Хотим добавить немного дожатий

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

Для реализации нужно:

  1. вынести шаг запуска тестов в библиотечную функцию (shared steps),

  2. получить упавшие тесты из прогона,

  3. добавить условия перезапуска.

Теперь немного подробнее про каждый из этих шагов.

Многократное использование шагов Shared Steps

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

Решение нашлось не сразу. Оказывается, для многократного использования кода в Jenkins есть встроенный механизм shared libraries, который позволяет описать методы один раз и затем применять их во всех пайплайнах.

Существуют два варианта подключения этой библиотеки.

  1. Написанный проект/код подключить через UI Jenkins. Для этого требуются отдельные права на добавление shared libraries или привлечение девопс-специалистов (что не всегда удобно).

  2. Хранить код в отдельном проекте или в проекте с пайплайнами. При использовании этот код подключается как динамическая библиотека и выкачивается каждый раз при запуске пайплайна.

Мы используем второй вариант размещаем shared steps в проекте с пайплайнами.

Для этого в проекте нужно:

  • создать папку var,

  • в ней создать файл с названием метода, который планируется запускать например, gradlew.groovy,

  • стандартно определить имя метода (должен называться call), то есть написать def call и определить входящие параметры,

  • в теле метода можно написать произвольный Groovy-код и/или Pipeline-степы.

Pipeline script:

//Подключение библиотеки//https://www.jenkins.io/doc/book/pipeline/shared-libraries/ - описание с картинкамиlibrary identifier: 'pipeline-shared-lib'  pipeline {stages {stage("Build") {steps {gradlew(tasks: ["build"]) // вызов метода из библиотеки}}}}

var/gradlew.groovy

def call(Map<String, List<String>> parameters) {  // стандратное имя для глобального методаdef tasks = parameters["tasks"]def args = parameters["args"] ?: []sh "./gradlew ${args.join(' ')}     ${tasks.join(' ')}"    // произвольный groovy код + pipeline-методы}

Вынесение запуска тестов в shared steps в /var

  1. Выносим startTests.groovy в /var.

Во-первых, нужно вынести запуск тестов в отдельный метод. Выглядит это так создаем файл, называем метод def call, берем кусок кода, который был в пайплайне, и выносим его в этот step.

def call(Map<String, String> params) {    def threadsCount = params["threadsCount"] ?: "3"    def testList = params["testList"] ?: "*Test"    stage("start test job") {      runTest = build job: '/QA/TestJob',                  parameters: [                         string(name: 'threadsCount', value: threadsCount),                         string(name: 'runId', value: runId),                         string(name: 'testList', value: testList)],                         propagate: false   }}

Для передачи параметров используется Map<String, String>. Почему не передавать каждый параметр отдельно? Это не очень удобно, т.к. в Groovy параметры не обозначены по названиям. При использовании Map синтаксис позволяет указать key:value через двоеточие. В коде (в месте вызова метода) это отображается наглядно.

Структура проекта будет выглядеть так.

  1. Подключение shared steps как внешней библиотеки.

Дальше нужно добавить вынесенные шаги в пайплайн. При динамической подгрузке библиотек (во время запуска пайплайна) эти шаги выкачиваются из репозитория и подключаются на лету.

Сделать это можно с помощью сниппет-генератора выбираем степ library и указываем ветку, в которую все будет собираться и репозиторий. Дальше нажимаем кнопку Сгенерировать и вставляем получившийся пайплайн.

library changelog: false,    identifier: 'shared-lib@master',    retriever: modernSCM([   $class    : 'GitSCMSource',   remote   : 'ssh://git@bitbucket.ru/qa/jenkins-groovy-scripts.git'])

Теперь после подключения shared steps вместо шага запуска тестов build нужно вставить startTest. Не забудьте, что имя метода должно совпадать с именем файла.

Теперь наш пайплайн выглядит так.

//Динамическое подключение библиотекиlibrary changelog: false,    identifier: 'shared-lib@master',    retriever: modernSCM([   $class   : 'GitSCMSource',  remote   : 'ssh://git@bitbucket.ru/qa/jenkins-groovy-scripts.git'])pipeline {   agent {  label any  }  stages { stage("start test") {    steps{   startTests(runId: runId ) //Вызов метода из библиотеки  }    }  }}

Первый шаг реализован, теперь можно многократно запускать тесты в разных местах. Переходим к 2 шагу.

Получение упавших тестов из прогона

Теперь нам нужны упавшие тесты. Каким образом их извлечь?

  • Установить в Jenkins плагин JUnit Test Result Report и использовать его API.

  • Взять результаты прогона JUnit (обычно в формате XML), распарсить и извлечь нужные данные.

  • Запросить список упавших тестов из нужного места.

В нашем случае таким местом является собственный написанный сервис Reporter. Во время прогона тестов результат по каждому из них отправляется именно туда. В конце прогона делаем http-запрос и получаем упавшие тесты.

http://reporter:8080/failedTests/$runId

Добавление условий перезапуска

На этом шаге следует добавить getFailedTests.groovy в /var. Представим, что у вас есть такой сервис Reporter. Нужно назвать файл getFailedTests, сделать запрос httpRequest в этот сервис и распарсить его.

def call(String runId) {    def response = httpRequest httpMode: 'GET',     url: "http://reporter:8080/failedTests/$runId"     def json = new JsonSlurper().parseText(response.content)        return json.data}

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

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

Условия перезапуска

Какие условия для перезапуска можно реализовать?

Приведу часть условий, которые используем мы.

1) Если нет упавших тестов, прогон завершается.

if (countFailedTests == 0) {echo FINISHED   }

2) Как я уже писал выше, на тестовой среде ресурсы ограничены, и бывает такое, что ТС захлебывается в большом количестве параллельных тестов. Чтобы на дожатии избежать падений тестов по этой причине, понижаем число потоков на повторном запуске. Именно для этого при создании джобы и в самом пайплайне мы добавили параметр threadsCount.

Если и после уменьшения потоков тесты не дожимаются (количество упавших тестов на предыдущем прогоне равно числу упавших тестов на текущем), тогда считаем, что дело не во влиянии ТС на тесты. Останавливаем прогон для анализа причин падений и тем самым предотвращаем последующий холостой запуск.

if (countFailedTests == previousCountFailedTests) { echo TERMINATED - no one new passed test after retry}

3) Третья и самая простая проверка состоит в том, что если падает большое количество тестов, то дожимать долго. Скорее всего, причина падений какая-то глобальная, и ее нужно изучать.

Для себя мы определили: если тестов > 40, дожимать не автоматически не будем, потому что 40 наших E2E могут проходить порядка 15 минут.

if (countFailedTests > FAILEDTESTSTRESHOLD) {   echo TERMINATED - too much failed tests   }
Получился метод:

https://github.com/useriq/retry-flaky-tests/blob/master/jenkins-pipeline-retry/var/testsWithRerun.groovy

def call(Map<String, String> params) {    assert params["runId"]    def threadsCount = params["threadsCount"] ?: "8"    def testList = params["testList"] ?: "*Test"    def runId = params["runId"]    int FAILED_TESTS_TRESHOLD = 40    def countFailedTests = 0    def failedTests    int run = 1    boolean isFinished = false    int threads = threadsCount as int    while (run <= Integer.valueOf(runCount) && !isFinished) {        if (run == 1) {            startTests()        } else {            if (countFailedTests > 0) {                threads = reduceThreads(threads)                testList = failedTests.toString().minus('[').minus(']').minus(' ')                startTests()            }        }        stage("check ${run}_run result ") {            failedTests = getFailedTests(runId)            def previousCountFailedTests = countFailedTests            countFailedTests = failedTests.size()            if (countFailedTests == 0) {                echo "FINISHED"                isFinished = true            }            if (countFailedTests > FAILED_TESTS_TRESHOLD) {                echo "TERMINATED - too much failed tests > ${FAILED_TESTS_TRESHOLD}"                isFinished = true            }            if (countFailedTests == previousCountFailedTests) {                echo "TERMINATED - no one new passed test after retry"                isFinished = true            }        }        run += 1    }}

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

Итоговый pipeline

Итак, все 3 шага реализованы итоговый пайплайн выглядит так.

library changelog: false,       identifier: 'shared-lib@master',       retriever: modernSCM([               $class       : 'GitSCMSource',               remote       : 'ssh://git@bitbucket.ru/qa/jenkins-groovy-scripts.git']) assert runId != nullpipeline {   agent {       label any   }   stages {       stage("start test") {           steps {             testsWithRerun(runId: runId)           }       }   }}

Визуализация с Blue Ocean

Как все это выглядит при прогоне в Jenkins? У нас, к примеру, для визуализации в Jenkins установлен плагин Blue Ocean.

На картинке ниже можно увидеть, что:

  1. запустился метод testwith_rerun,

  2. прошел первый запуск,

  3. прошла проверка упавших тестов,

  4. запустился второй прогон,

  5. после успешной проверки джоба завершилась.

Вот так выглядит визуализация нашего настоящего прогона.

В реальном примере две ветки: мы параллельно запускаем два проекта с тестами (на Java и Kotlin). Можно заметить, что тесты одного проекта прошли с первого раза, а вот тесты другого пришлось дожимать еще раз. Таким образом визуализация помогает найти этап, на котором падают тесты.

А так выглядит реальный timeline приемки релиза.

После первого прогона отправили дожимать упавшие тесты. Во второй раз упавших тестов намного меньше, дожимаем в третий раз и вуаля, успешный build.

Задача решена.

Итог

Мы перенесли логику перезапусков упавших тестов из тестового проекта на уровень выше на CI. Таким образом сделали механизм перезапуска универсальным, более гибким и независимым от стека, на котором написаны автотесты.

Раньше наши тесты дожимались безусловно, по несколько раз, с неизменным количеством параллельных потоков. При перегрузке тестового стенда, некорректных настройках тестового окружения либо каких-то других проблемах красные тесты перезапускались фиксированное число раз без шансов дожаться. В худшем случае прогоны могли длиться часами. Добавив условия fail fast, мы сократили это время.

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

Какой профит мы получили:

  • уменьшили time-to-market тестируемых изменений,

  • сократили длительность аренды тестового стенда под приемочное тестирование,

  • увеличили пропускную способность очереди приемочного тестирования,

  • не завязаны на тестовый фреймворк (под капотом может быть что угодно дожатия будут работать),

  • поделились знаниями об использовании Jenkins Pipeline.

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

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru