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 filenameDocker контейнер для изоляции:
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 физически невозможен.
Похожие материалы
Проекты с похожими технологиями и задачами
PVS-Studio — Система автоматизированного тестирования
Комплексная система E2E-тестирования на Selenium для полного покрытия корпоративного сайта pvs-studio.com. Автоматизация регрессионного тестирования критически важного бизнес-ресурса.
- Selenium
- Python
- pytest
- Docker
- CI/CD
Цифровой тьютор
Аналитическая платформа для дирекции университета с инструментами оценки усвоения материала студентами и рекомендациями по оптимизации учебного процесса
- Python
- Django
- PostgreSQL
- Redis
- Celery
- +2
Viva64 Trial CRM — Автоматизация email-маркетинга
Система автоматизированного email-маркетинга на основе триггерных писем для конвертации пользователей триальных версий PVS-Studio в платящих клиентов. CRM для управления жизненным циклом trial-пользователей.
- Python
- Django
- Celery
- PostgreSQL
- SMTP
- +1