Writing automated tests for QGIS plugins

The capabilities for testing QGIS plugins (particularly the question of integration testing, within a QGIS environment, as the OP highlights) has improved a great deal recently. I therefore hope this update will help contemporary readers, as well as the OP.

Boundless published a must-read article in July 2016 for anyone serious about automating the testing of QGIS plugins entitled; QGIS Continuous Integration Testing Environment for Python Plugins. It describes the approach and tools they use - all of which are open source.

Key aspects are:

  • Their special QGIS plugin tester which can automate tests inside the QGIS environment
  • The use of docker QGIS images, allowing testing against various QGIS versions/configurations in a container-base environment
  • A special docker QGIS image, which is used for testing QGIS itself, but which - by invoking qgis_testrunner.sh can be used to run unit tests on a plugin
  • The use of Travis CI for continuous integration - i.e. full test suite is run with every new code commit

If you are familiar with Travis CI/docker it ought to be relatively easy to set up.

  1. Pull the Docker image with the QGIS testing environment and run it
  2. Run qgis_setup.sh NameOfYourPlugin to install the plugin and prepare QGIS for the test runner
  3. Optionally perform all operations needed to build your plugin
  4. Run the test runner inside the Docker invoking the qgis_testrunner.sh

You asked for best practice & as of today I'd certainly consider this it. QGIS docs still haven't a dedicated section on plugin testing (I expect this will change shortly) but the "Pray that it all holds together" approach is certainly no longer the only option.

UPDATE (April-2020): Some of the mentioned links no longer work, but a small example of how to run the tests using Travis CI is provided here along with the qgis-testing environment Dockerfile by Planet, formerly known as Boundless.


It looks like this is possible to use unittest to test Python plugins loaded into a standalone Python application.

qgis.core.iface isn't available from standalone applications, so I've written a dummy instance that returns a function which will accept any arguments given to it and do nothing else. This means that calls like self.iface.addToolBarIcon(self.action) don't throw errors.

The example below loads a plugin myplugin, which has some drop down menus with layer names taken from the map layer registry. The tests check to see if the menus have been populated correctly, and can be interacted with. I'm not sure if this is the best way to load the plugin, but it seems to work.

myplugin widget

#!/usr/bin/env python

import unittest

import os
import sys

# configure python to play nicely with qgis
osgeo4w_root = r'C:/OSGeo4W'
os.environ['PATH'] = '{}/bin{}{}'.format(osgeo4w_root, os.pathsep, os.environ['PATH'])
sys.path.insert(0, '{}/apps/qgis/python'.format(osgeo4w_root))
sys.path.insert(1, '{}/apps/python27/lib/site-packages'.format(osgeo4w_root))

# import Qt
from PyQt4 import QtCore, QtGui, QtTest
from PyQt4.QtCore import Qt

# import PyQGIS
from qgis.core import *
from qgis.gui import *

# disable debug messages
os.environ['QGIS_DEBUG'] = '-1'

def setUpModule():
    # load qgis providers
    QgsApplication.setPrefixPath('{}/apps/qgis'.format(osgeo4w_root), True)
    QgsApplication.initQgis()

    globals()['shapefile_path'] = 'D:/MasterMap.shp'

# FIXME: this seems to throw errors
#def tearDownModule():
#    QgsApplication.exitQgis()

# dummy instance to replace qgis.utils.iface
class QgisInterfaceDummy(object):
    def __getattr__(self, name):
        # return an function that accepts any arguments and does nothing
        def dummy(*args, **kwargs):
            return None
        return dummy

class ExamplePluginTest(unittest.TestCase):
    def setUp(self):
        # create a new application instance
        self.app = app = QtGui.QApplication(sys.argv)

        # create a map canvas widget
        self.canvas = canvas = QgsMapCanvas()
        canvas.setCanvasColor(QtGui.QColor('white'))
        canvas.enableAntiAliasing(True)

        # load a shapefile
        layer = QgsVectorLayer(shapefile_path, 'MasterMap', 'ogr')

        # add the layer to the canvas and zoom to it
        QgsMapLayerRegistry.instance().addMapLayer(layer)
        canvas.setLayerSet([QgsMapCanvasLayer(layer)])
        canvas.setExtent(layer.extent())

        # display the map canvas widget
        #canvas.show()

        iface = QgisInterfaceDummy()

        # import the plugin to be tested
        import myplugin
        self.plugin = myplugin.classFactory(iface)
        self.plugin.initGui()
        self.dlg = self.plugin.dlg
        #self.dlg.show()

    def test_populated(self):
        '''Are the combo boxes populated correctly?'''
        self.assertEqual(self.dlg.ui.comboBox_raster.currentText(), '')
        self.assertEqual(self.dlg.ui.comboBox_vector.currentText(), 'MasterMap')
        self.assertEqual(self.dlg.ui.comboBox_all1.currentText(), '')
        self.dlg.ui.comboBox_all1.setCurrentIndex(1)
        self.assertEqual(self.dlg.ui.comboBox_all1.currentText(), 'MasterMap')

    def test_dlg_name(self):
        self.assertEqual(self.dlg.windowTitle(), 'Testing')

    def test_click_widget(self):
        '''The OK button should close the dialog'''
        self.dlg.show()
        self.assertEqual(self.dlg.isVisible(), True)
        okWidget = self.dlg.ui.buttonBox.button(self.dlg.ui.buttonBox.Ok)
        QtTest.QTest.mouseClick(okWidget, Qt.LeftButton)
        self.assertEqual(self.dlg.isVisible(), False)

    def tearDown(self):
        self.plugin.unload()
        del(self.plugin)
        del(self.app) # do not forget this

if __name__ == "__main__":
    unittest.main()

I've also put a DummyInterface together, which enables you to test QGIS plugins standalone. After reading Snorfalorpagus blog, check out my answer here.

To find a real-life example, on how I test(ed) QGIS-plugins visit this github project at https://github.com/UdK-VPT/Open_eQuarter/tree/master/mole and have a look into the tests-package.