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 недели







