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). Замовте консультацію — ми проаналізуємо ваш проект і запропонуємо оптимальне рішення.







