Привет, Хабр! Меня зовут Вадим, я уже много лет в тестировании и сейчас работаю Head of QA в Альфа-Банке (Беларусь). За эти годы я успел поработать с десятками инструментов, написать сотни тест-кейсов и... потратить неприлично много времени на рутину, которую можно было автоматизировать ещё вчера.

Знаете, есть такая особенность нашей профессии - мы автоматизируем всё вокруг, но часто забываем автоматизировать собственную боль. Сегодня хочу поделиться решением одной из таких "болей", с которой сталкивается каждый QA-инженер, работающий с ТестОпс: необходимость вручную синхронизировать тест-кейсы после каждого прогона автотестов.

Да-да, именно та ситуация, когда ты полчаса подряд кликаешь по кнопке «Синхронизировать». И это в 2025 году, когда ИИ уже пишет код лучше некоторых разработчиков!

Проблема: когда тест-кейсы внезапно "исчезают"

А теперь к сути проблемы. Представьте ситуацию: к вам приходит DPO (Director Product Owner) или PM (Product Manager) с вопросом по покрытию тестами какой-то функциональности. Вы заходите в ТестОпс, открываете тест-кейсы и... видите пустые сценарии. Хотя буквально пару дней назад всё было на месте, и тесты исправно выполнялись.

Знакомо? Мне - очень. Первый раз столкнувшись с этим, я подумал, что это какой-то глюк системы. Но после небольшого расследования выяснилось, что всё дело в политиках очистки данных, которые настроены в ТестОпс для предотвращения засорения базы данных.

Логика простая: старые результаты тестов удаляются через определённое время, а вместе с ними исчезают и связанные с ними шаги тест-кейсов. В итоге получается парадоксальная ситуация - тесты работают, результаты есть, а сценарии в тест-кейсах пропали.

Варианты решения проблемы

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

  • ✅ Простое решение

  • ❌ Увеличивает нагрузку на БД

  • ❌ Не решает проблему кардинально, просто откладывает её

2. Вручную синхронизировать каждый тест-кейс после прогона

  • ✅ Гарантированный результат

  • ❌ Огромные временные затраты

  • ❌ Человеческий фактор (можно забыть)

  • ❌ Не масштабируется

3. Настроить CI/CD так, чтобы тесты запускались чаще политики очистки

  • ✅ Автоматическое решение

  • ❌ Не всегда целесообразно (лишняя нагрузка на инфраструктуру)

  • ❌ Подходит не для всех проектов

4. Автоматизировать процесс синхронизации через скрипт

  • ✅ Эффективно и быстро

  • ✅ Запускается по требованию

  • ✅ Не создаёт лишней нагрузки

  • ✅ Полностью решает проблему

Выбор был очевиден - четвёртый вариант. Именно поэтому родился этот скрипт.

Решение: автоматизируем всё

Как говорится, "если что-то делаешь больше двух раз - пора автоматизировать". А тут мы делали это сотни раз! 

Поэтому мы с коллегой Сергеем (имя, возможно, изменено) написали Python-скрипт, который автоматически синхронизирует все тест-кейсы одной командой. Вернее, основной код написал Сергей, а я доработал. Никаких лишних кликов в интерфейсе, никакой рутины, просто запускаешь скрипт и идёшь пить кофе (или заниматься действительно важными задачами).

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

Создаём sync_test_cases.py

import logging
import os
import sys
from typing import Dict, List, Optional
import requests
from dotenv import load_dotenv

class AllureTestCaseSyncer:
    """Класс для синхронизации тест-кейсов с результатами запусков в Allure TestOps."""
    
    def __init__(self):
        """Инициализация синхронизатора с загрузкой конфигурации."""
        load_dotenv()
        self._setup_logging()
        self._load_config()
        self._setup_headers()
        self.access_token: Optional[str] = None
        
    def _setup_logging(self) -> None:
        """Настройка логирования."""
        logging.basicConfig(
            level=logging.INFO,
            format='%(asctime)s - %(levelname)s - %(message)s',
            handlers=[
                logging.StreamHandler(sys.stdout),
                logging.FileHandler('allure_sync.log', encoding='utf-8')
            ]
        )
        self.logger = logging.getLogger(__name__)
        
    def _load_config(self) -> None:
        """Загрузка и валидация конфигурации из переменных окружения."""
        self.allure_url = os.getenv('ALLURE_URL')
        self.allure_token = os.getenv('ALLURE_TOKEN')
        self.launch_id = os.getenv('LAUNCH_ID')
        self.page_size = int(os.getenv('PAGE_SIZE', '1000'))
        
        # Валидация обязательных параметров
        if not self.allure_token:
            raise ValueError("ALLURE_TOKEN не установлен в переменных окружения")
        if not self.launch_id:
            raise ValueError("LAUNCH_ID не установлен в переменных окружения")
            
        self.logger.info(f"Конфигурация загружена: URL={self.allure_url}, LAUNCH_ID={self.launch_id}")
        
    def _setup_headers(self) -> None:
        """Настройка заголовков для HTTP запросов."""
        self.json_headers = {'Accept': 'application/json'}
        self.all_headers = {'Accept': '*/*'}
        
    def authenticate(self) -> None:
        """Аутентификация в Allure TestOps и получение access token."""
        self.logger.info("Начало аутентификации...")
        
        auth_data = {
            'grant_type': (None, 'apitoken'),
            'scope': (None, 'openid'),
            'token': (None, self.allure_token),
        }
        
        try:
            response = requests.post(
                f'{self.allure_url}/api/uaa/oauth/token',
                headers=self.json_headers,
                files=auth_data,
                timeout=30
            )
            response.raise_for_status()
            
            self.access_token = response.json()['access_token']
            self.auth_headers = {'Authorization': f'Bearer {self.access_token}'}
            self.logger.info("Аутентификация успешно завершена")
            
        except requests.exceptions.RequestException as e:
            self.logger.error(f"Ошибка аутентификации: {e}")
            raise
        except KeyError:
            self.logger.error("Неверный формат ответа при аутентификации")
            raise
            
    def close_launch(self) -> None:
        """Закрытие запуска в Allure TestOps."""
        self.logger.info(f"Закрытие запуска {self.launch_id}...")
        
        try:
            response = requests.post(
                f'{self.allure_url}/api/launch/{self.launch_id}/close',
                headers=self.auth_headers,
                timeout=30
            )
            response.raise_for_status()
            self.logger.info(f"Запуск {self.launch_id} успешно закрыт")
            
        except requests.exceptions.RequestException as e:
            self.logger.warning(f"Ошибка при закрытии запуска: {e}")
            
    def get_test_results(self) -> List[Dict]:
        """Получение результатов тестов из запуска."""
        self.logger.info(f"Получение информации о запуске {self.launch_id}...")
        
        params = {
            'page': '0',
            'size': str(self.page_size),
            'sort': 'name,ASC',
        }
        
        try:
            response = requests.get(
                f'{self.allure_url}/api/v2/launch/{self.launch_id}/test-result/flat',
                params=params,
                headers={**self.auth_headers, **self.all_headers},
                timeout=60
            )
            response.raise_for_status()
            
            content = response.json().get('content', [])
            self.logger.info(f"Получено {len(content)} результатов тестов")
            return content
            
        except requests.exceptions.RequestException as e:
            self.logger.error(f"Ошибка получения результатов тестов: {e}")
            raise
            
    def sync_test_case_scenario(self, test_case_id: str) -> bool:
        """
        Синхронизация сценария для конкретного тест-кейса.
        
        Args:
            test_case_id: ID тест-кейса для синхронизации
            
        Returns:
            bool: True если синхронизация успешна, False в противном случае
        """
        try:
            # Получение сценария из результатов выполнения
            self.logger.info(f"Получение сценария для тест-кейса {test_case_id}...")
            
            scenario_response = requests.get(
                f'{self.allure_url}/api/testcase/{test_case_id}/scenariofromrun',
                headers=self.auth_headers,
                timeout=30
            )
            scenario_response.raise_for_status()
            scenario_data = scenario_response.json()
            
            # Сохранение сценария в тест-кейс
            self.logger.info(f"Сохранение сценария для тест-кейса {test_case_id}...")
            
            save_response = requests.post(
                f'{self.allure_url}/api/testcase/{test_case_id}/scenario',
                headers={**self.auth_headers, **self.json_headers},
                json=scenario_data,
                timeout=30
            )
            save_response.raise_for_status()
            
            self.logger.info(f"Сценарий для тест-кейса {test_case_id} успешно синхронизирован")
            return True
            
        except requests.exceptions.RequestException as e:
            self.logger.error(f"Ошибка синхронизации тест-кейса {test_case_id}: {e}")
            return False
            
    def sync_all_test_cases(self, test_results: List[Dict]) -> None:
        """Синхронизация всех тест-кейсов из переданных результатов тестов."""
        if not test_results:
            self.logger.warning("Результаты тестов не найдены")
            return
            
        # Синхронизация каждого тест-кейса
        success_count = 0
        total_count = len(test_results)
        
        for test_result in test_results:
            execution_id = test_result.get('id')
            test_case_id = test_result.get('testCaseId')
            
            if not test_case_id:
                self.logger.warning(f"Тест-кейс ID не найден для execution {execution_id}")
                continue
                
            self.logger.info(f"Обработка: ExecutionId={execution_id}, TestCaseId={test_case_id}")
            
            if self.sync_test_case_scenario(test_case_id):
                success_count += 1
                
        self.logger.info(f"Синхронизация завершена: {success_count}/{total_count} успешно")


def main() -> None:
    """Главная функция для запуска синхронизации."""
    try:
        syncer = AllureTestCaseSyncer()
        
        # Аутентификация
        syncer.authenticate()
        
        # Закрытие запуска
        syncer.close_launch()
        
        # Получение результатов тестов
        test_results = syncer.get_test_results()
        
        # Синхронизация тест-кейсов
        syncer.sync_all_test_cases(test_results)
        
    except Exception as e:
        logging.error(f"Ошибка выполнения скрипта: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()

Создаём файл .env:

# URL вашего ТестОпс инстанса (обязательно)
ALLURE_URL=https://allure.example.com

# API токен для доступа к ТестОпс (обязательно)
# Получить можно в настройках профиля -> API tokens
ALLURE_TOKEN=your_api_token_here

# ID запуска (launch) для синхронизации (обязательно)
LAUNCH_ID=12345

# Размер страницы для получения результатов (опционально, по умолчанию 1000)
PAGE_SIZE=1000

Когда скрипт не нужен?

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

Скрипт НЕ нужен, если:

  • Тесты выполняются чаще, чем срабатывает политика очистки - результаты постоянно обновляются

  • Увеличили время жизни данных в политиках - и вас устраивает нагрузка на БД

  • Малое количество тест-кейсов (< 10-20) - проще синхронизировать вручную

  • Не используете связку тест-кейсов с результатами - работаете только с автотестами

Скрипт особенно полезен, если:

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

  • Много тест-кейсов - сотни тест-кейсов для синхронизации

  • Работа с заказчиками - DPO, PM регулярно просматривают тест-кейсы

  • Разные команды и проекты - нужно массово обновлять покрытие

  • Банковская/финансовая сфера - высокие требования к документированию процессов

Как это работает изнутри

Алгоритм простой и понятный:

def main() -> None:
    """Главная функция для запуска синхронизации."""
    try:
        syncer = AllureTestCaseSyncer()
        
        # Аутентификация
        syncer.authenticate()
        
        # Закрытие запуска
        syncer.close_launch()
        
        # Получение результатов тестов
        test_results = syncer.get_test_results()
        
        # Синхронизация тест-кейсов
        syncer.sync_all_test_cases(test_results)
        
    except Exception as e:
        logging.error(f"Ошибка выполнения скрипта: {e}")
        sys.exit(1)

Скрипт использует официальное API ТестОпс, поэтому он надёжен и безопасен.

Запуск

python3 sync_test_cases.py

Всё! Скрипт автоматически синхронизирует все тест-кейсы из указанного запуска.

Пример использования с Playwright (JS/TS)

Особенно актуально для команд, использующих Playwright для автоматизации. Вот типичный workflow: 1. Настройка Playwright тестов

// playwright.config.js
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: [
    ['html'],
    ['allure-playwright', {
      detail: true,
      outputFolder: 'allure-results',
      suiteTitle: false,
    }]
  ],
  // ... остальные настройки
});

2. Пример теста с аннотациями

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { allure } from 'allure-playwright';

test.describe('Авторизация пользователя', () => {
  test('Успешная авторизация с валидными данными', async ({ page }) => {
    await allure.epic('Авторизация');
    await allure.feature('Логин');
    await allure.story('Позитивные сценарии');
    
    await allure.step('Открываем страницу логина', async () => {
      await page.goto('/login');
      await expect(page.locator('h1')).toContainText('Вход в систему');
    });
    
    await allure.step('Вводим валидные учётные данные', async () => {
      await page.fill('[data-testid=username]', 'testuser@example.com');
      await page.fill('[data-testid=password]', 'SecurePassword123');
    });
    
    await allure.step('Нажимаем кнопку "Войти"', async () => {
      await page.click('[data-testid=login-button]');
    });
    
    await allure.step('Проверяем успешную авторизацию', async () => {
      await expect(page.locator('[data-testid=user-menu]')).toBeVisible();
      await expect(page.url()).toContain('/dashboard');
    });
  });
});

3. Интеграция с CI/CD

# .github/workflows/tests.yml
name: E2E Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          
      - name: Install dependencies
        run: npm ci
        
      - name: Install Playwright browsers
        run: npx playwright install --with-deps
        
      - name: Run Playwright tests
        run: npx playwright test
        
      - name: Generate Allure report
        run: npx allure generate allure-results --clean
        
      - name: Upload to ТестОпс
        run: |
          # Загрузка результатов в ТестОпс
          curl -X POST "${{ secrets.ALLURE_URL }}/api/result" \
            -H "Authorization: Bearer ${{ secrets.ALLURE_TOKEN }}" \
            -F "results=@allure-results.zip"
            
      - name: Sync test cases
        run: |
          # Получаем ID последнего запуска и синхронизируем
          export LAUNCH_ID=$(curl -s "${{ secrets.ALLURE_URL }}/api/launch" \
            -H "Authorization: Bearer ${{ secrets.ALLURE_TOKEN }}" | \
            jq -r '.content[0].id')
          python3 sync_test_cases.py
        env:
          ALLURE_URL: ${{ secrets.ALLURE_URL }}
          ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }}

4. Результат

После выполнения пайплайна:

  • Тесты выполнены

  • Результаты загружены в ТестОпс

  • Тест-кейсы автоматически синхронизированы

  • Никаких ручных действий не требуется!

Логирование и мониторинг

Скрипт ведёт подробные логи, что помогает отслеживать процесс:

2025-09-15 14:30:01 - INFO - Конфигурация загружена: URL=https://allure.company.com, LAUNCH_ID=12345
2025-09-15 14:30:02 - INFO - Начало аутентификации...
2025-09-15 14:30:03 - INFO - Аутентификация успешно завершена
2025-09-15 14:30:04 - INFO - Закрытие запуска 12345...
2025-09-15 14:30:05 - INFO - Запуск 12345 успешно закрыт
2025-09-15 14:30:06 - INFO - Получение информации о запуске 12345...
2025-09-15 14:30:07 - INFO - Получено 47 результатов тестов
2025-09-15 14:30:08 - INFO - Обработка: ExecutionId=67890, TestCaseId=123
2025-09-15 14:30:09 - INFO - Сценарий для тест-кейса 123 успешно синхронизирован
2025-09-15 14:32:15 - INFO - Синхронизация завершена: 45/47 успешно

Обработка ошибок

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

2025-09-15 14:31:30 - ERROR - Ошибка синхронизации тест-кейса 456: 403 Forbidden
2025-09-15 14:31:31 - INFO - Обработка: ExecutionId=67891, TestCaseId=457
2025-09-15 14:31:32 - INFO - Сценарий для тест-кейса 457 успешно синхронизирован

Альтернативные сценарии использования

Локальная разработка

# Запустили тесты локально
npm run test:e2e

# Получили LAUNCH_ID из ТестОпс
export LAUNCH_ID=54321

# Синхронизировали тест-кейсы
python3 sync_test_cases.py

Batch-обработка

# Синхронизация нескольких запусков
for launch_id in 12345 12346 12347; do
  export LAUNCH_ID=$launch_id
  python3 sync_test_cases.py
  echo "Launch $launch_id synced"
done

Если у вас релизы раз в неделю, то за год вы сэкономите около 40 часов рабочего времени. Это почти целая рабочая неделя! Представляете, что можно сделать за эту неделю вместо бездумного кликанья по кнопкам?

В масштабах нашей команды в Альфа-Банке экономия получается ещё более впечатляющей. У нас несколько команд QA, и каждая экономит десятки часов в месяц. Эти часы мы теперь тратим на улучшение процессов, обучение команды и, что самое важное, на повышение качества наших продуктов.

Заключение

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

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

Когда использовать:

  • ✅ Периодические запуски тестов

  • ✅ Большое количество тест-кейсов

  • ✅ Интеграция с CI/CD

  • ✅ Командная работа

Когда можно обойтись без скрипта:

  • ❌ Тесты выполняются постоянно (каждый коммит)

  • ❌ Малое количество тест-кейсов (< 10)

  • ❌ Результаты тестов не связаны с тест-кейсами

А у вас есть опыт автоматизации работы с ТестОпс? Поделитесь в комментариях своими лайфхаками!

? Если тема автоматизации, тестирования и ИИ вам интересна - подписывайтесь на мой телеграм-канал https://t.me/it\\_vadimqa. Там я делюсь практикой, кейсами и инструментами, которые реально упрощают жизнь QA-инженерам.

Комментарии (0)


  1. Lizalush13
    26.09.2025 10:16

    какая топовая статья! с кодом с полным объяснением: рассмотренные варианты. просто кладезь.

    Спасибо большое автору!

    <3 сохраняю себе