AI-агент для автоматичного тестування UI (AI QA)
Класична проблема 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% (наприклад, для проекту з бюджетом $10 000 економія $7 000)
- Зниження витрат на 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-тести в 10 разів скорочують час підтримки і в 7 разів зменшують кількість падінь при зміні UI.
Як написати AI-тест: покрокова інструкція
- Опишіть сценарій природною мовою: наприклад, «Зайти на сторінку логіну, ввести email і пароль, натиснути Увійти, перевірити, що з'явилося привітання».
- Передайте опис агенту — він сам розіб'є його на кроки і виконає, використовуючи AI-підбір селекторів та візуальний аналіз.
- Налаштуйте очікувані ассерти: для кожного кроку вкажіть, що має відбутися (текст, елемент, стан).
- Запустіть тест в ізольованому середовищі (Docker, CI-контейнер).
- Додайте тест у CI/CD — агент згенерує HTML-звіт зі скріншотами кожного кроку.
Процес впровадження AI-агента
| Етап | Опис | Орієнтовний термін |
|---|---|---|
| Аналіз | Вивчаємо поточний набір тестів та сценарії | 1–2 дні |
| Проектування | Визначаємо, які тести конвертувати, а які залишити | 1 день |
| Реалізація | Розробляємо AI-агента під ваш стек | 3–5 днів |
| Тестування | Прогоняємо на реальних кейсах, порівнюємо з еталоном | 2–3 дні |
| Деплой | Інтеграція в CI/CD (Jenkins, GitLab CI, GitHub Actions) | 3–5 днів |
Як AI-агент знаходить елементи, якщо немає data-testid?
В основі лежить комбінація методів:
- Генерація CSS-селекторів за текстовим описом (через LLM).
- Візуальний аналіз скріншота — якщо селектори не спрацювали, агент передає зображення сторінки моделі для пошуку елемента.
- Використання текстового вмісту (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). Замовте консультацію — ми проаналізуємо ваш проект і запропонуємо оптимальне рішення.







