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.
Contents
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.
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 resultsMargaritaMixerTest.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.
May 15, 2015 @ 05:25:00
Thanks, Great article! I have an upcoming internship where I have to clean / document / partly rewrite a PyQt4/GIS application and this will help me get started 🙂
Jun 08, 2015 @ 02:17:02
I noticed in my own test case, when I have the self.app = QApplication(sys.argv) in the setUp() method, I get a segfault when I run my tests. Did you experience that? I think it happens because multiple instance of the QApplication get created at once and Qt doesn’t like to have more than one QApplication instance going at once. I fixed this by pulling the creation of the QApplication out of the setUp(). Just thought this would help other who experience this problem.
Aug 30, 2015 @ 21:54:26
Thank you for the comment. I fixed this and upgraded MargaritaMixer to support Python 3.4. Enjoy.
Aug 30, 2015 @ 17:41:57
The segfaults persist when placing the QApplication instance creation in setUpClass as well. What George says is what works, specifically move from:
class Test(unittest.TestCase):
def setUp(self):
self.app = QtGui.QApplication([])
to:
app = QtGui.QApplication([])
class Test(unittest.TestCase):
def setUp(self):
Place the QApplication instantiation at the module level so it can be re-used across all tests. This appears to resolve all of the segfaults without introducing any other behavior.
Aug 30, 2015 @ 21:52:33
Thank you for the fix. I implemented it and upgraded MargaritaMixer to support Python 3.4. Enjoy.
Python:Unit and functional testing a PySide-based application? – IT Sprite
Sep 27, 2015 @ 08:32:25
[…] you seen: johnnado.com/pyqt-qtest-example It is PyQt, but pretty much the […]
Oct 22, 2015 @ 03:10:49
Nice article, John, and I have this working for my own app. Thanks! But I’m a bit disappointed because I have events connected to the widgets, but the widgets don’t signal when their values change. In retrospect, this isn’t a surprise, because app.exec_() isn’t called. I thought about putting app.exec_() on a thread, but apparently that’s a big no-no. The best I can do is after I change a value with QTest, I can all the event handler manually. But whether the correct event handler is called should be part of what I’m testing! IMHO, it seems like all we can do is test whether PyQt can change its widget values, not whether our GUIs actually respond to widget changes in the correct manner. Any ideas?
Sep 23, 2017 @ 23:28:42
Hi, i have managed to get signalling working for my application, i think in general that signalling should work but i think you have to have everything connected to the same “root object”, i do like this:
When starting the application normally (not in test mode):
my_qapplication = QtWidgets.QApplication(sys.argv)
my_main = mc.matc_main.MyMainClass(my_qapplication)
my_main.main_window_qmainwindow.show()
sys.exit(my_qapplication.exec_())
For the tests:
test_app = QtWidgets.QApplication(sys.argv) # – just before the test class
my_main = mc.matc_main.MyMainClass(test_app) # -at the start of the test function where i want to use it
Also i found out that there’s something called “signal spy”
http://doc.qt.io/qt-5/qsignalspy.html
which i haven’t checked out yet but which may be interesting as well
Oct 16, 2016 @ 16:30:48
Hey,
Re other people’s comments about unites and pyqt, I found it much easier to fix these issues using pytest because you can scope your fixtures. Means you have to move to a whole different way of working though (injecting fixtures using parameters rather than class values).
Other thing I’m trying to work on right now is testing actions and menus, which are different because you apparently can’t use mouseClick for actions. You wouldn’t happen to know anything about that would you?
Oct 19, 2016 @ 02:02:36
What a great comment. Sorry, I do not know the answer to your question, but PyTest fixtures are indeed flexible.
If you figure out how to do it and want to write a new article, please contact me or, since this article is Creative Commons open sourced, re-publish a new version of my article, reworked for PyTest. Add a link to it at https://wiki.python.org/moin/PyQt/GUI_Testing.
Sep 26, 2017 @ 11:47:41
Just for reference for those finding this article (thank you John for writing it), the plugin for pytest used for running tests for PyQt is called pytest-qt:
https://pytest-qt.readthedocs.io/
https://pypi.python.org/pypi/pytest-qt
Kind Regards, Tord
Apr 11, 2017 @ 20:05:05
Hi John, I really liked your tutorial but I have some questions if you have time? First, how does unittest know to call the test_defaults first? Second, I am having a hard time understanding the okwidget potion of the test, can you please explain further?
Thank you,
Aaron
Jan 24, 2018 @ 17:01:25
Hi and thank you for a great article! I wanted to ask: How can we test launching and accepting results from modal dialogs? In our project when we try to do this the execution freezed at “exec_” for the modal dialog. Can signals be used somehow or is there another approach? Kind Regards, Tord
Jan 24, 2018 @ 18:23:27
I’ve found out that there are two ways to create modal dialogs, you don’t have to use .exec_(), instead you can use .setModal(True) and .show(). It does require a slightly different approach but it solves the problem described above
Mar 22, 2018 @ 04:27:49
How do you set up your test class? I’m not seeing a setUp() function anywhere in your article and it doesn’t seem very straightforward for pyqt apps. I’m struggling to get it set up correctly and would love an example!