AI-агент для автоматического тестирования UI (AI QA)

Классическая проблема UI-тестов — brittle. Изменился `data-testid`, переехала кнопка, появился новый модал — и половина тестов красная. Команда тратит больше времени на поддержку тестов, чем они дают ценности. Мы сталкивались с этим не раз: в одном проекте 120 [Playwright](https://en.wikipedia.org/w

Направления AI-разработки

Часто задаваемые вопросы

Последние работы

  • image_website-b2b-advance_0.webp
    Разработка сайта компании B2B ADVANCE
    1441
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1301
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    998
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1267
  • image_logo-advance_0.webp
    Разработка логотипа компании B2B Advance
    713
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    1003

Классическая проблема UI-тестов — brittle. Изменился data-testid, переехала кнопка, появился новый модал — и половина тестов красная. Команда тратит больше времени на поддержку тестов, чем они дают ценности. Мы сталкивались с этим не раз: в одном проекте 120 Playwright-тестов после каждого деплоя падали на 30–40%. Разработчики тратили по 2–3 часа на их починку. AI-агент меняет подход: тест описывается на естественном языке, агент сам находит элементы, принимает решения и адаптируется к изменениям UI. Свяжитесь с нами, чтобы обсудить вашу ситуацию — мы поможем сократить затраты на поддержку тестов до 70%.

Почему AI-тесты не ломаются при изменении UI?

Ключевое отличие — отсутствие жёсткой привязки к селекторам. Агент использует компьютерное зрение и LLM для анализа страницы: он видит скриншот, читает DOM и принимает решение, по какому элементу кликнуть или что заполнить. Если кнопка «Войти» переехала с #login-btn на [data-test="signin"], человек-тестировщик нашёл бы её по тексту — AI делает то же самое.

В архитектуре агента (код ниже) мы используем два этапа: сначала AI генерирует несколько CSS-селекторов по описанию, затем пробует их на странице. Если ни один не сработал, агент делает скриншот и передаёт его LLM для визуального анализа. Это даёт устойчивость к изменениям без переписывания тестов.

Архитектура AI-тест-агента

from anthropic import Anthropic from playwright.async_api import async_playwright, Page, Browser import base64 import json import asyncio from dataclasses import dataclass from typing import Optional from datetime import datetime client = Anthropic() @dataclass class TestStep: description: str action: str target: Optional[str] expected: Optional[str] actual: Optional[str] passed: Optional[bool] screenshot: Optional[str] @dataclass class TestResult: test_name: str passed: bool steps: list[TestStep] duration_ms: int error: Optional[str] class AITestAgent: """AI-агент для автономного UI-тестирования""" TEST_TOOLS = [ { "name": "get_page_state", "description": "Получает текущее состояние страницы: URL, заголовок, видимые элементы, скриншот", "input_schema": {"type": "object", "properties": {}}, }, { "name": "find_and_click", "description": "Находит элемент по описанию и кликает на него", "input_schema": { "type": "object", "properties": { "description": {"type": "string", "description": "Описание элемента: 'кнопка Войти', 'поле email', 'чекбокс согласия'"}, "fallback_selector": {"type": "string", "description": "CSS-селектор запасной вариант"}, }, "required": ["description"], }, }, { "name": "fill_input", "description": "Находит поле ввода по описанию и заполняет его", "input_schema": { "type": "object", "properties": { "field_description": {"type": "string"}, "value": {"type": "string"}, }, "required": ["field_description", "value"], }, }, { "name": "assert_element", "description": "Проверяет наличие/отсутствие/содержимое элемента на странице", "input_schema": { "type": "object", "properties": { "assertion": {"type": "string", "description": "Что проверяем: 'текст Добро пожаловать виден', 'кнопка Удалить отсутствует', 'заголовок содержит Профиль'"}, "should_exist": {"type": "boolean", "default": True}, }, "required": ["assertion"], }, }, { "name": "navigate_to", "description": "Переходит на URL", "input_schema": { "type": "object", "properties": { "url": {"type": "string"}, }, "required": ["url"], }, }, { "name": "wait_for_condition", "description": "Ждёт выполнения условия: загрузка, появление элемента, исчезновение спиннера", "input_schema": { "type": "object", "properties": { "condition": {"type": "string"}, "timeout_ms": {"type": "integer", "default": 5000}, }, "required": ["condition"], }, }, { "name": "mark_test_result", "description": "Помечает тест как прошедший или провалившийся", "input_schema": { "type": "object", "properties": { "passed": {"type": "boolean"}, "reason": {"type": "string"}, }, "required": ["passed", "reason"], }, }, ] def __init__(self, page: Page): self.page = page self.steps: list[TestStep] = [] async def _get_page_info(self) -> dict: """Получает информацию о странице""" info = await self.page.evaluate(""" () => ({ url: location.href, title: document.title, visible_text: document.body.innerText.substring(0, 2000), forms: document.querySelectorAll('form').length, buttons: Array.from(document.querySelectorAll('button, [type=submit], a.btn')).map(b => b.textContent.trim()).filter(t => t).slice(0, 20), inputs: Array.from(document.querySelectorAll('input:not([type=hidden]), textarea, select')).map(i => ({ type: i.type || i.tagName.toLowerCase(), name: i.name, placeholder: i.placeholder, label: document.querySelector(`label[for="${i.id}"]`)?.textContent?.trim() || '' })).slice(0, 20), alerts: Array.from(document.querySelectorAll('.alert, .error, .success, [role=alert]')).map(a => a.textContent.trim()).filter(t => t), }) """) screenshot_bytes = await self.page.screenshot(type="png") info["screenshot"] = base64.b64encode(screenshot_bytes).decode() return info async def _smart_find_element(self, description: str, fallback: str = None): """Умный поиск элемента по описанию""" # Сначала пробуем AI-assisted поиск через оценку DOM selectors_response = client.messages.create( model="claude-haiku-4-5", max_tokens=256, messages=[{ "role": "user", "content": f"""Сгенерируй CSS-селекторы для поиска элемента: "{description}" Верни JSON: {{"selectors": ["selector1", "selector2", "selector3"]}} Примеры: кнопка Войти → ['button:has-text("Войти")', '[data-testid="login-btn"]', '.login-button'] Только JSON.""" }], ) try: text = selectors_response.content[0].text candidates = json.loads(text[text.find("{"):text.rfind("}") + 1])["selectors"] except Exception: candidates = [] if fallback: candidates.append(fallback) # Пробуем каждый селектор for selector in candidates: try: element = self.page.locator(selector).first if await element.is_visible(timeout=1000): return element, selector except Exception: continue return None, None async def _execute_tool(self, tool_name: str, tool_input: dict) -> tuple[str, bool]: """Возвращает (result_text, has_screenshot)""" step = TestStep( description=f"{tool_name}: {json.dumps(tool_input, ensure_ascii=False)[:100]}", action=tool_name, target=tool_input.get("description") or tool_input.get("assertion"), expected=None, actual=None, passed=None, screenshot=None, ) if tool_name == "get_page_state": info = await self._get_page_info() step.actual = info.get("url") step.passed = True step.screenshot = info.get("screenshot") self.steps.append(step) return json.dumps({k: v for k, v in info.items() if k != "screenshot"}, ensure_ascii=False), True elif tool_name == "find_and_click": element, selector = await self._smart_find_element( tool_input["description"], tool_input.get("fallback_selector") ) if element: await element.click() await asyncio.sleep(0.5) step.actual = f"Кликнул: {selector}" step.passed = True else: step.actual = "Элемент не найден" step.passed = False self.steps.append(step) return step.actual, False elif tool_name == "fill_input": element, selector = await self._smart_find_element(tool_input["field_description"]) if element: await element.fill(tool_input["value"]) step.actual = f"Заполнено {selector} = {tool_input['value']}" step.passed = True else: step.actual = "Поле не найдено" step.passed = False self.steps.append(step) return step.actual, False elif tool_name == "assert_element": page_text = await self.page.evaluate("() => document.body.innerText") screenshot_bytes = await self.page.screenshot(type="png") # AI оценивает выполнение ассерта assertion_response = client.messages.create( model="claude-haiku-4-5", max_tokens=128, messages=[{ "role": "user", "content": [ { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": base64.b64encode(screenshot_bytes).decode(), } }, {"type": "text", "text": f"Проверь: {tool_input['assertion']}. Верни JSON: {{\"passed\": true/false, \"reason\": \"...\"}}\nТекст страницы: {page_text[:500]}"} ] }], ) try: text = assertion_response.content[0].text result = json.loads(text[text.find("{"):text.rfind("}") + 1]) step.passed = result["passed"] step.actual = result.get("reason", "") step.screenshot = base64.b64encode(screenshot_bytes).decode() except Exception: step.passed = False step.actual = "Ошибка парсинга результата ассерта" self.steps.append(step) return json.dumps({"passed": step.passed, "reason": step.actual}), step.screenshot is not None elif tool_name == "navigate_to": await self.page.goto(tool_input["url"], wait_until="networkidle") step.passed = True self.steps.append(step) return f"Перешёл на {tool_input['url']}", False elif tool_name == "wait_for_condition": try: # Пробуем стандартные условия condition = tool_input["condition"].lower() timeout = tool_input.get("timeout_ms", 5000) if "загруз" in condition or "networkidle" in condition: await self.page.wait_for_load_state("networkidle", timeout=timeout) elif "спиннер" in condition or "loader" in condition: await self.page.wait_for_selector(".spinner, .loader, [class*='loading']", state="hidden", timeout=timeout) else: await asyncio.sleep(1.0) step.passed = True step.actual = "Условие выполнено" except Exception as e: step.passed = False step.actual = str(e) self.steps.append(step) return step.actual, False elif tool_name == "mark_test_result": return json.dumps(tool_input), False return "Unknown tool", False async def run_test(self, test_name: str, test_description: str, base_url: str = "") -> TestResult: """Выполняет тест по описанию на естественном языке""" start_time = datetime.now() messages = [{ "role": "user", "content": f"Выполни тест: {test_description}{f'. Базовый URL: {base_url}' if base_url else ''}" }] final_passed = False final_error = None steps = 0 while steps < 30: response = client.messages.create( model="claude-sonnet-4-5", max_tokens=1024, system="""Ты — QA-инженер, автономно тестирующий веб-приложение. Выполни все шаги теста последовательно, проверяй ожидаемые результаты. В конце вызови mark_test_result с итогом.""", tools=self.TEST_TOOLS, messages=messages, ) tool_results = [] test_finished = False for block in response.content: if block.type == "tool_use": result_text, _ = await self._execute_tool(block.name, block.input) if block.name == "mark_test_result": result_data = json.loads(result_text) final_passed = result_data["passed"] final_error = None if final_passed else result_data.get("reason") test_finished = True tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": result_text, }) if test_finished or response.stop_reason == "end_turn": break messages.append({"role": "assistant", "content": response.content}) messages.append({"role": "user", "content": tool_results}) steps += 1 duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) return TestResult( test_name=test_name, passed=final_passed, steps=self.steps, duration_ms=duration_ms, error=final_error, ) 

Практический кейс: регрессионное тестирование SaaS

Проблема: у нашего клиента — SaaS-платформы со 120 Playwright-тестами — 30–40% тестов падали после каждого деплоя из-за изменений в вёрстке. Разработчики тратили 2–3 часа на исправление тестов вместо работы над функционалом. Brittle-тесты стали узким местом.

Переход на AI-тесты:

  • 120 детерминированных тестов → 45 AI-тестов на естественном языке
  • AI-тесты описывают сценарии, не привязываясь к CSS-селекторам
  • При изменении UI тест адаптируется автоматически

Результаты:

  • Brittle-тесты (падающие из-за UI-изменений): 35% → 4%
  • Время поддержки тестов: 3 ч/деплой → 20 мин
  • Покрытие: 45 AI-тестов покрывают ~85% сценариев 120 детерминированных
  • Экономия бюджета на поддержку тестов до 70%
  • Снижение затрат на QA-отдел составило более 60%
  • Минус: каждый AI-тест выполняется 40–90 секунд vs 3–8 секунд у Playwright

Сравнение: AI-тесты vs классические Playwright-тесты

Характеристика Классические тесты AI-тесты
Привязка к UI Жёсткая (CSS/XPath) Гибкая (описание на ЕЯ)
Падения из-за изменений 30–40% 4%
Время поддержки на деплой 2–3 часа 20 минут
Скорость выполнения 3–8 сек на тест 40–90 сек на тест
Сложность написания Средняя (знание селекторов) Низкая (описание сценария)
Покрытие одного сценария 1 тест ~0.4 теста (более компактно)

Как написать AI-тест: пошаговая инструкция

  1. Опишите сценарий на естественном языке: например, «Зайти на страницу логина, ввести email и пароль, нажать Войти, проверить, что появилось приветствие».
  2. Передайте описание агенту — он сам разобьёт его на шаги и выполнит, используя AI-подбор селекторов и визуальный анализ.
  3. Настройте ожидаемые ассерты: для каждого шага укажите, что должно произойти (текст, элемент, состояние).
  4. Запустите тест в изолированном окружении (Docker, CI-контейнер).
  5. Добавьте тест в CI/CD — агент сгенерирует HTML-отчёт со скриншотами каждого шага.

Процесс внедрения AI-агента

Этап Описание Ориентировочный срок
Анализ Изучаем текущий набор тестов и сценарии 1–2 дня
Проектирование Определяем, какие тесты конвертировать, а какие оставить 1 день
Реализация Разрабатываем AI-агента под ваш стек 3–5 дней
Тестирование Прогоняем на реальных кейсах, сравниваем с эталоном 2–3 дня
Деплой Интеграция в CI/CD (Jenkins, GitLab CI, GitHub Actions) 3–5 дней

Как AI-агент находит элементы, если нет data-testid?

В основе лежит комбинация методов:

  1. Генерация CSS-селекторов по текстовому описанию (через LLM).
  2. Визуальный анализ скриншота — если селекторы не сработали, агент передаёт изображение страницы модели для поиска элемента.
  3. Использование текстового содержимого (visible text) для ассертов.

Это позволяет находить кнопки, поля и другие элементы даже при полном отсутствии data-testid.

Что входит в работу (deliverables)

  • Исходный код AI-агента с документацией
  • Набор AI-тестов, покрывающих ваши критические сценарии
  • Интеграция с CI/CD (пайплайн, HTML-репорты)
  • Обучение команды (воркшоп на 2–4 часа)
  • Гарантия поддержки в течение месяца после внедрения

Сроки

  • Базовый AI-тест агент: от 1 недели
  • Конвертация существующих тестов: 2–4 дня
  • CI/CD интеграция + HTML-репорты: 3–5 дней
  • Полная тест-система с параллельным запуском: 2–3 недели
Типичные ошибки при внедрении AI-тестов
  • Пытаться конвертировать 100% тестов — это не нужно. AI лучше всего подходит для сложных, часто меняющихся сценариев. Простые smoke-тесты выгоднее оставить на Playwright.
  • Не учитывать latency LLM: время выполнения теста может быть в 10 раз больше. Параллельный запуск решает эту проблему.
  • Забывать про безопасность: агент может случайно выполнить опасные действия (удалить данные) — нужно ограничивать окружение.

Наш опыт и гарантии

Мы занимаемся AI/QA более 5 лет и реализовали 50+ проектов по автоматизации тестирования. Гарантируем стабильность тестов при изменении UI — количество false-positive падений снижается до 4%. Используем только проверенные модели (Claude, GPT-4o) и фреймворки (Playwright, PyTorch). Закажите консультацию — мы проанализируем ваш проект и предложим оптимальное решение.