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

Проектируем и внедряем системы искусственного интеллекта: от прототипа до production-ready решения. Наша команда объединяет экспертизу в машинном обучении, дата-инжиниринге и MLOps, чтобы AI работал не в лаборатории, а в реальном бизнесе.
Показано 1 из 1 услугВсе 1566 услуг
AI-агент для автоматического тестирования UI (AI QA)
Средняя
~2-4 недели
Часто задаваемые вопросы
Направления 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

AI-автоматизация UI-тестирования и QA

Классическая проблема UI-тестов: brittle. Изменился data-testid, переехала кнопка, появился новый modal — и половина тестов красная. Команда тратит больше времени на поддержку тестов, чем они дают ценности. AI-агент меняет подход: тест описывается на естественном языке, агент сам находит элементы, принимает решения и адаптируется к изменениям UI.

Архитектура 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

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

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

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

Результаты:

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

Сроки

  • Базовый AI-тест агент: 1 неделя
  • Конвертация существующих тестов: 2–4 дня
  • CI/CD интеграция + HTML-репорты: 3–5 дней
  • Полная тест-система с параллельным запуском: 2–3 недели