-
Notifications
You must be signed in to change notification settings - Fork 5
/
Copy pathmayaunittest.py
380 lines (297 loc) · 12.9 KB
/
mayaunittest.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
"""
Contains functions and classes to aid in the unit testing process within Maya.
The main classes are:
TestCase - A derived class of unittest.TestCase which add convenience functionality such as auto plug-in
loading/unloading, and auto temporary file name generation and cleanup.
TestResult - A derived class of unittest.TextTestResult which customizes the test result so we can do things like do a
file new between each test and suppress script editor output.
To write tests for this system you need to,
a) Derive from cmt.test.TestCase
b) Write one or more tests that use the unittest module's assert methods to validate the results.
Example usage:
# test_sample.py
from cmt.test import TestCase
class SampleTests(TestCase):
def test_create_sphere(self):
sphere = cmds.polySphere(n='mySphere')[0]
self.assertEqual('mySphere', sphere)
# To run just this test case in Maya
import cmt.test
cmt.test.run_tests(test='test_sample.SampleTests')
# To run an individual test in a test case
cmt.test.run_tests(test='test_sample.SampleTests.test_create_sphere')
# To run all tests
cmt.test.run_tests()
"""
import os
import shutil
import sys
import unittest
import tempfile
import uuid
import logging
import maya.cmds as cmds
# The environment variable that signifies tests are being run with the custom TestResult class.
CMT_TESTING_VAR = "CMT_UNITTEST"
def new_scene():
cmds.file(f=True, new=True)
def run_tests(directories=None, test=None, test_suite=None):
"""Run all the tests in the given paths.
@param directories: A generator or list of paths containing tests to run.
@param test: Optional name of a specific test to run.
@param test_suite: Optional TestSuite to run. If omitted, a TestSuite will be generated.
"""
if test_suite is None:
test_suite = get_tests(directories, test)
runner = unittest.TextTestRunner(verbosity=2, resultclass=TestResult)
runner.failfast = False
runner.buffer = Settings.buffer_output
runner.run(test_suite)
def get_module_tests(module_root, test_patteren='test_*.py'):
""" Search for tests in this single module
"""
test_suite = unittest.TestSuite()
directories_added_to_path = []
discovered_suite = unittest.TestLoader().discover(
module_root, pattern=test_patteren)
if discovered_suite.countTestCases():
test_suite.addTests(discovered_suite)
# Remove the added paths.
for path in directories_added_to_path:
sys.path.remove(path)
return test_suite
def get_tests(directories=None, test=None, test_suite=None, test_patteren='test_*.py'):
"""Get a unittest.TestSuite containing all the desired tests.
@param directories: Optional list of directories with which to search for tests. If omitted, use all "tests"
directories of the modules found in the MAYA_MODULE_PATH.
@param test: Optional test path to find a specific test such as 'test_mytest.SomeTestCase.test_function'.
@param test_suite: Optional unittest.TestSuite to add the discovered tests to. If omitted a new TestSuite will be
created.
@return: The populated TestSuite.
"""
if directories is None:
directories = maya_module_tests()
# Populate a TestSuite with all the tests
if test_suite is None:
test_suite = unittest.TestSuite()
if test:
# Find the specified test to run
directories_added_to_path = [p for p in directories if add_to_path(p)]
discovered_suite = unittest.TestLoader().loadTestsFromName(test)
if discovered_suite.countTestCases():
test_suite.addTests(discovered_suite)
else:
# Find all tests to run
directories_added_to_path = []
for p in directories:
discovered_suite = unittest.TestLoader().discover(p, pattern=test_patteren)
if discovered_suite.countTestCases():
test_suite.addTests(discovered_suite)
# Remove the added paths.
for path in directories_added_to_path:
sys.path.remove(path)
return test_suite
def maya_module_tests():
"""Generator function to iterate over all the Maya module tests directories."""
for path in os.environ["MAYA_MODULE_PATH"].split(os.pathsep):
p = "{0}/tests".format(path)
if os.path.exists(p):
yield p
class Settings(object):
"""Contains options for running tests."""
# Specifies where files generated during tests should be stored
# Use a uuid subdirectory so tests that are running concurrently such as on a build server
# do not conflict with each other.
temp_dir = os.path.join(tempfile.gettempdir(),
"mayaunittest", str(uuid.uuid4()))
# Controls whether temp files should be deleted after running all tests in the test case
delete_files = True
# Specifies whether the standard output and standard error streams are buffered during the test run.
# Output during a passing test is discarded. Output is echoed normally on test fail or error and is
# added to the failure messages.
buffer_output = False
# Controls whether we should do a file new between each test case
file_new = True
def set_temp_dir(directory):
"""Set where files generated from tests should be stored.
@param directory: A directory path.
"""
if os.path.exists(directory):
Settings.temp_dir = directory
else:
raise RuntimeError("{0} does not exist.".format(directory))
def set_delete_files(value):
"""Set whether temp files should be deleted after running all tests in a test case.
@param value: True to delete files registered with a TestCase.
"""
Settings.delete_files = value
def set_buffer_output(value):
"""Set whether the standard output and standard error streams are buffered during the test run.
@param value: True or False
"""
Settings.buffer_output = value
def set_file_new(value):
"""Set whether a new file should be created after each test.
@param value: True or False
"""
Settings.file_new = value
def add_to_path(path):
"""Add the specified path to the system path.
@param path: Path to add.
@return True if path was added. Return false if path does not exist or path was already in sys.path
"""
if os.path.exists(path) and path not in sys.path:
sys.path.insert(0, path)
return True
return False
class TestCase(unittest.TestCase):
"""Base class for unit test cases run in Maya.
Tests do not have to inherit from this TestCase but this derived TestCase contains convenience
functions to load/unload plug-ins and clean up temporary files.
"""
# Keep track of all temporary files that were created so they can be cleaned up after all tests have been run
files_created = []
# Keep track of which plugins were loaded so we can unload them after all tests have been run
plugins_loaded = set()
@classmethod
def tearDownClass(cls):
super(TestCase, cls).tearDownClass()
cls.delete_temp_files()
cls.unload_plugins()
@classmethod
def load_plugin(cls, plugin):
"""Load the given plug-in and saves it to be unloaded when the TestCase is finished.
@param plugin: Plug-in name.
"""
cmds.loadPlugin(plugin, qt=True)
cls.plugins_loaded.add(plugin)
@classmethod
def unload_plugins(cls):
# Unload any plugins that this test case loaded
for plugin in cls.plugins_loaded:
cmds.unloadPlugin(plugin)
cls.plugins_loaded = []
@classmethod
def delete_temp_files(cls):
"""Delete the temp files in the cache and clear the cache."""
# If we don't want to keep temp files around for debugging purposes, delete them when
# all tests in this TestCase have been run
if Settings.delete_files:
for f in cls.files_created:
if os.path.exists(f):
os.remove(f)
cls.files_create = []
if os.path.exists(Settings.temp_dir):
shutil.rmtree(Settings.temp_dir)
@classmethod
def get_temp_filename(cls, file_name):
"""Get a unique filepath name in the testing directory.
The file will not be created, that is up to the caller. This file will be deleted when
the tests are finished.
@param file_name: A partial path ex: 'directory/somefile.txt'
@return The full path to the temporary file.
"""
temp_dir = Settings.temp_dir
if not os.path.exists(temp_dir):
os.makedirs(temp_dir)
base_name, ext = os.path.splitext(file_name)
path = "{0}/{1}{2}".format(temp_dir, base_name, ext)
count = 0
while os.path.exists(path):
# If the file already exists, add an incrememted number
count += 1
path = "{0}/{1}{2}{3}".format(temp_dir, base_name, count, ext)
cls.files_created.append(path)
return path
def assertListAlmostEqual(self, first, second, places=7, msg=None, delta=None):
"""Asserts that a list of floating point values is almost equal.
unittest has assertAlmostEqual and assertListEqual but no assertListAlmostEqual.
"""
self.assertEqual(len(first), len(second), msg)
for a, b in zip(first, second):
self.assertAlmostEqual(a, b, places, msg, delta)
def tearDown(self):
if Settings.file_new and CMT_TESTING_VAR not in os.environ.keys():
# If running tests without the custom runner, like with PyCharm, the file new of the TestResult class isn't
# used so call file new here
cmds.file(f=True, new=True)
class TestResult(unittest.TextTestResult):
"""Customize the test result so we can do things like do a file new between each test and suppress script
editor output.
"""
def __init__(self, stream, descriptions, verbosity):
super(TestResult, self).__init__(stream, descriptions, verbosity)
self.successes = []
def startTestRun(self):
"""Called before any tests are run."""
super(TestResult, self).startTestRun()
# Create an environment variable that specifies tests are being run through the custom runner.
os.environ[CMT_TESTING_VAR] = "1"
ScriptEditorState.suppress_output()
if Settings.buffer_output:
# Disable any logging while running tests. By disabling critical, we are disabling logging
# at all levels below critical as well
logging.disable(logging.CRITICAL)
def stopTestRun(self):
"""Called after all tests are run."""
if Settings.buffer_output:
# Restore logging state
logging.disable(logging.NOTSET)
ScriptEditorState.restore_output()
if Settings.delete_files and os.path.exists(Settings.temp_dir):
shutil.rmtree(Settings.temp_dir)
del os.environ[CMT_TESTING_VAR]
super(TestResult, self).stopTestRun()
def stopTest(self, test):
"""Called after an individual test is run.
@param test: TestCase that just ran."""
super(TestResult, self).stopTest(test)
if Settings.file_new:
cmds.file(f=True, new=True)
def addSuccess(self, test):
"""Override the base addSuccess method so we can store a list of the successful tests.
@param test: TestCase that successfully ran."""
super(TestResult, self).addSuccess(test)
self.successes.append(test)
class ScriptEditorState(object):
"""Provides methods to suppress and restore script editor output."""
# Used to restore logging states in the script editor
suppress_results = None
suppress_errors = None
suppress_warnings = None
suppress_info = None
@classmethod
def suppress_output(cls):
"""Hides all script editor output."""
if Settings.buffer_output:
cls.suppress_results = cmds.scriptEditorInfo(
q=True, suppressResults=True)
cls.suppress_errors = cmds.scriptEditorInfo(
q=True, suppressErrors=True)
cls.suppress_warnings = cmds.scriptEditorInfo(
q=True, suppressWarnings=True)
cls.suppress_info = cmds.scriptEditorInfo(
q=True, suppressInfo=True)
cmds.scriptEditorInfo(
e=True,
suppressResults=True,
suppressInfo=True,
suppressWarnings=True,
suppressErrors=True,
)
@classmethod
def restore_output(cls):
"""Restores the script editor output settings to their original values."""
if None not in {
cls.suppress_results,
cls.suppress_errors,
cls.suppress_warnings,
cls.suppress_info,
}:
cmds.scriptEditorInfo(
e=True,
suppressResults=cls.suppress_results,
suppressInfo=cls.suppress_info,
suppressWarnings=cls.suppress_warnings,
suppressErrors=cls.suppress_errors,
)