AI-агент для автоматичного тестування UI

AI-агент для автоматичного тестування UI (AI QA) Класична проблема UI-тестів — brittle. Змінився `data-testid`, переїхала кнопка, з'явився новий модал — і половина тестів червона. Команда витрачає більше часу на підтримку тестів, ніж вони дають цінності. Ми стикалися з цим не раз: в одному проект

Напрямки 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

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-тест: покрокова інструкція

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