python

Автоматизируем десктопный GUI на Python + pywinauto: как подружиться c MS UI Automation

  • пятница, 14 июля 2017 г. в 03:12:16
https://habrahabr.ru/post/323962/
  • Тестирование IT-систем
  • Разработка под Windows
  • Программирование
  • Python
  • Open source


Python библиотека pywinauto — это open source проект по автоматизации десктопных GUI приложений на Windows. За последние два года в ней появились новые крупные фичи:


  • Поддержка технологии MS UI Automation. Интерфейс прежний, и теперь поддерживаются: WinForms, WPF, Qt5, Windows Store (UWP) и так далее — почти все, что есть на Windows.
  • Система бэкендов/плагинов (сейчас их двое под капотом: дефолтный "win32" и новый "uia"). Дальше плавно двигаемся в сторону кросс-платформенности.
  • Win32 хуки для мыши и клавиатуры (hot keys в духе pyHook).

Также сделаем небольшой обзор того, что есть в open source для десктопной автоматизации (без претензий на серьезное сравнение).


Эта статья — частично расшифровка доклада с конференции SQA Days 20 в Минске (видеозапись и слайды), частично русская версия Getting Started Guide для pywinauto.




Начнём с краткого обзора опен сорса в этой области. Для десктопных GUI приложений всё несколько сложнее, чем для веба, у которого есть Selenium. Вот основные подходы:


Координатный метод


Хардкодим точки кликов, надеемся на удачные попадания.
[+] Кросс-платформенный, легко реализуемый.
[+] Легко сделать "record-replay" запись тестов.
[-] Самый нестабильный к изменению разрешения экрана, темы, шрифтов, размеров окон и т.п.
[-] Нужны огромные усилия на поддержку, часто проще перегенерить тесты с нуля или тестировать вручную.
[-] Автоматизирует только действия, для верификации и извлечения данных есть другие методы.


Инструменты (кросс-платформенные): autopy, PyAutoGUI, PyUserInput и многие другие. Как правило, более сложные инструменты включают в себя эту функциональность (не всегда кросс-платформенно).


Стоит сказать, что координатный метод может дополнять остальные подходы. Например, для кастомной графики можно кликать по относительным координатам (от левого верхнего угла окна/элемента, а не всего экрана) — обычно это достаточно надежно, особенно если учитывать длину/ширину всего элемента (тогда и разное разрешение экрана не помешает).


Другой вариант: выделять для тестов только одну машину со стабильными настройками (не кросс-платформенно, но в каких-то случаях годится).


Распознавание эталонных изображений


[+] Кросс-платформенный
[+-] Относительно надежный (лучше, чем координатный метод), но всё же требует хитростей.
[-+] Относительно медленный, т.к. требует ресурсов CPU для алгоритмов распознавания.
[-] О распознавании текста (OCR), как правило, речи не идёт => нельзя достать текстовые данные. Насколько мне известно, существующие OCR решения не слишком надежны для этого типа задач, и широкого применения не имеют (welcome в комменты, если это уже не так).


Инструменты: Sikuli, Lackey (Sikuli-совместимый, на чистом Python), PyAutoGUI.


Accessibility технологии


[+] Самый надежный метод, т.к. позволяет искать по тексту, независимо от того, как он отрисован системой или фреймворком.
[+] Позволяет извлекать текстовые данные => проще верифицировать результаты тестов.
[+] Как правило, самый быстрый, т.к. почти не расходует ресурсы CPU.
[-] Тяжело сделать кросс-платформенный инструмент: абсолютно все open-source библиотеки поддерживают одну-две accessibility технологии. Windows/Linux/MacOS целиком не поддерживает никто, кроме платных типа TestComplete, UFT или Squish.
[-] Не всегда такая технология в принципе доступна. Например, тестирование загрузочного экрана внутри VirtualBox'а — тут без распознавания изображений не обойтись. Но во многих классических случаях все-таки accessibility подход применим. О нем дальше и пойдет речь.


Инструменты: TestStack.White на C#, Winium.Desktop на C# (Selenium совместимый), MS WinAppDriver на C# (Appium совместимый), pywinauto, pyatom (совместим с LDTP), Python-UIAutomation-for-Windows, RAutomation на Ruby, LDTP (Linux Desktop Testing Project) и его Windows версия Cobra.


LDTP — пожалуй, единственный кросс-платформенный open-source инструмент (точнее семейство библиотек) на основе accessibility технологий. Однако он не слишком популярен. Сам не пользовался им, но по отзывам интерфейс у него не самый удобный. Если есть позитивные отзывы, прошу поделиться в комментах.


Тестовый backdoor (a.k.a. внутренний велосипед)


Для кросс-платформенных приложений сами разработчики часто делают внутренний механизм для обеспечения testability. Например, создают служебный TCP сервер в приложении, тесты к нему подключаются и посылают текстовые команды: на что нажать, откуда взять данные и т.п. Надежно, но не универсально.


Основные десктопные accessibility технологии


Старый добрый Win32 API


Большинство Windows приложений, написанных до выхода WPF и затем Windows Store, построены так или иначе на Win32 API. А именно, MFC, WTL, C++ Builder, Delphi, VB6 — все эти инструменты используют Win32 API. Даже Windows Forms — в значительной степени Win32 API совместимые.


Инструменты: AutoIt (похож на VB) и Python обертка pyautoit, AutoHotkey (собственный язык, есть IDispatch COM интерфейс), pywinauto (Python), RAutomation (Ruby), win32-autogui (Ruby).


Microsoft UI Automation


Главный плюс: технология MS UI Automation поддерживает подавляющее большинство GUI приложений на Windows за редкими исключениями. Проблема: она не сильно легче в изучении, чем Win32 API. Иначе никто бы не делал оберток над ней.


Фактически это набор custom COM интерфейсов (в основном, UIAutomationCore.dll), а также имеет .NET оболочку в виде namespace System.Windows.Automation. Она, кстати, имеет привнесенный баг, из-за которого некоторые UI элементы могут быть пропущены. Поэтому лучше использовать UIAutomationCore.dll напрямую (если слышали про UiaComWrapper на C#, то это оно).


Разновидности COM интерфейсов:


(1) Базовый IUknown — "the root of all evil". Самый низкоуровневый, ни разу не user-friendly.
(2) IDispatch и производные (например, Excel.Application), которые можно использовать в Python с помощью пакета win32com.client (входит в pyWin32). Самый удобный и красивый вариант.
(3) Custom интерфейсы, с которыми умеет работать сторонний Python пакет comtypes.


Инструменты: TestStack.White на C#, pywinauto 0.6.0+, Winium.Desktop на C#, Python-UIAutomation-for-Windows (у них исходный код сишных оберток над UIAutomationCore.dll не раскрыт), RAutomation на Ruby.


AT-SPI


Несмотря на то, что почти все оси семейства Linux построены на X Window System (в Fedora 25 "иксы" поменяли на Wayland), "иксы" позволяют оперировать только окнами верхнего уровня и мышью/клавиатурой. Для детального разбора по кнопкам, лист боксам и так далее — существует технология AT-SPI. У самых популярных оконных менеджеров есть так называемый AT-SPI registry демон, который и обеспечивает для приложений автоматизируемый GUI (как минимум поддерживаются Qt и GTK).


Инструменты: pyatspi2.


pyatspi2, на мой взгляд, содержит слишком много зависимостей типа того же PyGObject. Сама технология доступна в виде обычной динамической библиотеки libatspi.so. К ней имеется Reference Manual. Для библиотеки pywinauto планируем реализовать поддержку AT-SPI имеено так: через загрузку libatspi.so и модуль ctypes. Есть небольшая проблема только в использовании нужной версии, ведь для GTK+ и Qt приложений они немного разные. Вероятный выпуск pywinauto 0.7.0 с полноценной поддержкой Linux можно ожидать в первой половине 2018-го.


Apple Accessibility API


На MacOS есть собственный язык автоматизации AppleScript. Для реализации чего-то подобного на Python, разумеется, нужно использовать функции из ObjectiveC. Начиная, кажется, еще с MacOS 10.6 в предустановленный питон включается пакет pyobjc. Это также облегчит список зависимостей для будущей поддержки в pywinauto.


Инструменты: Кроме языка Apple Script, стоит обратить внимание на ATOMac, он же pyatom. Он совместим по интерфейсу с LDTP, но также является самостоятельной библиотекой. На нем есть пример автоматизации iTunes на macOs, написанный моим студентом. Есть известная проблема: не работают гибкие тайминги (методы waitFor*). Но, в целом, неплохая вещь.




Как начать работать с pywinauto


Первым делом стоит вооружиться инспектором GUI объектов (то, что называют Spy tool). Он поможет изучить приложение изнутри: как устроена иерархия элементов, какие свойства доступны. Самые известные инспекторы объектов:


  • Spy++ — входит в поставку Visual Studio, включая Express или Community Edition. Использует Win32 API. Также известен его клон AutoIt Window Info.
  • Inspect.exe — входит в Windows SDK. Если он у вас установлен, то на 64-битной Windows можно найти его в папке C:\Program Files (x86)\Windows Kits\<winver>\bin\x64. В самом инспекторе нужно выбрать режим UI Automation вместо MS AA (Active Accessibility, предок UI Automation).

Просветив приложение насквозь, выбираем бэкенд, который будем использовать. Достаточно указать имя бэкенда при создании объекта Application.


  • backend="win32" — пока используется по умолчанию, хорошо работает с MFC, WTL, VB6 и другими legacy приложениями.
  • backend="uia" — новый бэкенд для MS UI Automation: идеально работает с WPF и WinForms; также хорош для Delphi и Windows Store приложений; работает с Qt5 и некоторыми Java приложениями. И вообще, если Inspect.exe видит элементы и их свойства, значит этот бэкенд подходит. В принципе, большинство браузеров тоже поддерживает UI Automation (Mozilla по умолчанию, а Хрому при запуске нужно скормить ключ командной строки --force-renderer-accessibility, чтобы увидеть элементы на страницах в Inspect.exe). Конечно, конкуренция с Selenium в этой области навряд ли возможна. Просто еще один способ работать с браузером (может пригодиться для кросс-продуктового сценария).

Входные точки для автоматизации


Приложение достаточно изучено. Пора создать объект Application и запустить его или присоединиться к уже запущенному. Это не просто клон стандартного класса subprocess.Popen, а именно вводный объект, который ограничивает все ваши действия границами процесса. Это очень полезно, если запущено несколько экземпляров приложения, а остальные трогать не хочется.


from pywinauto.application import Application
app = Application(backend="uia").start('notepad.exe')

# Опишем окно, которое хотим найти в процессе Notepad.exe
dlg_spec = app.UntitledNotepad
# ждем пока окно реально появится
actionable_dlg = dlg_spec.wait('visible')

Если хочется управлять сразу несколькими приложениями, вам поможет класс Desktop. Например, в калькуляторе на Win10 иерархия элементов размазана аж по нескольким процессам (не только calc.exe). Так что без объекта Desktop не обойтись.


from subprocess import Popen
from pywinauto import Desktop

Popen('calc.exe', shell=True)
dlg = Desktop(backend="uia").Calculator
dlg.wait('visible')

Корневой объект (Application или Desktop) — это единственное место, где нужно указывать бэкенд. Все остальное прозрачно ложится в концепцию "спецификация->враппер", о которой дальше.


Спецификации окон/элементов


Это основная концепция, на которой строится интерфейс pywinauto. Вы можете описать окно/элемент приближенно или более детально, даже если оно еще не существует или уже закрыто. Спецификация окна (объект WindowSpecification) хранит в себе критерии, по которым нужно искать реальное окно или элемент.


Пример детальной спецификации окна:


>>> dlg_spec = app.window(title='Untitled - Notepad')

>>> dlg_spec
<pywinauto.application.WindowSpecification object at 0x0568B790>

>>> dlg_spec.wrapper_object()
<pywinauto.controls.win32_controls.DialogWrapper object at 0x05639B70>

Сам поиск окна происходит по вызову метода .wrapper_object(). Он возвращает некий "враппер" для реального окна/элемента или кидает ElementNotFoundError (иногда ElementAmbiguousError, если найдено несколько элементов, то есть требуется уточнить критерий поиска). Этот "враппер" уже умеет делать какие-то действия с элементом или получать данные из него.


Python может скрывать вызов .wrapper_object(), так что финальный код становится короче. Рекомендуем использовать его только для отладки. Следующие две строки делают абсолютно одно и то же:


dlg_spec.wrapper_object().minimize() # debugging
dlg_spec.minimize() # production

Есть множество критериев поиска для спецификации окна. Вот лишь несколько примеров:


# могут иметь несколько уровней
app.window(title_re='.* - Notepad$').window(class_name='Edit')

# можно комбинировать критерии (как AND) и не ограничиваться одним процессом приложения
dlg = Desktop(backend="uia").Calculator
dlg.window(auto_id='num8Button', control_type='Button')

Список всех возможных критериев есть в доках функции pywinauto.findwindows.find_elements(...).


Магия доступа по атрибуту и по ключу


Python упрощает создание спецификаций окна и распознает атрибуты объекта динамически (внутри переопределен метод __getattribute__). Разумеется, на имя атрибута накладываются такие же ограничения, как и на имя любой переменной (нельзя вставлять пробелы, запятые и прочие спецсимволы). К счастью, pywinauto использует так называемый "best match" алгоритм поиска, который устойчив к опечаткам и небольшим вариациям.


app.UntitledNotepad
# то же самое, что
app.window(best_match='UntitledNotepad')

Если все-таки нужны Unicode строки (например, для русского языка), пробелы и т.п., можно делать доступ по ключу (как будто это обычный словарь):


app['Untitled - Notepad']
# то же самое, что
app.window(best_match='Untitled - Notepad')

Пять правил для магических имен


Как узнать эталонные магические имена? Те, которые присваиваются элементу перед поиском. Если вы указали имя, достаточно похожее на эталон, значит элемент будет найден.


  1. По заголовку (текст, имя): app.Properties.OK.click()
  2. По тексту и по типу элемента: app.Properties.OKButton.click()
  3. По типу и по номеру: app.Properties.Button3.click() (имена Button0 и Button1 привязаны к первому найденному элементу, Button2 — ко второму, и дальше уже по порядку — так исторически сложилось)
  4. По статическому тексту (слева или сверху) и по типу: app.OpenDialog.FileNameEdit.set_text("") (полезно для элементов с динамическим текстом)
  5. По типу и по тексту внутри: app.Properties.TabControlSharing.select("General")

Обычно два-три правила применяются одновременно, редко больше. Чтобы проверить, какие конкретно имена доступны для каждого элемента, можно использовать метод print_control_identifiers(). Он может печатать дерево элементов как на экран, так и в файл. Для каждого элемента печатаются его эталонные магические имена. Также можно скопипастить оттуда более детальные спецификации дочерних элементов. Результат в скрипте будет выглядеть так:


app.Properties.child_window(title="Contains:", auto_id="13087", control_type="Edit")

Само дерево элементов - обычно довольно большая портянка.
>>> app.Properties.print_control_identifiers()

Control Identifiers:

Dialog - 'Windows NT Properties'    (L688, T518, R1065, B1006)
[u'Windows NT PropertiesDialog', u'Dialog', u'Windows NT Properties']
child_window(title="Windows NT Properties", control_type="Window")
   |
   | Image - ''    (L717, T589, R749, B622)
   | [u'', u'0', u'Image1', u'Image0', 'Image', u'1']
   | child_window(auto_id="13057", control_type="Image")
   |
   | Image - ''    (L717, T630, R1035, B632)
   | ['Image2', u'2']
   | child_window(auto_id="13095", control_type="Image")
   |
   | Edit - 'Folder name:'    (L790, T596, R1036, B619)
   | [u'3', 'Edit', u'Edit1', u'Edit0']
   | child_window(title="Folder name:", auto_id="13156", control_type="Edit")
   |
   | Static - 'Type:'    (L717, T643, R780, B658)
   | [u'Type:Static', u'Static', u'Static1', u'Static0', u'Type:']
   | child_window(title="Type:", auto_id="13080", control_type="Text")
   |
   | Edit - 'Type:'    (L790, T643, R1036, B666)
   | [u'4', 'Edit2', u'Type:Edit']
   | child_window(title="Type:", auto_id="13059", control_type="Edit")
   |
   | Static - 'Location:'    (L717, T669, R780, B684)
   | [u'Location:Static', u'Location:', u'Static2']
   | child_window(title="Location:", auto_id="13089", control_type="Text")
   |
   | Edit - 'Location:'    (L790, T669, R1036, B692)
   | ['Edit3', u'Location:Edit', u'5']
   | child_window(title="Location:", auto_id="13065", control_type="Edit")
   |
   | Static - 'Size:'    (L717, T695, R780, B710)
   | [u'Size:Static', u'Size:', u'Static3']
   | child_window(title="Size:", auto_id="13081", control_type="Text")
   |
   | Edit - 'Size:'    (L790, T695, R1036, B718)
   | ['Edit4', u'6', u'Size:Edit']
   | child_window(title="Size:", auto_id="13064", control_type="Edit")
   |
   | Static - 'Size on disk:'    (L717, T721, R780, B736)
   | [u'Size on disk:', u'Size on disk:Static', u'Static4']
   | child_window(title="Size on disk:", auto_id="13107", control_type="Text")
   |
   | Edit - 'Size on disk:'    (L790, T721, R1036, B744)
   | ['Edit5', u'7', u'Size on disk:Edit']
   | child_window(title="Size on disk:", auto_id="13106", control_type="Edit")
   |
   | Static - 'Contains:'    (L717, T747, R780, B762)
   | [u'Contains:1', u'Contains:0', u'Contains:Static', u'Static5', u'Contains:']
   | child_window(title="Contains:", auto_id="13088", control_type="Text")
   |
   | Edit - 'Contains:'    (L790, T747, R1036, B770)
   | [u'8', 'Edit6', u'Contains:Edit']
   | child_window(title="Contains:", auto_id="13087", control_type="Edit")
   |
   | Image - 'Contains:'    (L717, T773, R1035, B775)
   | [u'Contains:Image', 'Image3', u'Contains:2']
   | child_window(title="Contains:", auto_id="13096", control_type="Image")
   |
   | Static - 'Created:'    (L717, T786, R780, B801)
   | [u'Created:', u'Created:Static', u'Static6', u'Created:1', u'Created:0']
   | child_window(title="Created:", auto_id="13092", control_type="Text")
   |
   | Edit - 'Created:'    (L790, T786, R1036, B809)
   | [u'Created:Edit', 'Edit7', u'9']
   | child_window(title="Created:", auto_id="13072", control_type="Edit")
   |
   | Image - 'Created:'    (L717, T812, R1035, B814)
   | [u'Created:Image', 'Image4', u'Created:2']
   | child_window(title="Created:", auto_id="13097", control_type="Image")
   |
   | Static - 'Attributes:'    (L717, T825, R780, B840)
   | [u'Attributes:Static', u'Static7', u'Attributes:']
   | child_window(title="Attributes:", auto_id="13091", control_type="Text")
   |
   | CheckBox - 'Read-only (Only applies to files in folder)'    (L790, T825, R1035, B841)
   | [u'CheckBox0', u'CheckBox1', 'CheckBox', u'Read-only (Only applies to files in folder)CheckBox', u'Read-only (Only applies to files in folder)']
   | child_window(title="Read-only (Only applies to files in folder)", auto_id="13075", control_type="CheckBox")
   |
   | CheckBox - 'Hidden'    (L790, T848, R865, B864)
   | ['CheckBox2', u'HiddenCheckBox', u'Hidden']
   | child_window(title="Hidden", auto_id="13076", control_type="CheckBox")
   |
   | Button - 'Advanced...'    (L930, T845, R1035, B868)
   | [u'Advanced...', u'Advanced...Button', 'Button', u'Button1', u'Button0']
   | child_window(title="Advanced...", auto_id="13154", control_type="Button")
   |
   | Button - 'OK'    (L814, T968, R889, B991)
   | ['Button2', u'OK', u'OKButton']
   | child_window(title="OK", auto_id="1", control_type="Button")
   |
   | Button - 'Cancel'    (L895, T968, R970, B991)
   | ['Button3', u'CancelButton', u'Cancel']
   | child_window(title="Cancel", auto_id="2", control_type="Button")
   |
   | Button - 'Apply'    (L976, T968, R1051, B991)
   | ['Button4', u'ApplyButton', u'Apply']
   | child_window(title="Apply", auto_id="12321", control_type="Button")
   |
   | TabControl - ''    (L702, T556, R1051, B962)
   | [u'10', u'TabControlSharing', u'TabControlPrevious Versions', u'TabControlSecurity', u'TabControl', u'TabControlCustomize']
   | child_window(auto_id="12320", control_type="Tab")
   |    |
   |    | TabItem - 'General'    (L704, T558, R753, B576)
   |    | [u'GeneralTabItem', 'TabItem', u'General', u'TabItem0', u'TabItem1']
   |    | child_window(title="General", control_type="TabItem")
   |    |
   |    | TabItem - 'Sharing'    (L753, T558, R801, B576)
   |    | [u'Sharing', u'SharingTabItem', 'TabItem2']
   |    | child_window(title="Sharing", control_type="TabItem")
   |    |
   |    | TabItem - 'Security'    (L801, T558, R851, B576)
   |    | [u'Security', 'TabItem3', u'SecurityTabItem']
   |    | child_window(title="Security", control_type="TabItem")
   |    |
   |    | TabItem - 'Previous Versions'    (L851, T558, R947, B576)
   |    | [u'Previous VersionsTabItem', u'Previous Versions', 'TabItem4']
   |    | child_window(title="Previous Versions", control_type="TabItem")
   |    |
   |    | TabItem - 'Customize'    (L947, T558, R1007, B576)
   |    | [u'CustomizeTabItem', 'TabItem5', u'Customize']
   |    | child_window(title="Customize", control_type="TabItem")
   |
   | TitleBar - 'None'    (L712, T521, R1057, B549)
   | ['TitleBar', u'11']
   |    |
   |    | Menu - 'System'    (L696, T526, R718, B548)
   |    | [u'System0', u'System', u'System1', u'Menu', u'SystemMenu']
   |    | child_window(title="System", auto_id="MenuBar", control_type="MenuBar")
   |    |    |
   |    |    | MenuItem - 'System'    (L696, T526, R718, B548)
   |    |    | [u'System2', u'MenuItem', u'SystemMenuItem']
   |    |    | child_window(title="System", control_type="MenuItem")
   |    |
   |    | Button - 'Close'    (L1024, T519, R1058, B549)
   |    | [u'CloseButton', u'Close', 'Button5']
   |    | child_window(title="Close", control_type="Button")

В некоторых случаях печать всего дерева может тормозить (например, в iTunes на одной вкладке аж три тысячи элементов!), но можно использовать параметр depth (глубина): depth=1 — сам элемент, depth=2 — только непосредственные дети, и так далее. Его же можно указывать в спецификациях при создании child_window.


Примеры


Мы постоянно пополняем список примеров в репозитории. Из свежих стоит отметить автоматизацию сетевого анализатора WireShark (это хороший пример Qt5 приложения; хотя эту задачу можно решать и без GUI, ведь есть scapy.Sniffer из питоновского пакета scapy). Также есть пример автоматизации MS Paint с его Ribbon тулбаром.


Еще один отличный пример, написанный моим студентом: перетаскивание файла из explorer.exe на Chrome страницу для Google Drive (он перекочует в главный репозиторий чуть позже).


И, конечно, пример подписки на события клавиатуры (hot keys) и мыши:
hook_and_listen.py.


Благодарности


Отдельное спасибо — тем, кто постоянно помогает развивать проект. Для меня и Валентина это постоянное хобби. Двое моих студентов из ННГУ недавно защитили дипломы бакалавра по этой теме. Александр внес большой вклад в поддержку MS UI Automation и недавно начал делать автоматический генератор кода по принципу "запись-воспроизведение" на основе текстовых свойств (это самая сложная фича), пока только для "uia" бэкенда. Иван разрабатывает новый бэкенд под Linux на основе AT-SPI (модули mouse и keyboard на основе python-xlib — уже в релизах 0.6.x).


Поскольку я довольно давно читаю спецкурс по автоматизации на Python, часть студентов-магистров выполняют домашние задания, реализуя небольшие фичи или примеры автоматизации. Некоторые ключевые вещи на стадии исследований тоже когда-то раскопали именно студенты. Хотя иногда за качеством кода приходится строго следить. В этом сильно помогают статические анализаторы (QuantifiedCode, Codacy и Landscape) и автоматические тесты в облаке (сервис AppVeyor) с покрытием кода в районе 95%.


Также спасибо всем, кто оставляет отзывы, заводит баги и присылает пулл реквесты!


Дополнительные ресурсы


За вопросами мы следим по тегу на StackOverflow (недавно появился тег в русской версии SO) и по ключевому слову на Тостере. Есть русскоязычный чат в Gitter'е.


Каждый месяц обновляем рейтинг open-source библиотек для GUI тестирования. По количеству звезд на гитхабе быстрее растут только Autohotkey (у них очень большое сообщество и длинная история) и PyAutoGUI (во многом благодаря популярности книг ее автора Al Sweigart: "Automate the Boring Stuff with Python" и других).