Unit Testing PyQt Applications With Nose and Mock
I spent a couple of evenings getting familar with the Mock mocking framework for Python. Turns out it works very well for testing PyQt code.
Let’s say you have the following program, which I just contrived up. It’s very contrived, because it ignores obvious and simpler implementations such as using the QWebView‘s setUrl method. Nevertheless, it works perfectly, so long as you can load Yahoo, Google and Bing. Error handling for edge cases such as network issues has been intentionally left out for simplicity. You have a combo box from which you can choose a search engine provider. Make a selection, and the search engine is loaded in the embedded webpage. As the webpage loads, it updates a progress bar.
As you read it, think about how you’re going to unit test it:
#!/usr/bin/env python from sip import setapi setapi("QDate", 2) setapi("QDateTime", 2) setapi("QTextStream", 2) setapi("QTime", 2) setapi("QVariant", 2) setapi("QString", 2) setapi("QUrl", 2) from PyQt4.QtCore import QUrl from PyQt4.QtGui import (QApplication, QComboBox, QMainWindow, QProgressBar, QVBoxLayout, QWidget) from PyQt4 import QtNetwork from PyQt4.QtWebKit import QWebView import sys def main(): app = QApplication(sys.argv) searcher = SearchLoader() searcher.show() searcher.start() sys.exit(app.exec_()) class SearchLoader(QMainWindow): def __init__(self, parent=None): super(SearchLoader, self).__init__(parent) layout = QVBoxLayout() self.engines = QComboBox() self.engines.addItem('http://ca.yahoo.com') self.engines.addItem('http://www.google.ca') self.engines.addItem('http://www.bing.com') self.engines.currentIndexChanged[unicode].connect( self.beginLoadEngine) layout.addWidget(self.engines) self.webView = QWebView() layout.addWidget(self.webView) self.progressBar = QProgressBar() layout.addWidget(self.progressBar) panel = QWidget() panel.setLayout(layout) self.setCentralWidget(panel) self.network = QtNetwork.QNetworkAccessManager() def beginLoadEngine(self, engine): self.engines.setEnabled(False) request = QtNetwork.QNetworkRequest(QUrl(engine)) reply = self.network.get(request) reply.downloadProgress.connect(self.updateProgress) reply.finished.connect(self.completeLoadEngine) def updateProgress(self, value, maximum): self.progressBar.setMaximum(maximum) self.progressBar.setValue(value) def completeLoadEngine(self): self.engines.setEnabled(True) self.progressBar.reset() reply = self.sender() html = str(reply.readAll()) self.webView.setHtml(html) reply.deleteLater() def start(self): self.beginLoadEngine(self.engines.currentText()) if __name__ == '__main__': main()
You notice, of course, that it access the network. You also notice that the network access functionality is not parameterized and not designed for dependency injection. So do you “refactor” it so that, say, its constructor takes a network acccess manager? The answer is no. You don’t need to uglify and complicate the program like that, because you’re using Python, and you can elegantly monkey patch.
The Mock framework, in particular, has exceptionally good support for this. We need only two mocks. We overwrite PyQt4.QtNetwork.QNetworkAccessManager with a mock, because that’s what actually sends requests over the network. Furthermore, the completeLoadEngine() method calls the sender() method to retrieve the network reply. Before we test completeLoadEngine, we overwrite sender so that it returns a mock reply.
The resulting nose test suite gives you 100% code coverage for the SearchLoader class:
from sip import setapi setapi("QDate", 2) setapi("QDateTime", 2) setapi("QTextStream", 2) setapi("QTime", 2) setapi("QVariant", 2) setapi("QString", 2) setapi("QUrl", 2) from PyQt4.QtGui import QApplication from mock import MagicMock, patch from nose.tools import eq_, ok_ from searcher import SearchLoader import sys app = QApplication(sys.argv) def test_begin_load(): with patch('PyQt4.QtNetwork.QNetworkAccessManager') \ as network: loader = SearchLoader() loader.beginLoadEngine('') ok_(loader.network.get.called) network.reset_mock() loader.start() ok_(loader.network.get.called) loader.updateProgress(1, 1) eq_(1, loader.progressBar.value()) eq_(1, loader.progressBar.maximum()) loader.sender = MagicMock() loader.completeLoadEngine() ok_(loader.sender.called)
When have nose generate a code coverage report, you can see that the coverage of SearchLoader class is indeed 100%:
nosetests --with-coverage --cover-html