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

Выбор подхода для запуска тестов
Каждый раз устанавливать зависимости на CI/CD‑воркере или собирать новый образ.
Минусы: долгое время сборки, загрязнение воркера лишними зависимостями.
Использовать тот же image, который выкатывается на продакшен‑стенд.
Минусы: возможные ошибки при сборке стенда, необходимость каждый раз доустанавливаем пакеты для тестирования, что опять же увеличивает время.
Использовать базовый образ со всеми зависимостями, включая тестовые.
Минусы: требует пересборки при изменении зависимостей, а после тестов нужно заново собирать продакшен‑образ.
Мы выбрали третий вариант, так как он обеспечивал баланс между скоростью тестирования и удобством управления зависимостями.
Особенности реализации
Начальная структура репозитория
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.