[Из песочницы] Функциональное тестирование програм на Qt

Предисловие

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

Учитывая все вышесказанное, а также тот «незначительный» факт, что заказчик в ТЗ прописал необходимость автоматического тестирования указанных в том же ТЗ функциональных требований, при старте очередного проекта стал актуальным вопрос выбора инструмента для автоматизации тестирования GUI. Проект был на Qt, и требовалась кроссплатформенность (Windows, Linux).

Какой в итоге opensource инструмент появился, смотрите по катом.

image


Инструменты для тестирования GUI

Какие готовые решения для тестирования GUI были доступны на тот момент (несколько лет назад)?

Если обобщить, то было два класса возможных утилит:


  1. Программы, созданные изначально для автоматизации действий пользователя, а не для тестирования.
    Думаю, каждый может назвать для своей любимой ОС парочку. Например, для Linux/X11 — см. пост на хабре.

Ни одна из таких утилит нам не подошла, поскольку не удовлетворяла как минимум одному из сформулированных требований:


  1. Кроссплатформенность.
    Большинство из них не кроссплатформенны, т.е. работают либо только на Windows, или только на Linux.


  2. Излишняя привязка к деталям реализации.
    Даже если решить проблему с кроссплатформенностью (например, запустив программу на Linux машине, а X server для неё на Windows), то излишняя привязка к деталям реализации приводит к проблемам следующего характера.

Самые простые утилиты записывали и воспроизводили нажатия мышки в системе координат (СК) экрана (т.е. другое разрешение экрана — и тест падает), поумнее использовали СК окна (другой Qt стиль, другой DPI — и тест падает). Cамые лучшие умели распознавать «нативные» виджеты ОС, но, к сожалению, Qt очень мало использовал «нативные» элементы конкретной ОС (Qt маскирует вид своих виджетов под конкретную ОС, в данном случае Linux или Windows, но внутри это все тот же QWidget).

Итог: ни одна утилита из этого класса нам не подошла. Нужно было бы создавать по два варианта тестов для одного и того же сценария работы пользователя — для Linux и для Windows. Плюс, слишком сложно было бы поддерживать созданные с помощью них тесты (любое изменение в интерфейсе их ломает).


  1. Программы, созданные именно для тестирования. Учитывая опыт (1), из этого класса утилит мы рассматривали только утилиты, имеющие поддержку Qt. Такая была ровно одна — squish (описание на хабре) c соответствующим подходом к ценообразованию — «свяжитесь с нами, мы оценим вас и выставим цену». Так было несколько лет назад, возможно, сейчас что-то изменилось. Я им послал запрос, но ответа так и не дождался.

Результат закономерен — было принято решение сделать такой инструмент самостоятельно.


Qt Monkey


Альфа версия

Первоначально задача казалась довольно простой. Есть подсистема Qt с характерным названием QTest (я ее уже использовал в проекте для написания модульных тестов для собственных виджетов). С помощью неё довольно легко записать последовательность нажатий клавиш и кликов мышки (QTest::mouseClick, QTest::keyCick). Генерировать же код теста можно с помощью преобразования QEventQTest::something, предварительно попросив Qt с помощью qApp->installEventFilter сообщать обо всех событиях в тестируемом приложении. В результате, предварительный вариант был быстро готов.

Правда, загрузка тестов с помощью механизма плагинов и написание самих тестов на C++ почему-то не вызвала понимания у QA инженеров. К счастью, в Qt есть простой способ встраивания JavaScript в приложение — QtScript. Эта подсистема позволяет очень легко, практически парой строчек кода, обеспечить взаимодействие наследников QObject и JavaScript, транслируя вызовы в обе стороны:

QScriptEngine engine;
QScriptValue global = engine.globalObject();
QScriptValue val = engine.newQObject(qobject);
global.setProperty(QLatin1String("myobject"), val);

а в javascript:

myobject.slot1();
var v = myobject.property1;

Осталось только придумать, как идентифицировать виджеты, ведь создать объект (QObject), свойства (Q_PROPERTY) которого — указатели на все когда-либо созданные графические элементы программы, довольно сложно.

После некоторого периода раздумий остановились на такой схеме именования виджета:

«идентификатор родителя (если есть)» точка «идентификатор конкретного объекта»,
где «идентификатор родителя (если есть)» опять разбивается на пару
«идентификатор родителя родителя (если есть)» точка «идентификатор конкретного родителя». И так пока QObject::parent возвращает не nullptr.

Идентификатор конкретного объекта может быть либо именем объекта — простейший случай (в случае использования Qt Designer имя объекта будет присутствовать), если же объект безымянный, то идентифицируем его через имя класса и порядковый номер.

Пример:


MainWindow.centralwidget.tabWidget.qt_tabwidget_stackedwidget.tab.pushButton_ModalDialog


Бета версия

Казалось бы, все замечательно, тесты легко создаются, работают на обеих платформах без каких-либо изменений (т.к. не привязаны к попиксельному расположению элементов). Но оказалось, что все не так просто. В действие вступили модальные диалоги (тогда это были диалоги открытия файлов, но проблему вызвал бы и банальный QMessageBox с Yes/No).

Ошибка была в следующем:


  • QTest::что_то_там создавал нужный экземпляр наследника QEvent и c помощью QApplication::notify доставлял его нужному объекту;
  • QApplication::notify работает синхронно, т.е. пока событие не обработается, управление обратно он не вернет;
  • в случае с диалогом создается новый QEventLoop, и он начинает обрабатывать события, пока диалог не закроют.

Таким образом, управление из QTest::что-то_там не вернется, пока диалог не закроется. Но как скрипт его может закрыть, если управление к нему не вернется, пока диалог не закрыт?

Ситуацию усугубляло еще и то, что блокирующие поведение QTest::что-то_там — как раз то, что нам нужно, исключая, конечно же, вызов диалогов. Вставлять в тест разного рода проверки намного проще, если знаешь, что именно после строчки
Test.activateItem('MainWindow.centralwidget.tabWidget.qt_tabwidget_tabbar', 'Tab 5'); будет активирована вкладка 'Tab 5', а не через, скажем, пять строчек после нее.

Конечно, если у нас есть блокирующий вызов, которого мы не можем избежать, очевидным решением является создание еще одного потока, но, к сожалению, события, связанные с GUI, должны обрабатываться только в главном потоке. Поэтому первый вариант решения был такой (псевдокод):

//addition thread
qApp->postEvent(objectInGuiThread, customEventObject);
semaphore->tryAcquire(timeout);

//gui thread
void ClassInGuiThread::customEvent()
{
    QTest::somthing();
    semaphore->release();
}

Т.е. с помощью QEvent мы извещаем объект, находящийся в главном (GUI) потоке, о том, что нужн о что-то сделать, а т.к. objectInGuiThread находится в GUI потоке, то его метод ::customEvent будет вызыван в контексте GUI потока, ну и с помощью семафора осуществляется синхронизация потоков.

Собственно, подобным образом и работает механизм signal/slots при вызове QObject::connect с параметром Qt::QueuedConnection, в том случае, когда сигнал посылается из одного потока, а объект, которому принадлежит слот, находится в другом потоке.

Очевидный недостаток — нужно правильно подобрать таймаут в вызове semaphore->tryAcquire(timeout);. Если, например, нажатие на виджет вызовет долгую операцию, а мы отвалимся по таймауту, а после этого продолжим работу, скажем, с попытки нажатия на какой-либо виджет, появляющийся только после завершения долгой операции, то результат может оказаться неожиданным для автора теста.

Печаль вызывает также тот факт, что QCoreApplication::loopLevel() при переходе с Qt 3 на Qt 4 сделали устаревшим, и он доступен только при сборке Qt 4.x с опцией qt3support, а вернули обратно его аналог QThread::loopLevel() только в Qt 5.5. Т.е. сложно отличить случай «нажатие → долгая операция» от случая «нажатие → модальный диалог».

Неочевидный недостаток этого кода — то, как могут быть обработаны два подряд идущих события, первое из которых закрывает модальное окно, а второе, например, имитирует нажатие на клавиатуре. В этом случае, из-за того, что закрытие QEventLoop, созданного внутри QDialog::exec не мгновенно (по крайней мере, в реализации QAbstractEventDispatcher с помощью glib), то QKeyEvent может попасть в QEventLoop класса QDialog, и тогда оно не вызовет, например, срабатывание соотвествующего QAction в главном окне.

Поэтому окончательный вариант для борьбы с модальными диалогами получился довольно сложный:


  1. Получить текущий модальный виджет qApp->activeModalWidget(), причем нужно учесть, что этот метод не помечен как thread-safe, поэтому код вызывается в другом потоке;


  2. Послать сообщение с просьбой выполнить нужное нажатие/клик в GUI потоке;


  3. Убедиться с помощью qApp->sendPostedEvents, что сообщение из 2) дошло
    и начало обрабатываться;


  4. Повторить 1) и сравнить результаты;


  5. Если новый model widget не появился, то ждем завершения 2) без таймаута, в противном случае только заданный пользователем таймаут (по умолчанию 5 секунд).

В этом алгоритме тоже есть проблемы (если новый диалог «появляется» или «исчезает» больше 5 секунд), но в этом случае, используя JavaScript, можно выставить таймаут побольше, либо еще как-то синхронизироваться. Также не обрабатывается вариант использования пользователем QEventLoop без создания модального диалога. После победы над диалогами qt monkey заработал достаточно стабильно, и проект был сдан.


Версия на github

Т.к. решений с отрытым исходным кодом для подобного рода задач до сих пор я не видел, то, находясь в вынужденном отпуске, я решил выпустить проект в свободное плавание. Хотя предыдущий работатадель не возражал против публикации qt monkey, я, на всякий случай переписав с его нуля, выложил на github.

Текущая версия состоит из трех компонентов:


  1. Библиотека, которую нужно слинковать с вашим проектом, после чего создать класс qt_monkey_agent::Agent где-нибудь в главном потоке;


  2. qtmonkey_app — консольная программа для общения с 1), с помощью нее, например, можно запускать ваши тесты в системе continuous integration;


  3. qtmonkey_gui — элементарный GUI для qtmonkey_app.

2 и 3 общаются с помощью stdout/stdin, потоки данных структурированы с помощью JSON. Предполагается, что qtmonkey_gui можно легко заменить плагином для вашей любимой IDE.

Если найдутся люди, которым будет интересен данный проект, то его легко найти по словам «qt monkey» на github’е. Pull requests приветствуются.

© Habrahabr.ru