Привет, Хабр! Меня зовут Иван Калашников, я занимаюсь автотестированием в Инфовотч.
В мире web и тонких клиентов по-прежнему приходится тестировать классические приложения: Office apps, Explorer, Telegram, WhatsApp. Сегодня для примера мы возьмем WhatsApp.
Погрузившись в автоматизацию ручных кликов в приложениях Windows с помощью Python, я попробовал несколько известных библиотек, каждая из которых поодиночке оставляла ощущение «чего-то не хватает». PyAutoGUI не видит скрытые элементы, плохо находит элементы с экранами разного масштаба и разрешения, а pywinauto требует разбирать дерево элементов UIA (Microsoft UI Automation), которого может попросту не быть.
В этой статье разберём, как объединение этих инструментов позволяет обойти ограничения каждого и надёжно автоматизировать windows-приложения. Комбинация UI-ориентированных (pywinauto, Win32/UIA) и image-based (PyAutoGUI) техник остаётся самым гибким способом тестировать Windows-приложения. Но чтобы смесь действительно работала, нужны: сравнение бэкендов, явные ожидания, DPI-awareness.
Мы пройдем через ряд мини-кейсов — от кликов, поиска, отправки сообщения, до чтения текста с экрана и выясним, как справляется связка Python-библиотек.
С какими приложениями UIA/Win32 работает хорошо?
Обычные Win32-приложения (Notepad, Calculator, Calendar);
WPF (Windows Presentation Foundation);
Windows Forms;
UWP (Universal Windows Platform) — приложения с поддержкой UI Automation;
Браузеры с включённой поддержкой accessibility (например, Edge, Chrome — через UIA или Accessibility API).
Какие приложения обычно «слепые» для UIA?
Игры на DirectX, OpenGL, Vulkan;
Electron, QtWebEngine (если не включён экспорт доступности);
Всё, что рисуется напрямую через GDI, Direct2D не имеет декларативных контролов.
Как проверить, поддерживает ли приложение UIA?
Используйте Inspect.exe из Windows SDK, путь по умолчанию -
C:\Program Files (x86)\Windows Kits\10\bin\10.0.xxxxx.0\x64\inspect.exe
(Можно скачать по ссылке:);Наведите на элемент — если есть AutomationId, ControlType, Name, то UIA работает.
Начало работы с UIA
На примере WhatsApp UWP, скачанного из Microsoft Store, разберем вход в приложение. Рассмотрим начальное окно по элементам (кнопка Get Started, текстовое поле Welcome to WhatsApp, текстовое поле с версией приложения Version 2.2523.1.0), доступным для pywinauto. Запускаем WhatsApp, открываем Inspect.exe, наводим мышкой на элемент, который хотим исследовать. Обратите внимание на скриншот.

Чтобы найти элемент в приложении с помощью pywinauto, можно использовать атрибуты Name, ControlType, AutomationId. Если элемент уникален по любому одному из этих признаков, то достаточно указать этот единственный атрибут. Если же элемент не уникален по одному признаку, потребуется комбинация атрибутов для его однозначной идентификации. Как видим на рисунке выше, у кнопки Get started есть Name
и ControlType
. Этого достаточно, чтобы навесить на элемент по локатору (title="Get started", control_type="Button"
). Также видно, что мы можем получить версию приложения, так как оно отображается в виде текстового поля.
Перейдем к тому, как автоматизировать клик на кнопку Get started по найденному локатору.
Локатор
GET_STARTED_BTN = {
"title": "Get started",
"control_type": "Button",
}
Описывает кнопку Get started по заголовку и типу UI-элемента.
Подключение к приложению
app = Application(backend="uia").connect(title="WhatsApp", timeout=10)
Соединяемся с уже запущенным окном WhatsApp через UI Automation бэкенд.
Получаем и подготавливаем главное окно
main_window = app.window(title="WhatsApp")
main_window.set_focus()
main_window.wait("visible", timeout=10)
Ставим окно в фокус и убеждаемся, что оно отобразится в течение 10 секунд.
Ждём готовности кнопки
start_btn = main_window.child_window(**GET_STARTED_BTN).wait("visible enabled", timeout=10)
Кликаем настоящей мышью
start_btn.click_input()
Метод click_input()
имитирует физический клик, обходит виртуальные ограничения UWP-окна. Виртуальное ограничение окна — это ситуация, когда приложение принимает сообщения об управлении WM_COMMAND
или UIA события, но не допускает перемещения курсора или инъекции аппаратных событий из-за песочницы APPCONTAINER
(среда безопасности, используемая Windows для запуска приложений из Microsoft Store). Наглядный пример — это приложение, запущенное под Remote Desktop: оно логически реагирует на команды, но физически видит только то, что пришло от драйвера мыши или клавиатуры.
Метод click()
отправляет контролу сообщение (WM_COMMAND, UIA Invoke
) — логический клик без движения курсора. Работает даже в том случае, если окно частично перекрыто, не требует фокуса. Аналог в Selenium веб-автоматизации element.click()
.
Метод click_input()
использует SendInput
, реально перемещая курсор на экране, и нажимает кнопку мыши. Обходит ограничения окон, где логический Invoke на элементе блокируется. Нужен видимый, активный контрол.
Скрипт целиком можно посмотреть по ссылке.
Скрипт нажимает на кнопку Get started, даже если окно (или кнопка) появляются с небольшой задержкой.
Практический сценарий автоматизации WhatsApp UWP
Открыть приложение.
Найти чат в поиске.
Убедиться, что чат открылся.
Отправить новое сообщение.
Общий класс для работы с десктопными приложениями UiAgent
__init__()
конструктор сохраняет выбранный движок автоматизации (backend), а также готовит поля под будущие объекты приложения (app) и главное окно приложения (main). Пока приложение не запущено — эти поля равны None.
class UiAgent:
_FALLBACK_CTYPES: Sequence[str | None] = ("Window", "Pane", None)
def __init__(self, backend: str = "uia") -> None:
self.backend = backend
self.app: Application | None = None
self.main: pywinauto.base_wrapper.BaseWrapper | None = None
connect()
— запускаем приложение или цепляемся к уже открытому.
Метод ищет главное окно по регулярному выражению title_re
. Если окно нашлось — просто «подключаемся» к нему. Если нет — стартуем приложение (cmd_line
) и ждём появления окна.
UWP-приложения требуют особого запуска через explorer.exe или через powershell Start-Process (Application.start()
для UWP не работает). Метод connect()
будет обрабатывать случаи, когда приложение запускается через библиотеку pywinauto — Application.start()
и случаи, когда приложение запускается через explorer.exe
Application.start()
в pywinauto запускает процесс по переданной командной строке или по пути до .exe файла и привязывает объект Application к только что созданному процессу, сохраняя его PID и дескриптор. Дополнительный Application().connect()
не нужен. Объект уже подключен к процессу, поэтому можно сразу искать окна, элементы и вызывать методы.
def connect(self, cmd_line: str, *, title_re: str, timeout: int = 15) -> None:
# 1. Пытаемся присоединиться к уже запущенному экземпляру
dlg = self._find_main_window(title_re, raise_error=False)
if dlg:
self.app = Application(backend=self.backend).connect(handle=dlg.handle)
self.main = self.app.window(handle=dlg.handle)
return
# 2. Запускаем приложение (особая обработка UWP / MS Store через explorer.exe)
if cmd_line.lower().startswith("shell:appsfolder"):
# Для UWP приложений необходим запуск через explorer.exe или через powershell Start-Process
subprocess.Popen(
["explorer.exe", cmd_line],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self.app = None # подключимся, когда окно появится
else:
self.app = Application(backend=self.backend).start(cmd_line)
# 3. Ожидаем появления главного окна
dlg = self._find_main_window(title_re, timeout=timeout, raise_error=False)
if not dlg:
raise TimeoutError(
f"Окно, соответствующее {title_re!r}, не найдено за {timeout} с после запуска"
)
# 4. Подключаемся, если запускали через explorer.exe (self.app ещё не инициализирован)
if self.app is None:
self.app = Application(backend=self.backend).connect(handle=dlg.handle)
# Сохраняем WindowSpecification, а не UIAWrapper, так как у UIAWrapper не возможно будет получить дамп дерева контролов.
self.main = self.app.window(handle=dlg.handle)
wait_for() — ждём, пока элемент появится/станет готов.
Принимает локатор (список составленных локаторов будет показан далее), нужное состояние и тайм-аут. Возвращает найденный контрол или кидает ошибку, если время вышло.
Окно может быть в одном из следующих состояний. Также допускается комбинирование состояний через пробел.
exists (существует) — означает, что окно имеет валидный дескриптор;
visible (видимое) — означает, что окно не скрыто;
enabled (активное) — означает, что окно не заблокировано;
ready (готово) — означает, что окно видимое и активное (visible + enabled);
active (на переднем плане) — означает, что окно является активным (имеет фокус).
def wait_for(
self,
locator: Mapping[str, Any],
*,
state: str = "exists",
timeout: int = 5,
):
if not self.main:
raise RuntimeError("Приложение не проинициализировано.")
return self.main.child_window(**locator).wait(state, timeout=timeout)
click()
— кликаем по элементу, а при неудаче — по картинке. Используем комбинированный подход.
Сначала пытаемся найти контрол через wait_for()
и нажать на него. Если не удалось найти элемент по локатору, используем PyAutoGUI для сравнения скриншота элемента fallback_img
с экраном. Затем кликаем по координатам найденного элемента.
Поиск элемента по картинке может быть полезен, если приложение использует защиту в виде динамически меняющихся локаторов. Например, когда меняются AutomationId, Title или Control Type. Также это актуально для кастомных элементов управления с пустыми свойствами UIA и контролов, созданных с помощью DirectX или OpenGL.
def click(
self,
locator: Mapping[str, Any],
*,
timeout: int = 5,
fallback_img: Path | None = None,
) -> None:
try:
ctrl = self.wait_for(locator, timeout=timeout)
logger.info(f"Контрол найден по локатору {locator}")
ctrl.click_input()
except (TimeoutError, RuntimeError):
logger.warning(f"Локатор {locator} (timeout={timeout}) не найден.")
logger.info(f"Локатор {locator} ищем по картинке {fallback_img}")
pt = pyautogui.locateCenterOnScreen(str(fallback_img), confidence=0.9)
if not pt:
raise LookupError(f"Элемент не найден ни по локатору {locator} ни по картинке {fallback_img}.")
logger.info(f"Клик по картинке {fallback_img}, координаты {pt}")
pyautogui.click(pt)
Рассмотрим случай, когда изображение кнопки может различаться в зависимости от темы (темная/светлая) или масштаба экрана. Для поиска нужного изображения на экране потребуется проверить массив доступных изображений. Есть папка со структурой ui_elements->Whatsapp->start_button
. В директории start_button
будут лежать картинки всех предполагаемых видов кнопки, которую нужно найти.
Чтобы сделать скриншот нужного элемента, нужно учесть возможные разрешения экрана, масштаб и доступные темы оформления. Затем делаем скриншоты этого элемента при разных настройках окружения. Важно, чтобы элемент был полностью виден на скрине, чтобы случайно не найти похожий элемент. Сам кликабельный элемент должен быть расположен по центру.
Примеры скриншотов кнопки "вложения" в WhatsApp UWP масштабом 100% и 150%.


Полезный лайфхак — это делать скриншот всего экрана (например, с помощью pyautogui.screenshot()
), если не удалось найти элемент по имеющимся картинкам, и из этого скриншота вручную вырезать нужную область с элементом.
def load_images(folder):
"""
Загружает все изображения PNG из папки внутри ./ui_elements.
:param str folder: Относительный путь внутри ./ui_elements, например "Whatsapp/start_button"
:returns: Список кортежей (PIL.Image.Image, str), где str — полный путь к изображению
"""
base_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'ui_elements', folder)
images = []
if not os.path.isdir(base_path):
raise FileNotFoundError(f"Папка не найдена: {base_path}")
for file_name in os.listdir(base_path):
if file_name.lower().endswith('.png'):
image_path = os.path.join(base_path, file_name)
with Image.open(image_path) as img:
images.append((img.copy(), image_path))
return images
Используем полученный массив изображений для нахождения координат искомого элемента.
def locate_on_screen(self, folder, confidence=0.8, min_search_time=10):
"""
Пытается найти одно из указанных изображений в папке на экране, пока оно не будет найдено.
:param str folder: Путь к папке содержит элементы изображений с расширением ".png".
:param float trust: Уровень уверенности — это число с плавающей точкой от 0 до 1
для pyautogui.locateOnScreen.
:param int min_search_time: Количество времени в секундах для повторения поиска.
:returns: Объект Box(NamedTuple) с координатами элемента на экране или None.
"""
images = self.load_images(folder)
for image, filename in images:
try:
location = pyautogui.locateOnScreen(
image, confidence=confidence, minSearchTime=min_search_time
)
if location is not None:
# После поиска элемента мы получаем координаты его верхнего левого угла, ширину и высоту. Чтобы нажать на нужную часть кнопки, центрируемся по её середине.
return pyautogui.center(
(location.left, location.top, location.width, location.height)
)
except pyautogui.ImageNotFoundException:
logger.inf(f"Изображение не найдено, имя файла: {filename}")
return None
Далее по коду будем использовать locate_on_screen
функцию для поиска координат элементов. Теперь можно управлять базой изображений элементов через наполнение папок нужными скриншотами.
type_keys()
— вводим текст, при желании жмём Enter.
Активное окно уже известно, поэтому напрямую посылаем последовательность клавиш. Аргумент enter=True
добавляет {ENTER} в конец строки — удобно для поиска или отправки сообщений.
def type_keys(self, text: str, *, enter: bool = False) -> None:
if not self.main:
raise RuntimeError("Приложение не проинициализировано.")
self.main.type_keys(text + ("{ENTER}" if enter else ""),
with_spaces=True,
set_foreground=False)
get_focus_on_window()
— вытаскиваем окно на передний план.
Нужно, когда приложение оказалось «под другими». Ищем окно по title_re
(или берём main
) и вызываем set_focus()
.
def get_focus_on_window(self, title_re: str | None = None, timeout: int = 5) -> None:
target = None
if title_re:
try:
target = (
Desktop(backend=self.backend)
.window(title_re=title_re)
.wait("exists", timeout=timeout)
)
except TimeoutError as e:
raise TimeoutError(f"Окно '{title_re}' не найдено за {timeout}с") from e
else:
target = self.main
if not target:
raise RuntimeError("Нет доступного окна для фокусировки.")
target.set_focus()
findmain_window()
— поиск главного окна.
Внутренний метод перебирает несколько возможных ControlType (Window, Pane, None), пока не найдёт видимое и готовое окно, заголовок которого совпадает с регулярным выражением title_re
. Если ничего не найдено и raise_error=True
, выбрасывает TimeoutError
.
def _find_main_window(
self,
title_re: str,
timeout: int = 5,
raise_error: bool = True,
):
"""Ищем главное окно, пробуя несколько ControlType."""
for ctype in self._FALLBACK_CTYPES:
try:
w = Desktop(backend=self.backend).window(
title_re=title_re,
control_type=ctype,
).wait("visible ready", timeout=timeout)
return w
except pywinauto.timings.TimeoutError:
continue
if raise_error:
raise TimeoutError(f"Window '{title_re}' not found")
return None
wait_for_keyboard_focus()
– ожидание фокуса элемента.
Метод позволяет дождаться клавиатурного фокуса элемента. В цикле опрашиваем has_keyboard_focus()
(для UIA-обёртки это свойство HasKeyboardFocus
) и, если по истечении тайм-аута фокуса нет, бросаем TimeoutError
. При успехе метод возвращает сам контрол, позволяя сразу выполнять следующее действие. Такой приём устраняет «флаки» в сценариях, где UI не успевает обработать предыдущую команду.
def wait_for_keyboard_focus(
self,
locator: Mapping[str, Any],
*,
timeout: int = 5,
poll: float = 0.05,
):
"""Ждём, пока элемент получит клавиатурный фокус (HasKeyboardFocus=True)."""
ctrl = self.wait_for(locator, state="exists", timeout=timeout)
start = monotonic()
while monotonic() - start < timeout:
if ctrl.has_keyboard_focus():
return ctrl # успех
sleep(poll)
raise TimeoutError(f"Элемент {locator} не получил фокус за {timeout} с")
close()
— закрываем приложение.
Если главное окно (main) известно, вызываем у него close()
. Полезно в конце теста, чтобы не оставлять «висящие» процессы.
def close(self) -> None:
if self.main:
self.main.close()
Класс для работы с приложением WhatsApp
Locator()
— компактный контейнер для локатора.
Датакласс хранит словарь параметров (auto_id
, control_type
, title_re
и т. д.) и позволяет передавать их через **locator
. Это делает описания элементов короткими и переиспользуемыми.
@dataclass(frozen=True)
class Locator:
params: dict[str, Any]
def iter(self):
return iter(self.params.items())
Часто используемые локаторы элементов вынесены в константы.
SEARCH_BOX = Locator({"auto_id": "SearchQueryTextBox", "control_type": "Edit"})
CHAT_TEXT_BOX = Locator({"auto_id": "InputBarTextBox", "control_type": "Edit"})
SENT_MSG_RE = Locator({
"auto_id": "TextBlock",
"control_type": "Text",
"title_re": "{pattern}",
})
WHATSAPP_SENT_MSG_IMG_FOLDER = "whatsapp/send_msg"
Получаем готовый UiAgent, чтобы переиспользовать его базовые операции (клики, ввод, ожидания). В классе WhatsAppAgent будут описаны базовые методы, специфичные для этого приложения.
class WhatsAppAgent:
def init(self, ui: UiAgent):
self.ui = ui
dump_controls()
— выгружаем дерево UI в файл.
Позволяет разово «снять» структуру элементов и сохранить в файл whatsapp_controls_YYYY.MM.DD.HH-MM-SS.txt
— удобно для анализа локаторов. Метод позволяет отлаживать код поиска элементов по локаторам.
def dump_controls(self) -> None:
timestamp = datetime.now().strftime("%Y.%m.%d.%H-%M-%S")
file_name = f"whatsapp_controls_{timestamp}.txt"
buf = StringIO()
with redirect_stdout(buf):
self.ui.app.window().dump_tree(depth=None)
with open(file_name, "w", encoding="utf-8") as f:
f.write(buf.getvalue())
open_chat()
— открываем нужный чат через поиск.
Нажимаем Ctrl + F, ждём, пока фокус перейдёт в строку поиска. Вводим имя контакта и жмём Enter — WhatsApp открывает диалог.
def open_chat(self, name: str) -> None:
self.ui.type_keys("^f")
self.ui.wait_for_keyboard_focus(SEARCH_BOX.params)
self.ui.type_keys(name, enter=True)
send_message()
— печатаем и отправляем сообщение.
Кликаем в поле ввода (если элемент не может быть найден по локатору, используем заранее подготовленную картинку элемента с рисунка 2). Дожидаемся реального фокуса клавиатуры. Печатаем текст и отправляем Enter.

def send_message(self, text: str) -> None:
self.ui.click(CHAT_TEXT_BOX.params, fallback_img_folder=WHATSAPP_SENT_MSG_IMG_FOLDER)
self.ui.wait_for_keyboard_focus(CHAT_TEXT_BOX.params)
self.ui.type_keys(text, enter=True)
Перед тем как погрузиться в реализацию сценария, полезно показать, как весь набор готовых методов собирается в настоящий тест. Ниже — минимальный pytest-скрипт, который:
Находит UWP-пакет WhatsApp и запускает приложение;
Через WhatsAppAgent открывает нужный чат и отправляет сообщение;
Проверяет, что сообщение действительно появилось в переписке.
Тестовый сценарий
whatsapp()
— фикстура.
Ищет
PackageFamilyName
WhatsApp в PowerShell и формирует AUMID;Создаёт UiAgent, подключается к окну WhatsApp и приводит его на передний план;
Заворачивает всё в объект WhatsAppAgent и отдаёт тестам;
После тестов закрывает приложение.
@pytest.fixture
def whatsapp():
app_name = "WhatsApp"
pkg = (
subprocess.run(
[
"powershell",
f"Get-AppxPackage *{app_name}* | Select-Object -ExpandProperty PackageFamilyName",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
.stdout.strip()
.decode("utf-8")
)
logger.info(pkg)
ui = UiAgent()
ui.connect(f"shell:AppsFolder\\{pkg}!App", title_re=app_name, timeout=25)
ui.get_focus_on_window(title_re=app_name)
wa = WhatsAppAgent(ui)
yield wa
ui.close()
test_send_message()
— тест на отправку сообщения и контроле его отправки.
Формируем динамический текст сообщения, чтобы легко отличить его от старых;
Открываем чат, отправляем сообщение;
Делаем ожидание элемента, допускающее NBSP (non-breaking space) и невидимый LTR-маркер (Left-To-Right mark, unicode = U+200E).
def test_send_message(whatsapp):
contact = CONTACT_PHONE
message = f"Hello from pywinauto {datetime.now().strftime('%Y.%m.%d.%H-%M-%S')}"
whatsapp.open_chat(contact)
whatsapp.send_message(message)
txt = re.escape(message).replace(r"\ ", r"[ \u00A0]")
pattern = rf"^{txt}[\r\n\u200e]*$"
whatsapp.ui.wait_for(
{**SENT_MSG_RE.params, "title_re": pattern},
timeout=10,
)
Анти-паттерны и лучшие практики
Не пишите координатно-ориентированные тесты, если есть UI-доступ. Используйте coordinates только как запасной парашют;
Убирайте
time.sleep()
—wait
(property
,timeout
) заметно ускоряет прогоны;Фиксируйте DPI —
import pyautogui
автоматически вызываетSetProcessDPIAware()
. win32api будет понимать реальное разрешение экрана и игнорировать масштаб. Снимайте скриншоты (PNG) на той же DPI, что и у тестовой машины;Горячие клавиши — если приложение поддерживает полноценные шорткаты, они надёжнее и быстрее кликов мыши: не зависят от расположения элементов и масштабирования интерфейса;
AutomationId (или другие стабильные уникальные идентификаторы) вместо Name- или Title-локаторов. Идентификаторы почти не меняются между версиями приложения и локализациями, поэтому тесты с ними реже «падают» после обновлений UI. Ниже рассмотрим пример, почему искать локаторы по их имени не лучшая идея.
Допустим, нам нужно прочитать сообщение, отправленное в определенное время 9:30PM. Сделаем дамп контролов, используя ранее написанную функцию dump_controls()
. На рисунке 3 дамп доступных контролов открытого чата. На нем видно невидимый символ U+200E, которым WhatsApp заканчивает текст сообщения. В данном случае локатор (title="9:30PM "
, control_type="Text"
) не сработает, нужно учитывать невидимые символы.

Локатор с параметрами title="Hello World!\r\n"
, control_type="Text"
позволяет извлечь текст сообщения, но не предоставляет время его отправки. Для получения полного сообщения, включая время, можно использовать контролы, отмеченные красным.
Особенности pywinauto
WindowSpecification
vs UIAWrapper
в pywinauto.
Таблица 1 — Сравнение WindowSpecification
и UIAWrapper
.
Характеристика |
WindowSpecification |
UIAWrapper |
Что это |
«Ленивый» объект-поиск. Хранит критерии ( |
«Обёртка» над уже найденным элементом. Содержит реальные хэндлы/паттерны и методы работы с ним ( |
Когда появляется |
Сразу после |
После вызова |
Методы поиска дальше |
Есть |
|
Промежуточное состояние |
Можно хранить как «указатель» на элемент и достраивать локатор позже. |
Фактически «снимок» на момент поиска; если окно обновится, wrapper может устареть. |
Использование в ожиданиях |
Имеет |
Тоже есть |
Почему в connect()
выгоднее хранить WindowSpecification
?
Метод
UiAgent.wait_for()
использует вызов.child_window()
, который доступен только у объектов типаWindowSpecification
. Если вместо этого хранить UIAWrapper, вызов.child_window()
станет невозможным и вызовет исключениеAttributeError
.Вызов
.child_window()
используется именно потому, что он обеспечивает поиск элемента по указанным внутри всего дерева контролов текущего окна. Это позволяет найти элемент, даже если интерфейс обновляется динамически.WindowSpecification
преобразуется в UIAWrapper в тот момент, когда понадобится взаимодействовать с ним. То есть элемент ищется по требованию, а не сразу, что позволяет избежать проблем, если состояние окна изменилось после первоначального поиска.
Если главное окно перерисуется (а UWP любит это делать), новый поиск через WindowSpecification
найдёт актуальный хэндл, а старый UIAWrapper станет недействителен.
Заключение
Комбинированный подход с использованием UI Automation и распознавания изображений позволил создать более устойчивые и надёжные скрипты тестирования по сравнению с чисто визуальным методом. UIA-подход послужил крепкой основой для автоматизации, однако на практике пришлось преодолеть ряд технических сложностей. Отдельные UIA-идентификаторы оказались нестабильными — на этот случай был реализован fallback-механизм на основе скриншотов.
Кроме того, выяснилось, что в заголовках окон присутствуют скрытые символы (например, невидимый LRM), из-за чего для поиска элементов потребовалось использовать регулярные выражения. Вывод иерархии контролов через метод print_control_identifiers
приводил к ошибке UnicodeEncodeError
, поэтому для анализа структуры окна пришлось применять альтернативный способ – дамп дерева элементов (dump_tree), что оказалось надёжнее.
Также проявились нюансы в эмуляции кликов: между методами .click()
и .click_input()
были отличия, и в некоторых случаях надёжнее работал именно физический клик через .click_input
. При использовании image-based действий (PyAutoGUI) пришлось учитывать такие факторы, как масштабирование и фокус окна – несоответствие масштаба или отсутствие активного окна приводили к сбоям в распознавании образов и кликах.
Рекомендую написать декоратор для отладки, который в случае возникновения исключения автоматически создаёт дамп текущей структуры контролов (dump_tree) и делает скриншот экрана (например, через pyautogui.screenshot
). Это позволяет наглядно и предметно выяснить причину сбоя скрипта, значительно упрощая процесс дебага и ускоряя поиск решений.
В итоге комбинированная стратегия полностью оправдала себя. Оптимально использовать возможности UI Automation (через pywinauto
) в качестве основного инструмента, дополняя их методами PyAutoGUI
там, где UIA сталкивается с ограничениями (например, при работе с гибридным интерфейсом или элементами, рисуемыми на canvas). Такой гибридный подход сочетает сильные стороны обоих методов и обеспечивает более надёжную и стабильную автоматизацию.
Ссылки