Интеграция Playwright/Selenium с LLM для автономного тестирования

Проектируем и внедряем системы искусственного интеллекта: от прототипа до production-ready решения. Наша команда объединяет экспертизу в машинном обучении, дата-инжиниринге и MLOps, чтобы AI работал не в лаборатории, а в реальном бизнесе.
Показано 1 из 1 услугВсе 1566 услуг
Интеграция Playwright/Selenium с LLM для автономного тестирования
Средняя
от 1 рабочего дня до 3 рабочих дней
Часто задаваемые вопросы
Направления AI-разработки
Этапы разработки AI-решения
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1218
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    853
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1047
  • image_logo-advance_0.png
    Разработка логотипа компании B2B Advance
    561
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    825

Автономное тестирование: Playwright/Selenium + LLM

E2E-тесты на Playwright и Selenium работают хорошо, пока верстка стабильна. На активно развивающихся продуктах команда из 5 разработчиков тратит 6–8 часов в неделю только на починку тестов после рефакторингов. LLM не заменяет Playwright, но убирает главную боль: вместо жёстких локаторов — семантический поиск элементов и автоматическое восстановление упавших тестов.

Гибридный подход: Playwright + LLM-assisted локаторы

Идея: используем Playwright для надёжного управления браузером, а LLM — только для нахождения нужных элементов когда стандартные локаторы не работают.

from playwright.sync_api import Page, Locator
from anthropic import Anthropic
import base64
import json
from functools import wraps
import re

client = Anthropic()


class SmartLocator:
    """Умный локатор с AI-fallback"""

    def __init__(self, page: Page):
        self.page = page
        self._locator_cache: dict[str, str] = {}

    def find(self, description: str, prefer_selector: str = None) -> Locator:
        """Находит элемент по описанию, кэшируя успешные локаторы"""

        # 1. Пробуем кэшированный локатор
        if description in self._locator_cache:
            selector = self._locator_cache[description]
            try:
                loc = self.page.locator(selector)
                if loc.count() > 0 and loc.first.is_visible(timeout=500):
                    return loc.first
            except Exception:
                del self._locator_cache[description]

        # 2. Пробуем предпочтительный локатор
        if prefer_selector:
            try:
                loc = self.page.locator(prefer_selector)
                if loc.count() > 0:
                    self._locator_cache[description] = prefer_selector
                    return loc.first
            except Exception:
                pass

        # 3. Playwright built-in семантика
        semantic_attempts = [
            lambda: self.page.get_by_role("button", name=re.sub(r"кнопк[аиу] ", "", description, flags=re.I)),
            lambda: self.page.get_by_label(description),
            lambda: self.page.get_by_placeholder(description),
            lambda: self.page.get_by_text(description, exact=False),
        ]

        for attempt in semantic_attempts:
            try:
                loc = attempt()
                if loc.count() > 0 and loc.first.is_visible(timeout=500):
                    return loc.first
            except Exception:
                continue

        # 4. AI-генерация локатора
        return self._ai_find_element(description)

    def _ai_find_element(self, description: str) -> Locator:
        """Использует LLM для поиска элемента по скриншоту и DOM"""
        screenshot_bytes = self.page.screenshot()
        dom_snippet = self.page.evaluate("""
            () => {
                const elements = document.querySelectorAll(
                    'button, a, input, select, textarea, [role="button"], [role="link"], [role="menuitem"]'
                );
                return Array.from(elements).slice(0, 60).map(el => ({
                    tag: el.tagName.toLowerCase(),
                    text: el.textContent?.trim().slice(0, 60) || '',
                    id: el.id || '',
                    class: el.className?.toString().slice(0, 60) || '',
                    type: (el as any).type || '',
                    name: (el as any).name || '',
                    placeholder: (el as any).placeholder || '',
                    aria_label: el.getAttribute('aria-label') || '',
                    data_testid: el.getAttribute('data-testid') || '',
                })).filter(e => e.text || e.placeholder || e.aria_label);
            }
        """)

        response = client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=256,
            messages=[{
                "role": "user",
                "content": [
                    {
                        "type": "image",
                        "source": {
                            "type": "base64",
                            "media_type": "image/png",
                            "data": base64.b64encode(screenshot_bytes).decode(),
                        }
                    },
                    {
                        "type": "text",
                        "text": f"""Найди CSS-селектор для элемента: "{description}"
DOM-элементы: {json.dumps(dom_snippet[:30], ensure_ascii=False)}

Верни JSON: {{"selector": "css_selector", "confidence": 0.0-1.0}}
Предпочитай: [data-testid="..."], #id, [aria-label="..."]
Только JSON."""
                    }
                ]
            }],
        )

        try:
            text = response.content[0].text
            result = json.loads(text[text.find("{"):text.rfind("}") + 1])
            selector = result["selector"]

            loc = self.page.locator(selector)
            if loc.count() > 0:
                self._locator_cache[description] = selector
                return loc.first
        except Exception:
            pass

        raise RuntimeError(f"Элемент не найден: {description}")


class SelfHealingTest:
    """Тест с самовосстановлением локаторов"""

    def __init__(self, page: Page):
        self.page = page
        self.smart = SmartLocator(page)
        self.failed_steps: list = []

    def step(self, description: str):
        """Декоратор для шагов теста с AI-восстановлением"""
        def decorator(func):
            @wraps(func)
            def wrapper(*args, **kwargs):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    # Пробуем восстановить через AI
                    recovery = self._attempt_recovery(description, str(e))
                    if recovery:
                        return recovery
                    self.failed_steps.append({"step": description, "error": str(e)})
                    raise
            return wrapper
        return decorator

    def _attempt_recovery(self, step_description: str, error: str):
        """Пытается восстановить провалившийся шаг"""
        screenshot_bytes = self.page.screenshot()

        response = client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=256,
            messages=[{
                "role": "user",
                "content": [
                    {
                        "type": "image",
                        "source": {
                            "type": "base64",
                            "media_type": "image/png",
                            "data": base64.b64encode(screenshot_bytes).decode(),
                        }
                    },
                    {
                        "type": "text",
                        "text": f"""Тест упал на шаге: "{step_description}"
Ошибка: {error}

Видишь ли ты на скриншоте что-то, что мешает выполнению?
Верни JSON: {{"blocker": "описание проблемы или null", "recovery_selector": "css или null"}}"""
                    }
                ]
            }],
        )

        try:
            text = response.content[0].text
            result = json.loads(text[text.find("{"):text.rfind("}") + 1])

            if result.get("recovery_selector"):
                self.page.click(result["recovery_selector"])
                return True
        except Exception:
            pass

        return None

LLM-генерация тестов из user stories

def generate_playwright_test(user_story: str, base_url: str, page_html: str = "") -> str:
    """Генерирует Playwright test из user story"""
    response = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=2048,
        messages=[{
            "role": "user",
            "content": f"""Сгенерируй Playwright Python тест для user story:

{user_story}

Base URL: {base_url}
{f"HTML контекст страницы: {page_html[:2000]}" if page_html else ""}

Требования к тесту:
- Используй Playwright best practices: get_by_role, get_by_label, get_by_text
- Явные expect() с timeout
- Без захардкоженных sleep()
- Комментарии к каждому шагу
- Используй data-testid если видишь в HTML

Формат: только Python код, без пояснений."""
        }],
    )

    return response.content[0].text


# Пример использования
story = """
Как пользователь, я хочу войти в систему:
1. Перейти на страницу /login
2. Ввести email [email protected]
3. Ввести пароль TestPass123
4. Нажать кнопку "Войти"
5. Увидеть страницу дашборда с текстом "Добро пожаловать"
"""

test_code = generate_playwright_test(story, "https://app.example.com")
print(test_code)

AI-анализ упавших тестов

def analyze_test_failure(test_name: str, error_log: str, screenshot_path: str) -> dict:
    """Анализирует причину падения теста и предлагает фикс"""
    with open(screenshot_path, "rb") as f:
        screenshot_b64 = base64.b64encode(f.read()).decode()

    response = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=1024,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {"type": "base64", "media_type": "image/png", "data": screenshot_b64}
                },
                {
                    "type": "text",
                    "text": f"""Тест упал: {test_name}

Лог ошибки:
{error_log[:2000]}

Проанализируй скриншот и лог. Верни JSON:
{{
  "root_cause": "краткое описание причины",
  "is_app_bug": true/false,
  "is_test_bug": true/false,
  "suggested_fix": "как исправить тест или баг",
  "new_selector": "если проблема в локаторе — новый CSS-селектор"
}}"""
                }
            ]
        }],
    )

    text = response.content[0].text
    return json.loads(text[text.find("{"):text.rfind("}") + 1])

Практический кейс: интернет-магазин, 350 тестов

Ситуация: активная разработка фронтенда (React + Tailwind). Дизайнер менял CSS-классы каждые 2 недели. 40% Selenium-тестов падало не из-за багов, а из-за изменений верстки. CI/CD pipeline показывал красный цвет, команда игнорировала.

Что изменили:

  • Перевели 80 самых нестабильных тестов на SmartLocator с AI-fallback
  • Настроили AI-анализ упавших тестов в CI: автоматически отделяет «баг в коде» от «изменился локатор»
  • LLM-генерация заготовок тестов для новых user stories

Результаты:

  • Ложно-красные тесты (UI изменился, не баг): 40% → 6%
  • Время на анализ упавших тестов в CI: 2 ч/деплой → 20 мин
  • Новые тест-заготовки от LLM: разработчик принимает 70% без правок, 30% требуют корректировки

Важно: AI-анализ упавших тестов особенно ценен — разработчики перестали игнорировать красный CI, потому что теперь сразу видят «это реальный баг» или «это локатор поехал».

Сроки

  • SmartLocator + AI-fallback для существующей тест-базы: 1 неделя
  • AI-генерация тестов + ревью пайплайн: 1 неделя
  • CI/CD интеграция с анализом падений: 3–5 дней
  • Полная система для большой тест-базы: 3–4 недели