Oster Galaxie Cyclomatic control panel

Test PyQt GUIs with QTest and unittest

by John McGehee on July 31, 2011

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.

PyQt QTest Example

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 stranger 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 the Voom public repository, managed by Fog Creek Software‘s Kiln, which is based on the Mercurial version control system.

You have several options for getting the files.  You can clone all the files on your computer using the Mercurial command,

hg clone https://voom.kilnhg.com/Repo/Make-Stuff-Happen/Group/PyQtTestExample

Or you can browse the file hierarchy.  You can also click on the individual file names appearing in the list below.

The 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

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 getJiggers():

    def test_defaults(self):
        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.getJiggers(), 36.0)
        self.assertEqual(self.form.getSpeedName(), "&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.getJiggers(), 36.0)
        self.assertEqual(self.form.getSpeedName(), "&Karate Chop")

Test PyQt QScrollBar

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

For convenience setFormToZero() sets all fields to zero:

    def setFormToZero(self):
        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):
        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.getJiggers(), 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.getJiggers(), 5)

Test PyQt QSpinBox

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

    def test_tripleSecSpinBox(self):
        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.getJiggers(), 2)

Test PyQt QLineEdit

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

    def test_limeJuiceLineEdit(self):
        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.getJiggers(), 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):
        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.getJiggers(), 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):
        self.form.ui.speedButton1.click()
        self.assertEqual(self.form.getSpeedName(), "&Mix")
        self.form.ui.speedButton2.click()
        self.assertEqual(self.form.getSpeedName(), "&Whip")
        self.form.ui.speedButton3.click()
        self.assertEqual(self.form.getSpeedName(), "&Puree")
        self.form.ui.speedButton4.click()
        self.assertEqual(self.form.getSpeedName(), "&Chop")
        self.form.ui.speedButton5.click()
        self.assertEqual(self.form.getSpeedName(), "&Karate Chop")
        self.form.ui.speedButton6.click()
        self.assertEqual(self.form.getSpeedName(), "&Beat")
        self.form.ui.speedButton7.click()
        self.assertEqual(self.form.getSpeedName(), "&Smash")
        self.form.ui.speedButton8.click()
        self.assertEqual(self.form.getSpeedName(), "&Liquefy")
        self.form.ui.speedButton9.click()
        self.assertEqual(self.form.getSpeedName(), "&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.

{ 6 comments… read them below or add one }

Divyanand August 1, 2011 at 10:33

Is there a similar approach to testing GUIs written in PerlTk?

John McGehee August 1, 2011 at 10:39

That’s a very different topic, Divyanand. I use Test::More for Perl unit testing, but that’s all I know.

The difficulties I had bringing up PyQt unit testing were strongly related to Python and QtTest, so I do not think that this article will help you with PerlTk.

Vincent Vande Vyvre August 3, 2011 at 20:43

[Note: The problem described here has been fixed--John McGehee]

Hi,

Testing your script I’ve got an error

AttributeError: ‘MargaritaMixer’ object has no attribute ‘jiggers’

Just change the function getLitters and it will be OK

def getLiters(self):
”’Return the total volume of the margaritas in liters.”’
return 0.0444 * self.getJiggers()

Thank’s for this interesting topic.
Cheers

John McGehee August 6, 2011 at 12:53

Ah, you’re right, Vincent. I violated the paramount rule of unit testing: either test it or eliminate it. I knew the test for getLiters() was missing, and it was bothering me–bothering me for good reason.

I added a test and fixed the bug.

David Boddie August 7, 2011 at 02:15

Thanks for writing this tutorial. GUI testing is something people ask about whenever the subject of testing comes up.

I found my way here from the PyQt Wiki. Do you think you could add a link to the http://www.diotavelli.net/PyQtWiki/GUI_Testing page, and maybe update your link from the http://www.diotavelli.net/PyQtWiki/SampleCode page to refer to a page on the Wiki itself? (The code linked to from there is currently unavailable and all the other pieces of sample code have their own pages on the Wiki.)

John McGehee August 13, 2011 at 10:49

Thank you for your comment, David. I added a link to the GUI_Testing page, and just deleted my link from the sample code page. If you previously had trouble accessing the example code, that issue is now fixed; please try again.

Leave a Comment

Previous post:

Next post: