Я искренне пытался заставить ChatGPT сгенерировать что-то приемлемое))

История о том, как мы развивали наш CI процесс для монолитного Python-репозитория с авто тестами, возникавшие проблемы и примеры их решений. Поговорим о Docker, линтерах, Allure TestOps и многом другом.

Добрый день. Меня зовут Анатолий Бобунов, я SDET (Software Development Engineer in Test) в EXANTE. В этой статье я расскажу о той части моей работы, которая касается CI (Continuous Integration) в нашем проекте. Кратко опишу состояние CI на момент моего прихода в компанию, расскажу о ключевых этапах развития CI, поделюсь проблемами, которые возникали, и решениями, которые мы принимали. Я понимаю, что о каждом сегменте данной статьи можно написать отдельный текст, но начать я решил с исторического обзора.

На данный момент, у нас есть монолитный репозиторий с авто тестами для всего бэкенда. Тесты пишем на Python с использованием Pytest в качестве раннера для тестов. Запускаются тесты внутри Dockerfile в GitLab CI. Результаты авто тестов отправляются в Allure TestOps, часть информации для аналитики отправляется в Grafana, в Slack отправляются кастомные уведомления. Так как сервисов для тестирования много, то тесты для каждого из тестируемых сервисов находятся в отдельной подпапке в базовой папке tests.

Состояние CI на момент моего прихода

Я пришел в компанию для оптимизации тестового фреймворка и решения специфичных для автоматизаторов проблем. Когда я начал работать в компании, в пайплайне (pipeline) была всего одна стадия Tests, в которой тесты для каждого сервиса запускались отдельной задачей (job). Количество тестов и время прогона задачи для сервисов сильно различалось. Разброс по времени был от 5 минут до 1 часа. В Slack приходило уведомление о состоянии прогона, но за подробностями нужно было идти в лог Gitlab-CI. 

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

Dockerfile для тестового окружения

Команда по тестированию – распределенная и в основном работает на Linux и macOS, лишь изредка кто-то использует Windows. Чтобы избежать проблем с совместимостью библиотек и операционных систем, было решено изолировать всё тестовое окружение в Docker образе. 

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

# Используем официальный образ Python 3.9 с минимальной конфигурацией
FROM python:3.9-slim

# Копируем все файлы из текущей директории на хосте в директорию ./project в контейнере
COPY ./ ./project

# Устанавливаем рабочую директорию внутри контейнера
WORKDIR ./project

# Добавляем директорию /project в переменную PATH
ENV PATH=$PATH:/project

# Создаем директории allure-results и log, если они еще не существуют
# allure-results/ - требуется для allure репортов
# log/ - папка куда складываем разнообразные лог файлы
RUN mkdir allure-results || true && mkdir log || true

# Обновляем пакеты и устанавливаем необходимые зависимости, затем очищаем кэш для уменьшения размера образа
RUN apt-get update && apt-get install --no-install-recommends -y wget python3-pip python3-dev python3-venv default-jre gcc procps \
    && apt-get -y autoremove --purge && apt-get -y clean \
    && rm -rf /var/lib/apt/lists/* && rm -rf /var/cache/apt

# Обновляем pip и устанавливаем зависимости из файла requirements.txt
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt

# Загружаем последнюю версию allurectl и делаем ее исполняемой
# allurectl - cli интерфейс для взаимодействия с Allure testops
RUN wget https://github.com/allure-framework/allurectl/releases/latest/download/allurectl_linux_386 -O ./allurectl && chmod +x ./allurectl

Побочным развитием данной задачи, стала идея, что, если в Merge Request (MR) присутствуют изменения в конфигурационных файлах версий библиотек или Dockerfile, мы хотели бы дополнительно запускать сборку тестовой версии Docker образа. Далее в пайплайне MR использовать этот тестовый докер образ, чтобы весь код MR проверялся на образе на мастер-ветке. При вливании MR в мастер или дев-ветку, тестовый докер образ обновляет мастер или дев-образ и становится основным. 

Тэг докер образа хранится в виде переменной в docker.env и передается дальше по всем джобам.

Updating the Docker image
# Файлы, при изменении которых должна запускаться сборка docker образа
.changes_def:
 changes: &changes-def
   - Dockerfile
   - requirements.txt
   - .dockerignore
   - .gitlab/rebuild-docker-stage.yml

# Правила для запуска сборки docker образа
.rules_push:
 rules:
   - &rules-push
     if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_REF_PROTECTED == "true"'  # Master and Dev branches is protected
     changes: *changes-def

.docker_def: &docker_def
 image: docker:24
 tags:
   - docker
 services:
   - name: docker:dind
     command: [ "--tls=false", "--insecure-registry=my-nexus-registry" ]
 before_script:
   - docker login -u $NEXUS_LOGIN -p $NEXUS_PASS $REGISTRY_URL

build_docker:
 stage: build-docker
 <<: *docker_def
 script:
   - |
     DOCKER_BUILDKIT=1 docker build \
     --tag $IMAGE_TAG . \
     --cache-from $IMAGE_TAG \
     --label "org.opencontainers.image.title=$CI_PROJECT_TITLE" \
     --label "org.opencontainers.image.url=$CI_PROJECT_URL" \
     --label "org.opencontainers.image.created=$CI_JOB_STARTED_AT" \
     --label "org.opencontainers.image.revision=$CI_COMMIT_SHA" \
     --label "org.opencontainers.image.author=$CI_COMMIT_AUTHOR" \
     --label "org.opencontainers.image.version=$CI_COMMIT_REF_NAME"
   - docker push $IMAGE_TAG
   - echo "IMAGE_TAG=$IMAGE_TAG" >> docker.env
 rules:
   - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
     variables:
       IMAGE_TAG: $IMAGE_TAG_BRANCH
     changes: *changes-def
   - *rules-push
 artifacts:
   reports:
     dotenv: docker.env
   expire_in: 1 days

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

Основной проблемой данного подхода является время ожидания сборки образа. Так как сборка происходит при каждой инициализации нового пайплайна в MR, в котором есть изменения в отслеживаемых файлах. Среднее время сборки – примерно 2 минуты. Пока не критично, но можно уже задумываться над оптимизацией данного этапа.

Интеграция Allure-Testops

Одной из первых задач было прикрутить Аллюр репортинг. Allure-Testops — это платформа для управления и анализа тестирования, предоставляющая инструменты для создания отчетов, визуализации данных тестирования и интеграции с CI/CD процессами. Установкой Allure-Testops на наше окружение занимался мой коллега. С моей стороны требовалась помощь в создании нужных интеграций, апдейте пайплайна и фреймворка. 

Чтобы отправить данные в Allure Testops, нам нужно:

  • залогиниться в Allure Testops (allurectl auth login),

  • создать прогон авто тестов в Allure Testops (allurectl job-run start),

  • отправить данные в Allure Testops.

Чтобы запустить тесты, используя эту команду, нам нужно уже быть залогиненными и иметь открытый job-run в Allure Testops. Стоит учитывать, что на стадии Tests могут запускаться одновременно несколько job. Если в MR изменен код который используется при тестировании нескольких сервисов, то будут запущены все джобы для этих сервисов. 

Пришлось “временно” создать промежуточный этап Prepare allure run, в котором проводится вся подготовительная работа для взаимодействия с allure. Все данные сохраняются в артефакты-джобы и передаются дальше в тестовые джобы.

prepare_allure:
 stage: prepare-allure-run
 tags:
   - docker
 image: $IMAGE_TAG
 script:
   - export PIPELINE_DATE=$(date +%Y_%b_%d)
   - export PIPELINE_DATETIME=$(date +%b_%d_%H:%M)
   - echo "PIPELINE_DATE=$PIPELINE_DATE" >> variables.env
   - echo "PIPELINE_DATETIME=$PIPELINE_DATETIME" >> variables.env
   - export ALLURE_LAUNCH_TAGS="${PIPELINE_DATE}, ${ALLURE_LAUNCH_TAGS}"
   - export ALLURE_LAUNCH_NAME="${PIPELINE_DATETIME} - ${ALLURE_LAUNCH_NAME}"
   - allurectl auth login --endpoint $ALLURE_ENDPOINT --token $ALLURE_TOKEN
   - allurectl job-run start --launch-name "${ALLURE_LAUNCH_NAME}" --launch-tags "${ALLURE_LAUNCH_TAGS}"
   - allurectl job-run env >> variables.env
 artifacts:
   reports:
     dotenv: variables.env
   expire_in: 2 days
Preparing Allure TestOps run

Collect стадия: базовая проверка корректности кода

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

В тот момент я находился на стадии ознакомления с фреймворком, и первым делом я решил попытаться запустить тесты для каждого сервиса. Для проверки корректности  я запускал тесты с помощью pytest с командой pytest {path_to_suite} --collect-only.

Опция --collect-only в pytest собирает и отображает список всех тестов, которые будут выполнены без их фактического запуска.

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

В результате внедрения collect стадии удалось сократить время на исправление “детских” ошибок в коде и проблем возникающих из-за неправильных конфигураций.

.collect: &collect
  stage: collect-tests
  tags:
    - docker
  image: $IMAGE_TAG
  before_script:
    - mkdir log || true
  artifacts:
    name: "$CI_JOB_NAME"
    paths:
      - log/
      - pytest.log
    expire_in: 3 days


eb_collect:
  <<: *collect
  script:
    - pytest tests/core/ --collect-only
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" || ($CI_PIPELINE_SOURCE == "push" && ($CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "dev"))'
      changes:
        - tests/core/**/*
        - src/**/*

Стадия статического анализа кода

Во время ревью МРов приходилось очень часто общаться с коллегами по поводу стиля написания кода. У всех, кто пишет код, бывают свои личные предпочтения по стилю.
Чтобы избежать лишних дискуссий по этому поводу, мы решили стандартизировать стиль написания кода в проекте. Я предложил добавить стадию статического анализа кода (линтинга, далее — линтеры).

Для проекта на Python была выбрана стандартная связка инструментов: black, isort и flake8. Однако от использования black пришлось отказаться, так как его строгие правила потребовали бы переписать слишком много старого кода, поэтому же нам не подошла mypy. Мы решили добавить их позже, когда-нибудь в будущем.

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

В первый месяц после включения стадии с линтерами настройка была установлена на allow_failure: true, что разрешало продолжение пайплайна даже в случае падения джобы. Это время давалось тестировщикам для анализа и исправления ошибок.

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

Внедрение стадии статического анализа кода позволило нам решить сразу несколько проблем:

  • ускорило процесс ревью МРов, так как теперь не приходится обращать внимания на стилистические ошибки в коде;

  • улучшило общее качество кода в проекте;

  • избавились от появление в коде простых ошибок, таких, как некорректные импорты, обращения к несуществующим методам или переменным, и др.

Чтобы тестировщики быстрее получали обратную связь от линтеров, было решено внедрить инструмент pre-commit. Pre-commit — это инструмент для автоматического запуска линтеров, тестов и других проверок перед каждым коммитом в систему контроля версий. Он позволяет гарантировать, что код соответствует установленным стандартам и не содержит простых ошибок перед тем, как попасть в репозиторий. Проверки происходят при каждом локальном коммите в актуальную ветку.

Стадия smoke тестов

Следующим этапом стало внедрение стадии smoke тестов. 

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

После общения с командой нам частично удалось выделить набор smoke тестов для некоторых сервисов. Мы пометили эти тесты с помощью @pytest.mark.smoke и выделили их запуск в отдельную стадию в GitLab CI. Падение хотя бы одного теста в smoke стадии останавливает дальнейшее прохождение pipeline, так как при поломке критичного функционала нет смысла запускать полноценный прогон тестов. 

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

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

Стадия healthchecks

В прошлых главах я внедрил линтеры (linters), стадию сборки тестов (collect stage), smoke тесты. Благодаря этим шагам код тестов у нас стал более красивым и работоспособным. При этом постоянно возникала проблема, что какой-либо сервис недоступен. В нашем случае это были проблемы инфраструктуры: обновления или починка чего-либо. Из-за этого часто возникала ситуация, что тесты упали, но проблема не на нашей стороне. 

В это же время в компании начался процесс переноса тестируемых сервисов в кубер.  Поэтому было принято решение продвигать идею реализации API запросов, которые смогут показать состояние сервиса – status, healthcheck и тп. Таким образом, стали появляться авто тесты, проверяющие состояние сервиса, а в пайплайне появилась отдельная стадия health check. Данные тесты выносились в отдельную папку healthchecks.

Стадию healthchecks решили запускать после линтеров и коллекта, но перед основным запуском тестов, так как нет смысла начинать тестирование, если сервис не доступен.

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

.healthcheck_def: &healthcheck_def
  stage: healthcheck
  tags:
    - docker
  image: $IMAGE_TAG
  script:
    - $TIMEOUT pytest $PYTEST_ARGS $HEALTH_CHECKS_SET
  artifacts:
    name: "$CI_JOB_NAME"
    paths:
      - log/
      - pytest.log
    expire_in: 14 days

healthcheck_for_article:
  <<: *healthcheck_def
  variables:
    HEALTH_CHECKS_SET: "tests/healthchecks/test_health_for_article.py"
  rules:
    - *base_rules
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" || ($CI_PIPELINE_SOURCE == "push" && ($CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "dev"))'
      changes:
        - tests/core/suite_for_article/**/*
        - tests/healthchecks/test_health_for_article.py

Стадия внутренних (internal) тестов

Кратко расскажу про стадию внутренних тестов. Помимо кода, который написан для взаимодействия с сервисами, у нас в репозитории есть папка со скриптами, которые используются для подготовки тестовых данных, и разнообразные внутренние утилиты. Чтобы отслеживать работоспособность этих скриптов, была внедрена стадия Internal-tests. На этой стадии запускаются юнит-тесты для проверки работоспособности скриптов. Стадия запускается только в том случае, если в MR присутствуют изменения в папке со скриптами.

Дебаг в gitlab-ci

Иногда для проверки какой-либо теории нам хотелось для отладки запустить код в gitlab-ci. Пришло время вспомнить про волшебную кнопку Run pipeline.

Новая идея подразумевала возможность кастомного запуска любой джобы с укороченной версией пайплайна, чтобы можно было пропустить часть проверок. Моя коллега предложила для этого использовать “предустановленные переменные" или Prefill variables

После обсуждения с командой и проверки ее идей, был реализован подход, который позволял любому из наших коллег зайти на вкладку pipelines, нажать кнопку Run pipelines и, выбрав заранее определенные данные, сформировать нужный прогон автотестов. Ниже – пример кода, в котором определены предустановленные переменные, дающие возможность выбрать тестовое окружение для запуска  авто тестов.

variables:
  DEPLOY_ENVIRONMENT:
    description: "Select the deployment target. Valid options are: 'test', 'stage'"
    value: "test"
    options:
      - "test"
      - "stage"

Также мы добавили возможность запуска любых тестов без привязки к сервису или каким-либо настройкам. Реализация была сделана в виде custom-run, в параметрах которой можно было передать любые аргументы для запуска pytest (PYTEST_ARGS и TEST_PATH - папки, файлы, классы, уровни логирования и тп).

.custom_def: &custom_def
  stage: tests
  tags:
    - docker
  image: $IMAGE_TAG
  allow_failure: true
  before_script:
    - *printenv
  rules:
    - *rules-web
  artifacts:
    name: "$CI_JOB_NAME"
    paths:
      - log/
      - pytest.log
    expire_in: 2 days
    when: always

custom_run:
  <<: *custom_def
  script:
    - pytest $PYTEST_ARGS $TEST_PATH

Во время запуска custom run в pipeline, запускается только одна джоба на стадии Tests.

Подведение итогов

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

Full Gitlab-CI pipeline scheme

На следующей блок схеме – пример реализации ночных (schedule) прогонов и приемочных (acceptance) тестов. Pipeline для обоих случаев один и тот же – проверяем, что сервис жив, подготавливаем Аллюр прогон, прогоняем все тесты для сервиса.

Gitlab-CI scheme for schedule and acceptance pipeline

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

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