суббота, 2 апреля 2011 г.

Тестирование пользовательского интерфейса в PyQt

Мне всегда была интересна тема автоматизации тестирования GUI, ибо задача, мягко скажем нетривиальная. А сейчас возник практический интерес к тестированию UI приложений на PyQT.

Как выяснилось, разработчики впихнули в QT целый фреймворк для автоматического тестирования, который в том числе умеет и эмулировать внешние действия пользователя на интерфейсе. Это называется QtTestLib. Другие разработчики не стали все это добро целиком тащить в PyQt, так как для питона и так предостаточно библиотек тестирования. А вот механизм эмуляции внешних телодвижений пользователя перенесли, спасибо.

Итак PyQt4.QtTest. Что это такое и с чем его едят читаем в документации. А я расскажу, как тыкал это пальцем на конкретном примере.



Для примера возьмем простейшее PyQt-приложение, которое умеет выполнять основные арифметические действия над двумя операндами и предварительно проверять операнды на корректность.




Приложение слеплено с помощью моей любимой обертки, описанной ранее, а в рамках текущего повествования интересен только класс обработки сигналов от формы. Он тривиален и содержит единственный слот, обрабатывающий нажатие кнопки "=".

class MainWndHandler(object):

    def calculate(self):
        u'''Обработчик нажатия кнопки btnCalc.
        Провека корректности параметров и вычисление результата.'''
        
        try:
            p1 = float(self.ui.edParam1.text())
        except ValueError, e:
            self.ui.edResult.setText(u'Invalid parameter #1: {0}'.format(str(e)))
            return

        try:
            p2 = float(self.ui.edParam2.text())
        except ValueError, e:
            self.ui.edResult.setText(u'Invalid parameter #2: {0}'.format(str(e)))
            return

        op = self.ui.cbOperation.currentText()
        if u'+' == op:
            res = p1 + p2
        elif u'-' == op:
            res = p1 - p2
        elif u'*' == op:
            res = p1 * p2
        elif u'/' == op:
            try:
                res = p1 / p2
            except ZeroDivisionError:
                self.ui.edResult.setText(u'Division by zero')
                return
        
        self.ui.edResult.setText(str(res))

Можно скачать архив с исходниками.

Стартовый скрипт приложения - main.py, можно запустить и поиграться, посмотреть, как обрабатывается некорректный ввод операндов.

А теперь самое интересное! Пишем тесты для GUI. Я использую unittest, но это непринципиально. Получилось у меня нечто следующее (я вырезал из теста похожие куски, полная версия в архиве):
#coding=utf-8

# служебные классы/модули
import sys
import unittest
from PyQt4.QtGui import QApplication
from PyQt4.QtTest import QTest
from PyQt4.QtCore import Qt

# тестируемые сущности
from wfactory import CWidgetFactory
from mainwnd import MainWndHandler

class TestQtGui(unittest.TestCase):

    def setUp(self):
        u'''Подготовка фикстуры'''
        
        # Требование QT - перед созданием виджета необходимо создать приложение.
        # Приложение создается однократно
        self.app = QApplication.instance() or QApplication(sys.argv)
        # Создаваемое окно не назначается главным окном приложения, т.к. приложение не будет запускаться        
        self.w = CWidgetFactory.create("MainWindow.ui", [MainWndHandler])()

    def test_empty_param1(self):
        QTest.mouseClick(self.w.ui.btnCalc, Qt.LeftButton)
        self.assertTrue(unicode(self.w.ui.edResult.text()).startswith(u'Invalid parameter #1: '))

    def test_bad_param2(self):
        QTest.keyClicks(self.w.ui.edParam1, u'2')
        QTest.keyClicks(self.w.ui.edParam2, u'aaa')
        QTest.mouseClick(self.w.ui.btnCalc, Qt.LeftButton)
        self.assertTrue(unicode(self.w.ui.edResult.text()).startswith(u'Invalid parameter #2: '))

    def test_addition(self):
        QTest.keyClicks(self.w.ui.edParam1, u'2')
        QTest.keyClicks(self.w.ui.edParam2, u'8.0')
        QTest.mouseClick(self.w.ui.cbOperation, Qt.LeftButton)
        QTest.keyClick(self.w.ui.cbOperation, u'+')
        QTest.keyClick(self.w.ui.cbOperation, Qt.Key_Enter)
        QTest.mouseClick(self.w.ui.btnCalc, Qt.LeftButton)
        self.assertEqual(self.w.ui.edResult.text(), unicode(10.))
        
    # тестирование вычитания, умножения и деления полностью аналогично сложению

    def test_division_by_zero(self):
        QTest.keyClicks(self.w.ui.edParam1, u'18')
        QTest.keyClicks(self.w.ui.edParam2, u'0.0')
        QTest.mouseClick(self.w.ui.cbOperation, Qt.LeftButton)
        QTest.keyClick(self.w.ui.cbOperation, u'/')
        QTest.keyClick(self.w.ui.cbOperation, Qt.Key_Enter)
        QTest.mouseClick(self.w.ui.btnCalc, Qt.LeftButton)
        self.assertEqual(self.w.ui.edResult.text(), u'Division by zero')

if __name__ == '__main__':
    suite = unittest.TestLoader().loadTestsFromTestCase(TestQtGui)
    unittest.TextTestRunner(verbosity=2).run(suite)

Обзовем файл test_ui_tcalc.py, находиться он должен в той же директории, что и остальные исходники, чтобы избежать сложностей с импортом тестируемых сущностей. Описание структуры хранения тестов в проекте выходит за рамки поста. Возможно опишу позже, когда придумаю :).

Запуск теста дает следующий результат:
test_addition (__main__.TestQtGui) ... ok
test_bad_param1 (__main__.TestQtGui) ... ok
test_bad_param2 (__main__.TestQtGui) ... ok
test_division (__main__.TestQtGui) ... ok
test_division_by_zero (__main__.TestQtGui) ... ok
test_empty_param1 (__main__.TestQtGui) ... ok
test_empty_param2 (__main__.TestQtGui) ... ok
test_multiplication (__main__.TestQtGui) ... ok
test_subtraction (__main__.TestQtGui) ... ok

----------------------------------------------------------------------
Ran 9 tests in 0.645s

OK

Удивительно, все работает :)
А теперь давайте "ошибемся" в логике приложения, перепутаем обработку сложения и вычитания:
        if u'+' == op:
            res = p1 - p2
        elif u'-' == op:
            res = p1 + p2

Результат:
test_addition (__main__.TestQtGui) ... FAIL
test_bad_param1 (__main__.TestQtGui) ... ok
test_bad_param2 (__main__.TestQtGui) ... ok
test_division (__main__.TestQtGui) ... ok
test_division_by_zero (__main__.TestQtGui) ... ok
test_empty_param1 (__main__.TestQtGui) ... ok
test_empty_param2 (__main__.TestQtGui) ... ok
test_multiplication (__main__.TestQtGui) ... ok
test_subtraction (__main__.TestQtGui) ... FAIL

======================================================================
FAIL: test_addition (__main__.TestQtGui)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\Work\Master\projects\python\TestCompl\test_ui_tcalc.py", line 52, in test_addition
    self.assertEqual(self.w.ui.edResult.text(), unicode(10.))
AssertionError: PyQt4.QtCore.QString(u'-6.0') != u'10.0'

======================================================================
FAIL: test_subtraction (__main__.TestQtGui)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\Work\Master\projects\python\TestCompl\test_ui_tcalc.py", line 61, in test_subtraction
    self.assertEqual(self.w.ui.edResult.text(), unicode(.5))
AssertionError: PyQt4.QtCore.QString(u'2.5') != u'0.5'

----------------------------------------------------------------------
Ran 9 tests in 0.808s

FAILED (failures=2)

Поразительно, что и ожидалось.

А теперь несколько положительных выводов:
  1. Тесты оперируют преимущественно объектами интерфейса. Если вы заметили, в коде тестов ни разу не встречаются координаты, что является обычной практикой и источником постоянных проблем при использовании внешних cистем автоматизации тестирования GUI. Однако, при крайней необходимости, координаты таки можно задавать;
  2. Не нужно заморачиваться с интеграцией тестов GUI с остальными тестами. Они ничем не отличаются :);
  3. Тестирование не поднимает окна, все делается в фоне. Это дает возможность предположить, что такие тесты сможет запускать сервис тестирования. Нужно проверить, еще одна тема для поста.

Правда, есть один маленький и, наверное, непринципиальный минус. Никто не удосужился написать инструмент для записи телодвижений пользователя на интерфейсе в виде готовых тестов :). "А, ладно, и так сойдет".

2 комментария:

  1. В настоящий момент плотно изучаю тестирование приложений, написанных на PyQt4. Не могу понять, как можно протестировать модальный диалог. Я могу запустить приложение, настроить его, но затем мне надо заполнить поля модального диалога и нажать на нём кнопку, но это не получается.

    ОтветитьУдалить
  2. К сожалению, я так и не реализовал полноценное тестирование UI своего проекта на PyQt, по большей части именно из-за этого. Не получается этим механизмом протестировать взаимодействие нескольких окон, только логику виджетов в пределах окна (что довольно малоценно). Может и можно как-то, но искал поверхностно и не нашел. Если найдете что-нибудь, не сочтите за труд запостить ссылочку.

    ОтветитьУдалить