Test PyQt GUIs with QTest and unittest

PyQt is the Python binding of the popular Qt cross-platform UI and application framework.  For unit testing, PyQt clients are expected to use the standard Python unittest module, with just a little help from the PyQt QtTest module.  It is not immediately apparent how to blend these two frameworks into a unified unit testing strategy.  In this article, I show you how to unit test a PyQt GUI dialog using only modules included in PyQt and Python.

While the Qt C++ API includes a complete unit testing framework,The PyQt QtTest module contains only the QTest class, with static methods to simulate keystrokes, mouse clicks, and mouse movement.

Testing a GUI dialog requires only the keystroke methods to type strings into QLineEdit widgets, and mouse clicks to click the OK button.  In more sophisticated drawing or layout applications, the mouse click and movement methods can be used to simulate drawing or dragging gestures.

The Margarita Mixer Dialog

For this example I used Qt Designer to create the user interface for a cocktail mixing machine.  To avoid temptation, I chose margaritas because I do not like them so much.

MargaritaMixer

In the upper portion of the dialog, the user specifies the number of jiggers for each ingredient (1 jigger = 0.0444 liters).  In the lower portion, the user selects from blender speeds with names even weirder than those appearing on a real Oster Galaxie Cyclomatic.  After specifying the amounts and blender speed, the user clicks OK, and an as-yet unimplemented machine creates the refreshing product.

PyQt QTest Example Files

The example is available on Bitbucket.

The interesting files are in directory pyqttestexample/src/:

  • MargaritaMixer.ui is the XML output of Qt Designer. It describes the design of the GUI dialog.
  • Ui_MargaritaMixer.py is the Python source code file that describes the design of the GUI dialog. It is created from the above Qt Designer output file using the command:
    pyuic4 --output Ui_MargaritaMixer.py MargaritaMixer.ui
  • MargaritaMixer.py contains the class that instantiates the GUI dialog and processes the results
  • MargaritaMixerTest.py is the unit test

Unit Test Program

Enough flair bartending with the wacky margarita mixer.  This article is all about the unit test in file MargaritaMixerTest.py.

Imports

First import the required modules and classes, including of course the module under test, MargaritaMixer:

import sys
import unittest
from PyQt4.QtGui import QApplication
from PyQt4.QtTest import QTest
from PyQt4.QtCore import Qt
import MargaritaMixer

Test the Dialog Defaults

The first test checks each of the default values of the dialog, pushes the OK button, and checks the volume returned by jiggers:

def test_defaults(self):
   '''Test the GUI in its default state'''
   self.assertEqual(self.form.ui.tequilaScrollBar.value(), 8)
   self.assertEqual(self.form.ui.tripleSecSpinBox.value(), 4)
   self.assertEqual(self.form.ui.limeJuiceLineEdit.text(), "12.0")
   self.assertEqual(self.form.ui.iceHorizontalSlider.value(), 12)
   self.assertEqual(self.form.ui.speedButtonGroup.checkedButton().text(), "&Karate Chop")

   # Class is in the default state even without pressing OK
   self.assertEqual(self.form.jiggers, 36.0)
   self.assertEqual(self.form.speedName, "&Karate Chop")
 
   # Push OK with the left mouse button
   okWidget = self.form.ui.buttonBox.button(self.form.ui.buttonBox.Ok)
   QTest.mouseClick(okWidget, Qt.LeftButton)
   self.assertEqual(self.form.jiggers, 36.0)
   self.assertEqual(self.form.speedName, "&Karate Chop")

Test PyQt QScrollBar

To test whether each ingredient appears in the total volume returned by jiggers, the test sets all ingredients to zero, sets the ingredient under test to some nonzero value, then calls jiggers.

For convenience setFormToZero() sets all fields to zero:

def setFormToZero(self):
    '''Set all ingredients to zero in preparation for setting just one
     to a nonzero value.
     '''
     self.form.ui.tequilaScrollBar.setValue(0)
     self.form.ui.tripleSecSpinBox.setValue(0)
     self.form.ui.limeJuiceLineEdit.setText("0.0")
     self.form.ui.iceHorizontalSlider.setValue(0)

Next test the scroll bar that determines the number of jiggers of tequila. Test the minimum and maximum values and then try a legal value:

def test_tequilaScrollBar(self):
    '''Test the tequila scroll bar'''
    self.setFormToZero()
 
    # Test the maximum. This one goes to 11.
    self.form.ui.tequilaScrollBar.setValue(12)
    self.assertEqual(self.form.ui.tequilaScrollBar.value(), 11)

    # Test the minimum of zero.
    self.form.ui.tequilaScrollBar.setValue(-1)
    self.assertEqual(self.form.ui.tequilaScrollBar.value(), 0)

    self.form.ui.tequilaScrollBar.setValue(5)
 
    # Push OK with the left mouse button
    okWidget = self.form.ui.buttonBox.button(self.form.ui.buttonBox.Ok)
    QTest.mouseClick(okWidget, Qt.LeftButton)
    self.assertEqual(self.form.jiggers, 5)

Test PyQt QDialogButtonBox

Note how in the previous and subsequent examples, QTest.mouseClick() is used to actually click on the center of the OK button:

    okWidget = self.form.ui.buttonBox.button(self.form.ui.buttonBox.Ok)
    QTest.mouseClick(okWidget, Qt.LeftButton)
    self.assertEqual(self.form.jiggers, 5)

Test PyQt QSpinBox

Next, set the triple sec spin box alone to a nonzero value and verify the result:

def test_tripleSecSpinBox(self):
    '''Test the triple sec spin box.
    Testing the minimum and maximum is left as an exercise for the reader.
    '''
    self.setFormToZero()
    self.form.ui.tripleSecSpinBox.setValue(2)

    # Push OK with the left mouse button
    okWidget = self.form.ui.buttonBox.button(self.form.ui.buttonBox.Ok)
    QTest.mouseClick(okWidget, Qt.LeftButton)
    self.assertEqual(self.form.jiggers, 2)

Test PyQt QLineEdit

Use QTest.keyClicks() to actually type a string into the lime juice line edit widget:

def test_limeJuiceLineEdit(self):
    '''Test the lime juice line edit.
    Testing the minimum and maximum is left as an exercise for the reader.
    '''
    self.setFormToZero()
    # Clear and then type "3.5" into the lineEdit widget
    self.form.ui.limeJuiceLineEdit.clear()
    QTest.keyClicks(self.form.ui.limeJuiceLineEdit, "3.5")

    # Push OK with the left mouse button
    okWidget = self.form.ui.buttonBox.button(self.form.ui.buttonBox.Ok)
    QTest.mouseClick(okWidget, Qt.LeftButton)
    self.assertEqual(self.form.jiggers, 3.5)

I used QTest.keyClicks() merely because this article emphasizes QtTest.  I think the test would be just as valid if the widget text were set directly using QLineEdit.setText():

    self.form.ui.limeJuiceLineEdit.setText("3.5")

Test PyQt QSlider

Test the ice slider:

def test_iceHorizontalSlider(self):
    '''Test the ice slider.
    Testing the minimum and maximum is left as an exercise for the reader.
    '''
    self.setFormToZero()
    self.form.ui.iceHorizontalSlider.setValue(4)

    # Push OK with the left mouse button
    okWidget = self.form.ui.buttonBox.button(self.form.ui.buttonBox.Ok)
    QTest.mouseClick(okWidget, Qt.LeftButton)
    self.assertEqual(self.form.jiggers, 4)

Test PyQt QRadioButton

The blender speed radio buttons are in a QButtonGroup. Test every button because it is very easy to leave one of them outside the button group:

def test_blenderSpeedButtons(self):
    '''Test the blender speed buttons'''
    self.form.ui.speedButton1.click()
    self.assertEqual(self.form.speedName, "&Mix")
    self.form.ui.speedButton2.click()
    self.assertEqual(self.form.speedName, "&Whip")
    self.form.ui.speedButton3.click()
    self.assertEqual(self.form.speedName, "&Puree")
    self.form.ui.speedButton4.click()
    self.assertEqual(self.form.speedName, "&Chop")
    self.form.ui.speedButton5.click()
    self.assertEqual(self.form.speedName, "&Karate Chop")
    self.form.ui.speedButton6.click()
    self.assertEqual(self.form.speedName, "&Beat")
    self.form.ui.speedButton7.click()
    self.assertEqual(self.form.speedName, "&Smash")
    self.form.ui.speedButton8.click()
    self.assertEqual(self.form.speedName, "&Liquefy")
    self.form.ui.speedButton9.click()
    self.assertEqual(self.form.speedName, "&Vaporize")

Comments, Please

If you have ideas on my style or ways to improve this article, please help everybody and leave a comment!


Photo credit: Oster Galaxie Cyclomatic by The Thrift Collective.

This article was originally published by John McGehee, Voom, Inc. under the CC BY 3.0 license.  Changes have been made.