DevOps нет, но вы держитесь: как разработчики запустили тесты на этапе MR

от автора

Со старта нашего проекта Polymatica EPM (бизнес‑платформа для автоматизации процессов стратегического планирования и бюджетирования) мы решили: код должен покрываться тестами. Проект построен на стеке FastAPI + Poetry + Pytest. Из‑за особенностей проекта тесты, в основном, функциональные. Все шло хорошо, команда росла, тесты писались, но запускались только на локальной машине перед коммитами. Наступил момент, когда нужно было внедрить автоматический прогон тестов на этапе Merge Request (MR).

На тот момент у нас был собственный GitLab и настроенный CI/CD, но ресурсы DevOps были ограничены. Поэтому задачу пришлось решать силами разработчиков. Меня зовут Дмитрий Богданов, я старший бэкенд‑разработчик, и в этой статье расскажу, как мы оптимизировали запуск тестов, с какими проблемами столкнулись и почему выбрали именно базовый образ для CI/CD.

Выбор подхода для запуска тестов

  1. Каждый раз устанавливать зависимости на CI/CD‑воркере или собирать новый образ.

    Минусы: долгое время сборки, загрязнение воркера лишними зависимостями.

  2. Использовать тот же image, который выкатывается на продакшен‑стенд.

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

  3. Использовать базовый образ со всеми зависимостями, включая тестовые.

    Минусы: требует пересборки при изменении зависимостей, а после тестов нужно заново собирать продакшен‑образ.

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

Особенности реализации

Начальная структура репозитория

 monorep/ ├── service_1/ │   ├── app/ │   ├── Dockerfile │   ├── poetry.lock │   └── pyproject.toml ├── service_2/ │   ├── app/ │   ├── Dockerfile │   ├── poetry.lock │   └── pyproject.toml ├── service_3/ │   ├── app/ │   ├── Dockerfile │   ├── poetry.lock │   └── pyproject.toml ├── .gitlab_ci.yml

monorep/ — корневой каталог монорепозитория.
service_1/, service_2/, service_3/ — подкаталоги с сервисами, каждый из которых содержит:

  • Dockerfile — файл для сборки Docker‑образа.

  • pyproject.toml — файл конфигурации для Poetry.

  • poetry.lock — файл с зафиксированными зависимостями.

  • app/ — каталог с кодом приложения, тесты находятся тут же.

В корне монорепозитория находятся:
.gitlab_ci.yml — файл конфигурации для GitLab CI/CD.

Для реализации нашего варианта нам нужно собрать все зависимости из всех сервисов и собрать их вместе. Также нам потребуется Dockerfile для «базового образа».

Собираем зависимости

Начнем по порядку — соберем все зависимости. В наше проекте каждый сервис содержит свои зависимости в poetry, в целом используется одинаковый стек, однако бывают специфические библиотеки (например polars). Для экспорта requirements.txt используем команду:

poetry export --without-hashes ‑f requirements.txt ‑output requirements1.txt

Теперь у нас есть несколько requirements.txt, можно объединить их вручную в один файл, но мы написали скрипт на python:

import argparse from packaging.requirements import Requirement, InvalidRequirement   def parse_requirements(file_path): dependencies = {} options = set() with open(file_path, 'r') as f:     for line in f:         line = line.strip()         if not line or line.startswith('#'):             continue                  if ' -- ' in line:             dep_part, options_part = line.split(' -- ', 1)             current_options = [' -- ' + opt.strip() for opt in options_part.split(' -- ')]             options.update(current_options)         else:             dep_part = line             current_options = []          dep_part = dep_part.split(';')[0].strip()         if not dep_part:             continue          try:             req = Requirement(dep_part)             dep_name = req.name             dependencies[dep_name] = dep_part         except InvalidRequirement:             print(f"⚠️ Ошибка парсинга: '{dep_part}' в файле {file_path} пропущена.")             continue  return dependencies, options  def main(): parser = argparse.ArgumentParser(description='Объединяет несколько requirements.txt') parser.add_argument('files', nargs='+', help='Список файлов для объединения') parser.add_argument('-o', '--output', default='requirements_all.txt', help='Выходной файл') args = parser.parse_args()  all_options = set() combined_deps = {} seen_files = set()  for file_path in args.files:     if file_path in seen_files:         continue     seen_files.add(file_path)          deps, opts = parse_requirements(file_path)     all_options.update(opts)          for dep_name, dep_spec in deps.items():         if dep_name in combined_deps:             print(f"⚠️ Конфликт: {dep_name} заменен на версию из {file_path} ({dep_spec})")         combined_deps[dep_name] = dep_spec  sorted_options = sorted(all_options) sorted_deps = sorted(combined_deps.items(), key=lambda x: x[0].lower())  with open(args.output, 'w') as f:     if sorted_options:         f.write('\n'.join(sorted_options) + '\n\n')     for dep_name, dep_spec in sorted_deps:         f.write(f"{dep_spec}\n")  print(f"✅ Файл {args.output} успешно создан!")  if __name__ == '__main__': main()

Для использования:

pip install packaging python merge_requirements.py requirements1.txt requirements2.txt requirements3.txt -o requirements_all.txt

Пишем Dockerfile для базового образа

FROM python:3.10.12-slim  RUN pip install --no-cache-dir --upgrade pip  COPY ./requirements_all.txt requirements_all.txt RUN pip install -r requirements_all.txt

Итоговая структура репозитория

monorep/ ├── service_1/ │   ├── app/ │   ├── Dockerfile │   ├── poetry.lock │   └── pyproject.toml ├── service_2/ │   ├── app/ │   ├── Dockerfile │   ├── poetry.lock │   └── pyproject.toml ├── service_3/ │   ├── app/ │   ├── Dockerfile │   ├── poetry.lock │   └── pyproject.toml ├── .gitlab_ci.yml ├── Dockerfile_gitlab └── requirements_all.txt

CI и настройка Gitlab

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

image: alpine   variables:   PRETEST: pretest    stages:   - pretest   - test   - dockerize  pretest:   stage: pretest   image: alpine:latest   only: changes:   - requirements_all.txt   - Dockerfile_gitlab   script: - apk add --no-cache bash docker - IMAGE=${CI_REGISTRY_IMAGE}/${PRETEST} - DOCKERFILE="-f Dockerfile_gitlab" - docker build $DOCKERFILE -t ${IMAGE}:$IMAGE_VERSION . - docker push ${IMAGE}:$IMAGE_VERSION - docker tag ${IMAGE}:$IMAGE_VERSION ${IMAGE}:latest - docker push ${IMAGE}:latest - echo $IMAGE:$IMAGE_VERSION > IMAGE_${PRETEST}   artifacts: paths:   - IMAGE_/c{PRETEST}   when: manual   allow_failure: true   ### Test ###   .test_template: &test_template   stage: test   image: ${CI_REGISTRY_IMAGE}/${PRETEST}:latest   allow_failure: true   rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_JOB_NAME == "service1:test"   changes:     - service1/**/*   when: always - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_JOB_NAME == "service2:test"   changes:     - service2/**/*   when: always - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_JOB_NAME == "service3:test"   changes:     - service3/**/*   when: always - if: $CI_COMMIT_REF_NAME =~ /^(dev|stage)$/ && $CI_JOB_NAME == "service1:test"   changes:     - service1/**/*   when: always - if: $CI_COMMIT_REF_NAME =~ /^(dev|stage)$/ && $CI_JOB_NAME == "service2:test"   changes:     - service2/**/*   when: always - if: $CI_COMMIT_REF_NAME =~ /^(dev|stage)$/ && $CI_JOB_NAME == "service3:test"   changes:     - service3/**/*   when: always - when: never   script: - cd ${SERVICE}_service - python --version - |   if [[ -f "migrate.py" ]]; then     python migrate.py   fi - pytest tests -vv --color yes --cov --cov-report term --cov-report xml:coverage.xml --junitxml=report.xml - cd ..   coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'   artifacts: reports:   coverage_report:     coverage_format: cobertura     path: ${SERVICE}_service/coverage.xml   junit: ${SERVICE}_service/report.xml   service1:test:   variables: SERVICE: service1   <<: *test_template   service2:test:   variables: SERVICE: service2   <<: *test_template   service3:test:   variables: SERVICE: service3   <<: *test_template  ### Dockerize ###   .dockerize_template: &dockerize_template   stage: dockerize   image: alpine:latest   rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event"   when: never - if: $CI_COMMIT_REF_NAME =~ /^(dev|stage)$/ && $CI_JOB_NAME == "service1:dockerize"   changes:     - service1/**/*   when: on_success   allow_failure: true - if: $CI_COMMIT_REF_NAME =~ /^(dev|stage)$/ && $CI_JOB_NAME == "service2:dockerize"   changes:     - service2/**/*   when: on_success   allow_failure: true - if: $CI_COMMIT_REF_NAME =~ /^(dev|stage)$/ && $CI_JOB_NAME == "service3:dockerize"   changes:     - service3/**/*   when: on_success   allow_failure: true - when: never   script: - apk add --no-cache bash docker - IMAGE=${CI_REGISTRY_IMAGE}/${SERVICE} - DOCKERFILE="-f ${SERVICE}_service/Dockerfile" - docker build $DOCKERFILE -t ${IMAGE}:$IMAGE_VERSION . - docker push ${IMAGE}:$IMAGE_VERSION - echo $IMAGE:$IMAGE_VERSION > IMAGE_${SERVICE}   artifacts: paths:   - IMAGE_${SERVICE}       service1:dockerize:   needs: - job: "service1:test"   optional: true   variables: SERVICE: service1   <<: *dockerize_template   service2:dockerize:   needs: - job: "service2:test"   optional: true   variables: SERVICE: service2   <<: *dockerize_template   service3:dockerize:   needs: - job: "service3:test"   optional: true   variables: SERVICE: service3   <<: *dockerize_template 

О том, как посмотреть результаты тестов, хорошо описано в официальной документации Gitlab.

Итоги

Мы используем этот подход уже более года, и он доказал свою эффективность:

  • среднее время прохождения тестов — 2–3 минуты на сервис,

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

  • базовый образ минимизировал время установки зависимостей.

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


ссылка на оригинал статьи https://habr.com/ru/articles/889824/