Dugan Chen's Homepage

Various Things

Test Driven Development with PyQt Maya Plugins

Do you want to use PyQt to develop your Maya plugins? Of course you do; the alternative is Mel. Do you want to develop these plugins via the process known as test driven development (or a variation, as shown below)? Again, of course you do. Any other process would be inefficient. Well guess what. You can do both.

The trick is that if you test any part of your application that uses a GUI, then you need to run the tests from inside Maya. If you don’t, then stop reading and just run the tests with Maya’s python interpreter and Nose. If you do, then Python’s unit testing documentation tells you how to run tests with “no requirement to be run from the command line”. That allows you to run the tests from an interactive environment such as, say, Maya.

Testing PyQt’s GUI components is done with its provided QTest class. The QTest class is meant to be used with Python’s existing unit testing framework. A particularly good tutorial can be found here:

Let’s put all of that together. Our demo plugin will consist of a single button which, when clicked, saves a Maya scene named “testscene.ma”. Here, therefore, is saver.py:

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 QDir
from PyQt4.QtGui import QMainWindow, QPushButton
from os.path import dirname, join, realpath
from pymel.core.system import saveAs


class Saver(QMainWindow):

    def __init__(self, parent=None):
        super(Saver, self).__init__(parent)
        self.save = QPushButton('&Save')
        self.save.clicked.connect(self.saveScene)
        self.setCentralWidget(self.save)

    @staticmethod
    def filenameRoot():
        path = realpath(join(dirname(__file__),
            'testscene'))
        path = QDir.fromNativeSeparators(path)
        return path

    @staticmethod
    def filename():
        return '{0}.ma'.format(Saver.filenameRoot())

    def saveScene(self):
        saveAs(self.filenameRoot(), type='mayaAscii')

Both Maya and Qt expect forward slashes to separate path components, even on Windows. We use QDir.fromNativeSeparators to account for that.

Our launcher, plugin.py, launches it from inside Maya. The getMayaWindow() function was written by Nathan Horne. Here is our launcher:

from sip import setapi, wrapinstance
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 QObject
import maya.OpenMayaUI as mui
from saver import Saver


def main():
    saver = Saver(getMayaWindow())
    saver.show()


def getMayaWindow():
    ptr = mui.MQtUtil.mainWindow()
    return wrapinstance(long(ptr), QObject)


if __name__ == '__main__':
    main()

I am paranoid enough about keeping the API at version 2 that I simply treat the setapi calls as boilerplate. To make this less painful, I recommend an editor that supports snippets, such as vim with neocomplcache-snippets-complete.

Launch Maya and verify that it runs. Click the button and confirm that it it saves a scene named “testscene.ma”.

from plugin import main
main()

At this point, we are ready to start writing our automated tests. As we work, they will reduce the chance of regressions.

Our “unit” test suite consists of a single functional test. It simulates a button click, and then tests that the scene has been saved. If it has, then it cleans up. Here is tests.py:

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 Qt
from PyQt4.QtTest import QTest
from os.path import exists
from os import remove
from plugin import getMayaWindow
from saver import Saver
import unittest


def main():
    loader = unittest.TestLoader()
    suite = loader.loadTestsFromTestCase(SaverTestCase)
    unittest.TextTestRunner(verbosity=2).run(suite)


class SaverTestCase(unittest.TestCase):

    def setUp(self):
        self.saver = Saver(getMayaWindow())

    def test_save(self):
        QTest.mouseClick(self.saver.save, Qt.LeftButton)

        self.assertTrue(exists(Saver.filename()))

        if exists(Saver.filename()):
            remove(Saver.filename())

if __name__ == '__main__':
    main()

Again, launch Maya and try it out:

from tests import main
main()

No window will be created. You will, however, see the following console output, which confirms that the test worked:


from tests import main
main()
# pymel.core : Updating pymel with pre-loaded plugins: fbxmaya # 
# test_save (tests.SaverTestCase) ... ok
# 
# ----------------------------------------------------------------------
# Ran 1 test in 0.082s
# 
# OK