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