SWAPYSWAPY – графическая утилита для автоматизации UI для pywinauto (Python).

В версии 0.4.7 полностью переработан генератор кода. Основные возможности, а также примеры как быстро и просто создать скрипты автоматического тестирования UI, смотрите под катом.

Описание


SWAPY – графическая утилита для просмотра иерархии окон и генерации кода автоматизации UI для библиотеки pywinauto.

Interface

Само название это акроним, отражающий основную идею приложения — Simple Windows Automation on PYthon. Утилита представляет собой полноценный exe файл, собранный с помощью PyInstaller. SWAPY не требует никаких дополнительных установок для автоматизации и генерации кода. Конечно, для дальнейшего использования кода вам понадобится установить как минимум Python и pywinauto. Но для проверки возможностей и, самое главное, подойдет ли такая связка для автоматизации Вашего приложения, SWAPY вполне самодостаточна.

Утилита содержит три основных компонента, это:

  • дерево объектов
  • таблица свойств выбранного объекта
  • поле с кодом

Чтобы создать скрипт, необходимо найти элемент в дереве всех контролов и затем вызвать действие, например, Click. При этом выполнится как само действие над объектом, так и обновится поле с кодом.

Раньше генератору кода уделялось мало внимания. Чаще использовались функции поиска элемента и просмотра его параметров. Все фиксы и фичи для генератора кода добавлялись по остаточному принципу. В итоге, чтобы получить рабочий код, нужны были определенные усилия со стороны пользователя — необходимо было последовательно проинициализировать всех предков.

Новый генератор кода, в основном, лишен прежних недостатков.

История развития


В начале 2011 года, будучи на должности «Automation QA Engineer», открыл для себя библиотеку для автоматизации UI – pywinauto. Об истории развития самой библиотеки можно кое-что узнать в статье «Старый новый pywinauto». На тот момент она практически не поддерживалась. Тем не менее Pywinauto победила всех своих конкурентов и была выбрана для тестирования ряда продуктов со средней сложностью графического интерфейса.

Отмечу основные преимущества, благодаря которым выбор пал именно на этот вариант:

  1. Цена инструмента. Pywinauto бесплатна, распространяется под лицензией GNU LGPL v.2.1
  2. Это библиотека Python. Со всеми его возможностями, библиотеками, и т.д.
  3. Простая подготовка окружения. Подготовить виртуальную машину для тестирования установив Python + pywinauto сильно проще установки, например, такого монстра как TestComplete. Это весьма актуально в контексте использования Continuous Integration.

Вскоре обнаружился один недостаток — тратится много времени на поиск необходимого элемента и анализ его свойств. Очень не хватало графической утилиты для просмотра дерева элементов и их параметров. Библиотеке для автоматизации графических интерфейсов было бы не плохо иметь графический интерфейс.

Было решено исправить эту несправедливость.

logo-headВ апреле 2011 года я начал работу над утилитой, к концу года версия стремительно выросла до 0.3.0, а утилита уже имела все ключевые составляющие и… множество проблем…

В течение следующего года потихоньку исправлялись ошибки и что-то незначительное добавлялось. А потом я сменил работу и интерес поддерживать утилиту, которую сам не использую, да еще и в одиночку, пропал.

Второе дыхание SWAPY получил в сентябре 2015, когда ребята из pywinauto позвали к себе.

С тех пор стал снова активно развивать утилиту. Ключевым улучшением можно назвать новый генератор кода.

Я пересмотрел свое отношение к функции генерации кода как к одной из основных функций. Именно через генератор кода можно познакомить разработчика с дополнительными возможностями библиотеки, а также спасти даже опытного разработчика от рутины.

Новые возможности


  • Генератор кода теперь работает нормально. Имеется ввиду что не нужно кликать по всем предкам в дереве объекта, чтобы получился рабочий код. Сейчас достаточно отыскать необходимый элемент и выполнить над ним действие, код будет автоматически построен вплоть до импорта. Один клик в новой версии:

    from pywinauto.application import Application
    
    app = Application().Start(cmd_line=u'"C:\\Program Files (x86)\\Notepad++\\notepad++.exe" ')
    notepad = app[u'Notepad++']
    notepad.Wait('ready')
    systabcontrol = notepad.Tab
    systabcontrol.Select(u'new 1')
    
    app.Kill_()
    

    В старой версии аналогичное действие приводит к такому результату:

    import pywinauto
    
    pwa_app = pywinauto.application.Application()
    ctrl.Select(0)
    

    Очевидно что такой код работать не будет.
  • Понятные имена переменных. Согласитесь, systabcontrol намного понятнее какого то ctrl. Имена формируются на базе имени класса контрола, либо из самого короткого имени для доступа (из pywinauto). Только если оба эти случая были безуспешными, то будет использовано безликое — control.
  • Контроль над одинаковыми именами переменных. Если необходимо работать с разными контролами, имеющими одинаковые имена, SWAPY следит что бы они оставались уникальными.

    button = calcframe.Button19
    button.Click()
    button2 = calcframe.Button20
    button2.Click()
    

    Это актуально для следующего пункта.
  • Повторное использование. Как правило, действие состоит из двух строк. В первой происходит инициализация доступа к контролу, во второй — непосредственно действие. Так вот, если понадобилось в какой то момент повторить действие над контролом, который уже был инициализирован, то добавляется просто код действия.

    button = calcframe.Button19
    button.Click()
    button2 = calcframe.Button20
    button2.Click()
    button.Click()  # Повторный Click по Button19
    
  • clear_last_commandОтмена последней команды. Частенько возникает необходимость удалить последнюю команду, например после неудачных экспериментов. Теперь есть возможность сделать это через контекстное меню редактора. При этом имя исчезнувшей переменной освободится и будет использовано в следующий раз. Отменять можно любое количество шагов. Нужно понимать, что отмена последней команды лишь очистит код, действие в приложении не будет отменено.

    Также есть возможность очистить сразу весь код, а еще можно сохранить весь код в файл.
  • imageИзменение кода «на лету». Пока эта функциональность используется в окнах верхнего уровня для переключения между app = Application().Start(cmd_line=... и app = Application().Connect(title=.... В большинстве случаев будет достаточно Start, но если не нужно запускать приложение, то следует выбрать Application.Connect в контекстном меню дерева объектов, кликнув на имени окна. Код в редакторе обновится, исчезнут привязанные к методу Application().Start команды — calcframe.Wait('ready') в начале и app.Kill_() в конце.

    Пример кода со стартом приложения.

    from pywinauto.application import Application
    
    app = Application().Start(cmd_line=u'"C:\\Program Files (x86)\\Notepad++\\notepad++.exe" ')
    notepad = app[u'Notepad++']
    notepad.Wait('ready')
    
    app.Kill_()
    

    Подключается к уже запущенному приложению.

    from pywinauto.application import Application
    
    app = Application().Connect(title=u'new 1 - Notepad++', class_name='Notepad++')
    notepad = app[u'Notepad++']
    


Пример использования


Теперь давайте создадим несколько скриптов для автоматизации тестирования. Я постарался выбрать достаточно жизненные примеры и одновременно продемонстрировать новые фичи кодогенератора.

Текст лицензии


В этом тесте мы проверим что текст лицензии отображается на диалоге About. Одновременно убедимся что SWAPY понимает, что новое окно принадлежит старому приложению и не будет создавать лишних вызовов app = Application().Start(...).

Проверка текста лицензии
  1. Запускаем вручную Notepad++.
  2. Находим в дереве элементов SWAPY нужный элемент меню и кликаем на него.
    click_menu
  3. Чтобы обновить дерево элементов для отображения вновь открытого окна, нужно поставить выделение на root элемент в дереве. При этом все дочерние элементы обновятся.
  4. Находим About диалог, он у меня называется Window#657198, это SWAPY сама сформировала название из handle окна, так как обычным способом(window.Texts()) имя не определилось.
  5. В иерархии About диалога находим текст лицензии и кликаем на него.

    About

    Добавились только следующие строчки:

    window = app.Dialog
    edit = window.Edit2
    edit.Click()  # Изменим на получение текста
    

    Т.е. SWAPY использовала существующую переменную app. С автогенерацией кода для этого теста мы закончили. Обратите внимание что Notepad++ будет запущен и закрыт после теста, за это отвечает последняя строка app.Kill_().

Финальный код теста может выглядеть следующим образом:

from pywinauto.application import Application
expected_text = “...”

app = Application().Start(cmd_line=u'"C:\\Program Files (x86)\\Notepad++\\notepad++.exe" ')
notepad = app[u'Notepad++']
notepad.Wait('ready')
menu_item = notepad.MenuItem(u'&?->\u041e \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u0435...\tF1')
menu_item.Click()
window = app.Dialog
edit = window.Edit2
actual_text = edit.Texts()

app.Kill_()

assertEqual(expected_text, actual_text)

Как видите, минимум собственного кода.

Порядок вкладок


Давайте проверим перемещение вкладок. Нарочно допустим ошибку при генерации кода и посмотрим как SWAPY позволит её убрать.

Проверка изменения порядка вкладок
  1. Запускаем вручную Notepad++.
  2. Откроем две дополнительные вкладки. Находим в дереве элементов необходимый ToolBar и выполняем действие Click на кнопке с индексом 0. Вследствие чего появится код и откроется одна новая вкладка.

    add_tab

    Нам нужна еще одна вкладка, повторим действие еще раз. Поскольку текст кнопок недоступен, используется адресация по индексу. Мы не заметили и нечаянно кликнули на кнопку с индексом 1.

    Добавился код:

    fix_code
    toolbar_button2 = toolbarwindow.Button(1)
    toolbar_button2.Click()
    

    Нужно исправляться. Чтобы не повторять все сначала, SWAPY позволяет отменить последнюю команду (можно последовательно отменить хоть весь код).

    Clear last command отменит последнюю команду (выделенный фрагмент) — как раз то, что нам и нужно. Чтобы полностью очистить код, есть команда Clear the code. Полная очистка спрятана за диалогом с подтверждением, во избежание несчастных случаев на производстве.

    Теперь мы сделаем все правильно и кликнем по кнопке с индексом 0.

    Добавится код:

    toolbar_button.Click()
    

    SWAPY помнит что уже есть toolbar_button = toolbarwindow.Button(0) и для повторного клика инициализировать его уже не нужно.
  3. Для drug-n-drop воспользуемся методом toolbarwindow.DragMouseInput. Детали использования можно подсмотреть в документации.

    Координаты вкладок можно определить с помощью systabcontrol.GetTabRect(0).mid_point()

Тест может выглядеть так:

# automatically generated by SWAPY
from pywinauto.application import Application

app = Application().Start(cmd_line=u'"C:\\Program Files (x86)\\'
                                   u'Notepad++\\notepad++.exe" ')
notepad = app[u'Notepad++']
notepad.Wait('ready')
systabcontrol = notepad.Tab
assertEqual([u'Tab', u'new 1'], systabcontrol.Texts())

toolbarwindow = notepad[u'3']
toolbar_button = toolbarwindow.Button(0)
toolbar_button.Click()
toolbar_button.Click()

assertEqual([u'Tab', u'new 1', u'new 2', u'new 3'], systabcontrol.Texts())

systabcontrol.DragMouseInput(
    press_coords=systabcontrol.GetTabRect(0).mid_point(),
    release_coords=systabcontrol.GetTabRect(2).mid_point())

assertEqual([u'Tab', u'new 2', u'new 3', u'new 1'], systabcontrol.Texts())

app.Kill_()

Тут пришлось немного почитать документацию и немного поработать с генерированным кодом.

Вставка и сохранение текста


Тест требует проверить копирование и вставку текста с последующим сохранением. Усложним задачу — Notepad++ уже запущен и свернут (Minimize), а стандартный notepad (из которого будет производится копирование) только предстоит запустить.

Работа с несколькими окнами
  1. Подготовим тестовые приложения. Запустим и свернем Notepad++, запустим обычный notepad с тестовым файлом = «notepad check.txt».
  2. В дереве объектов найдем блокнот и кликнем по содержимому редактора.

    notepad

    Обратите внимание, что notepad будет запущен с оригинальными аргументами.
  3. Теперь отыщем Notepad++ и его текстовое поле. Нужно не забыть его сначала развернуть (Restore).

    restore

    editor

    Все идет по плану, но тут внезапно мы вспомнили, что по условию задачи Notepad++ уже запущен, а наш код попытается его запустить.
    SWAPY по умолчанию генерирует связку app = Application().Start ... app.Kill_(). Но в нашем случае нам не нужно еще раз запускать Notepad++.

    Новый генератор кода позволяет изменять «подход» для генерации кода, причем это можно делать даже постфактум.
  4. Для изменения Application().Start на Application().Connect нужно вызвать контекстное меню для окна приложения Notepad++ и выбрать Application().Connect.

    Connect
  5. Копирование и вставку текста мы оформим позже, а сейчас предположим что текст есть и его нужно сохранить.

    save_as
  6. Открылось окно «Save as», необходимо обновить дерево элементов что бы его увидеть. Для этого нужно выделить root элемент дерева. После обновления дерева, кликнем на поле с именем сохраняемого файла (чтобы потом поменять) и на кнопку для сохранения.

    accept

Все основные действия есть, теперь осталось добавить отправку команд CTRL+C, CTRL+V и проверки чтобы получился настоящий тест.
Для отправки команд, воспользуемся встроенным методом TypeKeys.

Полный текст приведен ниже:

# automatically generated by SWAPY
from pywinauto.application import Application
import time
import os

SAVE_PATH = r"Notepad_default_path"

app = Application().Start(cmd_line=u'"C:\\Windows\\system32\\notepad.exe" check.txt')
notepad = app.Notepad
notepad.Wait('ready')
edit = notepad.Edit
edit.TypeKeys("^a^c")  # Copy all the text

app2 = Application().Connect(title=u'new 1 - Notepad++', class_name='Notepad++')
notepad2 = app2[u'Notepad++']
notepad2.Restore()
scintilla = notepad2[u'1']
scintilla.TypeKeys("^a^v")  # Paste the text

#Save a file
menu_item = notepad2.MenuItem(u'&\u0424\u0430\u0439\u043b->\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u043a\u0430\u043a...\tCtrl+Alt+S')
menu_item.Click()
window = app2.Dialog
edit2 = window.Edit
filename = "checked_at_%s" % time.time()  # Compose a filename
edit2.TypeKeys(filename)
button = window.Button
button.Click()

with open(os.path.join(SAVE_PATH, filename)) as f:
    assertEqual(“expected_text”, f.read())

app.Kill_()


А можно еще лучше?


Безусловно — Да!

Даже в описанных примерах мы вынуждены были делать Click() а потом уже вручную менять на получение текста — Texts(). Или же вручную добавляли TypeKeys. В будущих релизах еще предстоит упростить такие популярные действия, добавив дополнительные пункты в контекстное меню.

Пока нельзя управлять форматом доступа к элементам. Pywinauto позволяет получить доступ к элементам через атрибуты — window.Edit, а если это невозможно (недопустимое имя для переменной Python), то через __getitem__window[u'0'].

SWAPY находит самое короткое имя для доступа и пробует его применить в качестве атрибута. Если не получается, то через __getitem__. Идея пока самая простая — получить короткий код.

Но, например, в тесте «Порядок вкладок» есть такая строчка toolbarwindow = notepad[u'3']. Все работает, все ОК. Но, представьте, вы открыли этот тест через некоторое время, а там такой magic number. Вместо тройки могло бы быть Toolbar — самое понятное, а не самое короткое имя. В планах — дать юзеру возможность выбирать имя (“Имя! Имя, сестра!”).

Также пока нужно обновлять дерево объектов вручную. Автоматический refresh явно добавит удобства.

Полезные ссылки



P.S.


Хотел бы поблагодарить камрадов vasily-v-ryabov и airelil за активное участие в обсуждении фич для нового генератора кода.

Комментарии (2)


  1. EvilsInterrupt
    06.11.2015 15:25
    +1

    /offtop:

    Application().Start(cmd_line=u'«C:\\Program Files (x86)\\Notepad++\\notepad++.exe» ')

    Ранее Start() был static-методом. Почему решили переделать этот инстерфейс?


    1. moden
      06.11.2015 15:56

      На самом деле там было много Start* методов, пожалуй слишком много: start()/Start()/start_()/Start_()/__start(). Еще Марк пометил статические Start*/Connect* как deprecated. Фактически, мы просто закончили переезд и убрали лишнее. В статических методах все-равно был вызов методов инстанса, решили уйти от таких неявных вызовов.