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

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

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

b57af5c9051a0ed6ea94c3a2f76182df.png

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

  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.

© Habrahabr.ru