Перейти к содержимому
Technical Lead & Architect2024
#Python#OpenCV#Selenium#Pillow#Docker#CI/CD#SMTP

Perfector — Визуальное регрессионное тестирование

Автоматизированная система визуального тестирования UI для предотвращения непреднамеренных изменений верстки. Снепшоты страниц + OpenCV для детекции различий + автоматические отчеты для команды разработки.

Контекст проекта

Классическая проблема веб-разработки:

Когда проект разрастается до десятков или сотен страниц с общими компонентами и стилями, изменение CSS в одном месте может непреднамеренно сломать верстку на других страницах:

Типичные сценарии:

  • Изменил отступы в компоненте Button → съехала форма логина на другой странице
  • Обновил grid layout → поехали карточки на странице каталога
  • Добавил новый breakpoint → мобильная версия поломалась
  • Рефакторинг CSS переменных → изменились цвета в неожиданных местах

Почему manual testing не работает:

  • Невозможно проверить все страницы вручную после каждого изменения
  • Human error — легко пропустить subtle changes
  • Время — проверка сотен страниц = часы работы QA
  • Нет истории — сложно понять, когда именно что-то сломалось

Реальный кейс: Разработчик изменил z-index для модального окна. Это сломало dropdown меню на 15 страницах, но обнаружили только через неделю, когда пользователь пожаловался.

Задача

Создать автоматизированную систему визуального регрессионного тестирования, которая:

  • Делает скриншоты всех критичных страниц на baseline (reference)
  • После каждого деплоя делает новые скриншоты
  • Сравнивает через компьютерное зрение (OpenCV)
  • Выявляет визуальные различия
  • Отправляет отчет с найденными изменениями тимлиду для review
  • Интегрируется в CI/CD pipeline

Решение

Архитектура системы

┌─────────────────────────────────────────────────────┐
│                   CI/CD Trigger                      │
│            (git push, PR, scheduled)                 │
└─────────────────┬───────────────────────────────────┘


┌─────────────────────────────────────────────────────┐
│              Screenshot Engine                       │
│   (Selenium + Headless Chrome в Docker)              │
│                                                       │
│  1. Загружает список URL из конфига                 │
│  2. Открывает каждую страницу                       │
│  3. Делает full-page screenshot                     │
│  4. Сохраняет в storage                             │
└─────────────────┬───────────────────────────────────┘


┌─────────────────────────────────────────────────────┐
│           Visual Comparison Engine                   │
│              (OpenCV + Pillow)                       │
│                                                       │
│  1. Загружает baseline и новый скриншот             │
│  2. Выравнивает изображения (alignment)             │
│  3. Вычисляет diff через OpenCV                     │
│  4. Генерирует diff image с подсветкой              │
│  5. Вычисляет метрики (% различий)                  │
└─────────────────┬───────────────────────────────────┘


┌─────────────────────────────────────────────────────┐
│              Report Generator                        │
│                                                       │
│  1. Собирает все diffs в HTML отчет                 │
│  2. Классифицирует changes (critical/minor)         │
│  3. Добавляет метаданные (commit, author)           │
│  4. Генерирует email с визуализацией                │
└─────────────────┬───────────────────────────────────┘


┌─────────────────────────────────────────────────────┐
│          Notification System                         │
│                                                       │
│  Отправка email тимлиду с:                          │
│  - Summary (сколько страниц изменилось)             │
│  - Side-by-side comparison (до/после)               │
│  - Diff highlights (что именно изменилось)          │
│  - Links для review                                  │
└─────────────────────────────────────────────────────┘

Технологическая реализация

1. Screenshot Engine

Selenium + Headless Chrome:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import WebDriverWait
import time
 
class ScreenshotEngine:
    def __init__(self, viewport_width=1920, viewport_height=1080):
        self.options = Options()
        self.options.add_argument('--headless')
        self.options.add_argument('--no-sandbox')
        self.options.add_argument('--disable-dev-shm-usage')
        self.options.add_argument(f'--window-size={viewport_width},{viewport_height}')
        self.options.add_argument('--disable-gpu')
        self.options.add_argument('--hide-scrollbars')
 
        self.driver = webdriver.Chrome(options=self.options)
 
    def capture_page(self, url: str, output_path: str, wait_time: int = 2):
        """Делает full-page screenshot страницы"""
        try:
            self.driver.get(url)
 
            # Ждем полной загрузки
            WebDriverWait(self.driver, 10).until(
                lambda d: d.execute_script('return document.readyState') == 'complete'
            )
 
            # Дополнительная задержка для динамического контента
            time.sleep(wait_time)
 
            # JavaScript для ленивых изображений
            self.driver.execute_script("""
                // Trigger lazy-loaded images
                window.scrollTo(0, document.body.scrollHeight);
                window.scrollTo(0, 0);
            """)
 
            time.sleep(1)
 
            # Full page screenshot
            total_height = self.driver.execute_script(
                "return document.body.scrollHeight"
            )
            self.driver.set_window_size(self.options.arguments[3], total_height)
 
            self.driver.save_screenshot(output_path)
 
            return True
 
        except Exception as e:
            print(f"Error capturing {url}: {str(e)}")
            return False
 
    def capture_multiple(self, urls: list[str], output_dir: str):
        """Делает скриншоты списка URL"""
        results = {}
 
        for url in urls:
            page_name = self._url_to_filename(url)
            output_path = f"{output_dir}/{page_name}.png"
 
            success = self.capture_page(url, output_path)
            results[url] = {'path': output_path, 'success': success}
 
        return results
 
    def _url_to_filename(self, url: str) -> str:
        """Конвертирует URL в безопасное имя файла"""
        import re
        filename = re.sub(r'https?://', '', url)
        filename = re.sub(r'[^\w\-_]', '_', filename)
        return filename

Docker контейнер для изоляции:

FROM python:3.11-slim
 
# Установка Chrome и ChromeDriver
RUN apt-get update && apt-get install -y \
    wget \
    gnupg \
    unzip \
    && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
    && echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list \
    && apt-get update \
    && apt-get install -y google-chrome-stable \
    && apt-get clean
 
# Установка Python зависимостей
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
 
COPY . /app
WORKDIR /app
 
CMD ["python", "perfector.py"]

2. Visual Comparison Engine (OpenCV)

Алгоритм сравнения изображений:

import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from dataclasses import dataclass
 
@dataclass
class DiffResult:
    """Результат сравнения двух изображений"""
    similarity_score: float  # 0.0 - 1.0
    diff_percentage: float   # Процент различающихся пикселей
    diff_image_path: str     # Путь к изображению с подсветкой различий
    has_changes: bool
    changed_regions: list    # Bounding boxes изменений
 
class VisualComparator:
    def __init__(self, threshold: float = 0.95, min_diff_area: int = 100):
        """
        threshold: порог схожести (выше = строже)
        min_diff_area: минимальная площадь различия в пикселях
        """
        self.threshold = threshold
        self.min_diff_area = min_diff_area
 
    def compare_images(
        self,
        baseline_path: str,
        current_path: str,
        output_path: str
    ) -> DiffResult:
        """Сравнивает два изображения и генерирует diff"""
 
        # Загрузка изображений
        baseline = cv2.imread(baseline_path)
        current = cv2.imread(current_path)
 
        if baseline is None or current is None:
            raise ValueError("Failed to load images")
 
        # Приведение к одному размеру (если различаются)
        if baseline.shape != current.shape:
            current = cv2.resize(current, (baseline.shape[1], baseline.shape[0]))
 
        # Конвертация в grayscale для сравнения
        baseline_gray = cv2.cvtColor(baseline, cv2.COLOR_BGR2GRAY)
        current_gray = cv2.cvtColor(current, cv2.COLOR_BGR2GRAY)
 
        # Structural Similarity Index (SSIM)
        from skimage.metrics import structural_similarity as ssim
        similarity_score, diff = ssim(
            baseline_gray,
            current_gray,
            full=True
        )
 
        # Конвертация diff в uint8
        diff = (diff * 255).astype("uint8")
 
        # Thresholding для выделения различий
        thresh = cv2.threshold(
            diff,
            0,
            255,
            cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
        )[1]
 
        # Поиск контуров различий
        contours = cv2.findContours(
            thresh,
            cv2.RETR_EXTERNAL,
            cv2.CHAIN_APPROX_SIMPLE
        )
        contours = contours[0] if len(contours) == 2 else contours[1]
 
        # Фильтрация мелких изменений
        significant_changes = []
        changed_regions = []
 
        for contour in contours:
            area = cv2.contourArea(contour)
            if area > self.min_diff_area:
                x, y, w, h = cv2.boundingRect(contour)
                significant_changes.append(contour)
                changed_regions.append({'x': x, 'y': y, 'w': w, 'h': h})
 
        # Генерация визуального diff
        diff_image = self._generate_diff_image(
            baseline,
            current,
            significant_changes,
            output_path
        )
 
        # Вычисление процента различий
        diff_percentage = (len(significant_changes) * self.min_diff_area) / \
                         (baseline.shape[0] * baseline.shape[1]) * 100
 
        return DiffResult(
            similarity_score=similarity_score,
            diff_percentage=diff_percentage,
            diff_image_path=output_path,
            has_changes=similarity_score < self.threshold,
            changed_regions=changed_regions
        )
 
    def _generate_diff_image(
        self,
        baseline: np.ndarray,
        current: np.ndarray,
        contours: list,
        output_path: str
    ) -> str:
        """Генерирует side-by-side изображение с подсветкой различий"""
 
        # Создаем копии для рисования
        baseline_marked = baseline.copy()
        current_marked = current.copy()
 
        # Рисуем красные прямоугольники вокруг изменений
        for contour in contours:
            x, y, w, h = cv2.boundingRect(contour)
            cv2.rectangle(baseline_marked, (x, y), (x+w, y+h), (0, 0, 255), 2)
            cv2.rectangle(current_marked, (x, y), (x+w, y+h), (0, 0, 255), 2)
 
        # Side-by-side композиция
        height = max(baseline.shape[0], current.shape[0])
        width = baseline.shape[1] + current.shape[1] + 60  # 60px для labels
 
        # Создаем canvas
        canvas = np.ones((height + 60, width, 3), dtype=np.uint8) * 255
 
        # Добавляем baseline
        canvas[60:60+baseline.shape[0], 0:baseline.shape[1]] = baseline_marked
 
        # Добавляем current
        offset_x = baseline.shape[1] + 60
        canvas[60:60+current.shape[0], offset_x:offset_x+current.shape[1]] = current_marked
 
        # Добавляем текстовые labels
        cv2.putText(
            canvas,
            "BASELINE",
            (10, 40),
            cv2.FONT_HERSHEY_SIMPLEX,
            1.2,
            (0, 0, 0),
            2
        )
        cv2.putText(
            canvas,
            "CURRENT",
            (offset_x + 10, 40),
            cv2.FONT_HERSHEY_SIMPLEX,
            1.2,
            (0, 0, 0),
            2
        )
 
        # Сохраняем результат
        cv2.imwrite(output_path, canvas)
 
        return output_path

Оптимизации для production:

class OptimizedComparator(VisualComparator):
    """Оптимизированная версия с перцептивным хешингом"""
 
    def quick_check(self, baseline_path: str, current_path: str) -> bool:
        """Быстрая проверка через perceptual hash"""
        import imagehash
        from PIL import Image
 
        baseline_hash = imagehash.phash(Image.open(baseline_path))
        current_hash = imagehash.phash(Image.open(current_path))
 
        # Hamming distance между хешами
        distance = baseline_hash - current_hash
 
        # Если хеши идентичны — skip полное сравнение
        return distance == 0
 
    def compare_with_cache(self, baseline_path: str, current_path: str):
        """Сравнение с кешированием результатов"""
 
        # Быстрая проверка через hash
        if self.quick_check(baseline_path, current_path):
            return DiffResult(
                similarity_score=1.0,
                diff_percentage=0.0,
                diff_image_path=None,
                has_changes=False,
                changed_regions=[]
            )
 
        # Полное сравнение только если нужно
        return self.compare_images(baseline_path, current_path, output_path)

3. Report Generator

HTML отчет с визуализацией:

from jinja2 import Template
from datetime import datetime
 
class ReportGenerator:
    def __init__(self):
        self.template = self._load_template()
 
    def generate_report(
        self,
        diff_results: dict,
        metadata: dict,
        output_path: str
    ) -> str:
        """Генерирует HTML отчет"""
 
        # Классификация изменений
        critical_changes = []
        minor_changes = []
        no_changes = []
 
        for url, result in diff_results.items():
            if result.has_changes:
                if result.diff_percentage > 5.0:  # > 5% изменений
                    critical_changes.append({'url': url, 'result': result})
                else:
                    minor_changes.append({'url': url, 'result': result})
            else:
                no_changes.append(url)
 
        # Рендер HTML
        html = self.template.render(
            generated_at=datetime.now().isoformat(),
            commit_hash=metadata.get('commit_hash'),
            branch=metadata.get('branch'),
            author=metadata.get('author'),
            total_pages=len(diff_results),
            critical_count=len(critical_changes),
            minor_count=len(minor_changes),
            unchanged_count=len(no_changes),
            critical_changes=critical_changes,
            minor_changes=minor_changes,
            no_changes=no_changes
        )
 
        with open(output_path, 'w', encoding='utf-8') as f:
            f.write(html)
 
        return output_path
 
    def _load_template(self) -> Template:
        """HTML шаблон отчета"""
        template_str = """
<!DOCTYPE html>
<html>
<head>
    <title>Perfector Visual Regression Report</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 40px; }
        .summary { background: #f5f5f5; padding: 20px; border-radius: 8px; }
        .critical { border-left: 4px solid #e74c3c; }
        .minor { border-left: 4px solid #f39c12; }
        .change-card {
            margin: 20px 0;
            padding: 20px;
            border: 1px solid #ddd;
            border-radius: 8px;
        }
        .diff-image { max-width: 100%; margin: 10px 0; }
        .metric { display: inline-block; margin: 10px 20px 10px 0; }
        .metric-value { font-size: 24px; font-weight: bold; }
        .critical .metric-value { color: #e74c3c; }
        .minor .metric-value { color: #f39c12; }
    </style>
</head>
<body>
    <h1>🔍 Perfector Visual Regression Report</h1>
 
    <div class="summary">
        <h2>Summary</h2>
        <div class="metric">
            <div class="metric-value">{{ total_pages }}</div>
            <div>Total Pages</div>
        </div>
        <div class="metric">
            <div class="metric-value" style="color: #e74c3c;">{{ critical_count }}</div>
            <div>Critical Changes</div>
        </div>
        <div class="metric">
            <div class="metric-value" style="color: #f39c12;">{{ minor_count }}</div>
            <div>Minor Changes</div>
        </div>
        <div class="metric">
            <div class="metric-value" style="color: #27ae60;">{{ unchanged_count }}</div>
            <div>Unchanged</div>
        </div>
 
        <p><strong>Commit:</strong> {{ commit_hash }}</p>
        <p><strong>Branch:</strong> {{ branch }}</p>
        <p><strong>Author:</strong> {{ author }}</p>
        <p><strong>Generated:</strong> {{ generated_at }}</p>
    </div>
 
    {% if critical_changes %}
    <h2>⚠️ Critical Changes (> 5% difference)</h2>
    {% for change in critical_changes %}
    <div class="change-card critical">
        <h3>{{ change.url }}</h3>
        <p><strong>Similarity:</strong> {{ "%.2f"|format(change.result.similarity_score * 100) }}%</p>
        <p><strong>Changed:</strong> {{ "%.2f"|format(change.result.diff_percentage) }}% of page</p>
        <p><strong>Regions:</strong> {{ change.result.changed_regions|length }} areas affected</p>
        <img src="{{ change.result.diff_image_path }}" class="diff-image" alt="Visual diff">
    </div>
    {% endfor %}
    {% endif %}
 
    {% if minor_changes %}
    <h2>Minor Changes (< 5% difference)</h2>
    {% for change in minor_changes %}
    <div class="change-card minor">
        <h3>{{ change.url }}</h3>
        <p><strong>Similarity:</strong> {{ "%.2f"|format(change.result.similarity_score * 100) }}%</p>
        <img src="{{ change.result.diff_image_path }}" class="diff-image" alt="Visual diff">
    </div>
    {% endfor %}
    {% endif %}
 
    {% if no_changes %}
    <h2>✅ Unchanged Pages</h2>
    <ul>
    {% for url in no_changes %}
        <li>{{ url }}</li>
    {% endfor %}
    </ul>
    {% endif %}
</body>
</html>
        """
        return Template(template_str)

4. Email Notification System

import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
 
class EmailNotifier:
    def __init__(self, smtp_config: dict):
        self.smtp_config = smtp_config
 
    def send_report(
        self,
        to_emails: list[str],
        report_html_path: str,
        diff_images: list[str],
        summary: dict
    ):
        """Отправляет email с отчетом тимлиду"""
 
        msg = MIMEMultipart('related')
        msg['Subject'] = self._generate_subject(summary)
        msg['From'] = self.smtp_config['from_email']
        msg['To'] = ', '.join(to_emails)
 
        # HTML body
        with open(report_html_path, 'r', encoding='utf-8') as f:
            html_content = f.read()
 
        msg.attach(MIMEText(html_content, 'html'))
 
        # Attach diff images inline
        for idx, img_path in enumerate(diff_images):
            with open(img_path, 'rb') as f:
                img = MIMEImage(f.read())
                img.add_header('Content-ID', f'<diff_image_{idx}>')
                msg.attach(img)
 
        # Send email
        with smtplib.SMTP(
            self.smtp_config['host'],
            self.smtp_config['port']
        ) as server:
            server.starttls()
            server.login(
                self.smtp_config['username'],
                self.smtp_config['password']
            )
            server.send_message(msg)
 
    def _generate_subject(self, summary: dict) -> str:
        """Генерирует subject line в зависимости от результатов"""
 
        critical = summary.get('critical_count', 0)
        minor = summary.get('minor_count', 0)
 
        if critical > 0:
            return f"🔴 Perfector Alert: {critical} Critical UI Changes Detected"
        elif minor > 0:
            return f"🟡 Perfector Report: {minor} Minor UI Changes Found"
        else:
            return "✅ Perfector Report: No UI Changes Detected"

Интеграция в CI/CD

GitHub Actions workflow:

name: Visual Regression Testing
 
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
 
jobs:
  visual-regression:
    runs-on: ubuntu-latest
 
    steps:
      - uses: actions/checkout@v3
 
      - name: Build and deploy to staging
        run: |
          docker build -t myapp:test .
          docker run -d -p 8080:8080 myapp:test
 
      - name: Wait for app to be ready
        run: |
          timeout 60 bash -c 'until curl -f http://localhost:8080; do sleep 2; done'
 
      - name: Run Perfector
        uses: docker://perfector:latest
        env:
          PERFECTOR_MODE: compare
          PERFECTOR_URLS: urls.json
          PERFECTOR_BASELINE_DIR: /baseline
          PERFECTOR_THRESHOLD: 0.95
          SMTP_HOST: ${{ secrets.SMTP_HOST }}
          SMTP_USER: ${{ secrets.SMTP_USER }}
          SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
          TEAM_LEAD_EMAIL: ${{ secrets.TEAM_LEAD_EMAIL }}
 
      - name: Upload diff artifacts
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: visual-diffs
          path: diffs/
 
      - name: Comment on PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v6
        with:
          script: |
            // Attach visual diff report to PR comment
            const fs = require('fs');
            const report = fs.readFileSync('report.md', 'utf8');
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: report
            });

Результаты

Perfector превратил ненадежный manual процесс проверки UI в автоматизированную, быструю и точную систему, предотвращающую регрессии верстки до попадания в production.

Предотвращение багов:

  • Раннее обнаружение — баги верстки ловятся в CI/CD до мержа
  • 100% coverage — все страницы проверяются автоматически
  • Визуальные доказательства — четкие side-by-side сравнения
  • Контекст для review — тимлид видит что именно изменилось

Экономия времени:

  • Автоматизация QA — не нужно вручную проверять десятки страниц
  • Faster feedback loop — результаты за минуты, а не часы
  • Reduced debugging time — точно известно что и где сломалось
  • Confidence в изменениях — можно смело рефакторить CSS

Качество продукта:

  • Консистентный UI — случайные изменения не проскальзывают
  • Better UX — пользователи не видят сломанную верстку
  • Professional image — меньше embarrassing bugs в production
  • Regression prevention — история baseline защищает от откатов

Процессные улучшения:

  • Data-driven decisions — тимлид видит impact изменений
  • Clear accountability — кто и когда сломал верстку
  • Documentation — история визуальных изменений проекта
  • A/B testing support — сравнение вариантов дизайна

Ключевые выводы

Компьютерное зрение в тестировании: Использование OpenCV для UI testing — нестандартный, но мощный подход. В отличие от traditional E2E тестов (которые проверяют behavior), визуальное тестирование ловит subtle UI bugs, которые иначе пропускаются.

Shift-left testing: Интеграция в CI/CD сдвигает обнаружение багов влево — из production в staging, из staging в PR review. Чем раньше найден баг, тем дешевле его исправить.

Human-in-the-loop: Система не блокирует деплои автоматически, а отправляет отчет тимлиду для принятия решения. Это правильный баланс: автоматизация детекции + human judgment для оценки критичности.

Technical excellence: Проект демонстрирует способность:

  • Применять computer vision (OpenCV) для практических задач
  • Проектировать pipeline-based системы
  • Интегрироваться с CI/CD и DevOps инфраструктурой
  • Создавать developer-friendly инструменты

Platform vs Tool: Perfector — это не одноразовый скрипт, а полноценная платформа с конфигурацией, Docker-изоляцией, report generation, notifications. Production-ready инженерия.


Применимость:

  • E-commerce: критично для consistency UI при A/B тестах
  • SaaS: предотвращение breaking changes в dashboard
  • Media: защита layout при responsive changes
  • Enterprise: compliance с брендбуком и UI guidelines

Система особенно ценна для крупных проектов с большим количеством страниц, где manual regression testing физически невозможен.

Похожие материалы

Проекты с похожими технологиями и задачами