Dugan Chen's Homepage

Various Things

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