23 """Support code for running unit tests"""
38 __all__ = [
"init",
"MemoryTestCase",
"ExecutablesTestCase",
"getTempFilePath",
39 "TestCase",
"assertFloatsAlmostEqual",
"assertFloatsNotEqual",
"assertFloatsEqual",
40 "debugger",
"classParameters",
"methodParameters"]
46 def _get_open_files():
47 """Return a set containing the list of files currently open in this
53 Set containing the list of open files.
59 """Initialize the memory tester and file descriptor leak tester."""
62 open_files = _get_open_files()
66 """Sort supplied test suites such that MemoryTestCases are at the end.
68 `lsst.utils.tests.MemoryTestCase` tests should always run after any other
74 Sequence of test suites.
78 suite : `unittest.TestSuite`
79 A combined `~unittest.TestSuite` with
80 `~lsst.utils.tests.MemoryTestCase` at the end.
83 suite = unittest.TestSuite()
85 for test_suite
in tests:
92 for method
in test_suite:
93 bases = inspect.getmro(method.__class__)
95 if bases
is not None and MemoryTestCase
in bases:
96 memtests.append(test_suite)
98 suite.addTests(test_suite)
100 if isinstance(test_suite, MemoryTestCase):
101 memtests.append(test_suite)
103 suite.addTest(test_suite)
104 suite.addTests(memtests)
115 unittest.defaultTestLoader.suiteClass = suiteClassWrapper
119 """Check for resource leaks."""
123 """Reset the leak counter when the tests have been completed"""
127 """Check if any file descriptors are open since init() called."""
130 now_open = _get_open_files()
133 now_open =
set(f
for f
in now_open
if not f.endswith(
".car")
and
134 not f.startswith(
"/proc/")
and
135 not f.endswith(
".ttf")
and
136 not (f.startswith(
"/var/lib/")
and f.endswith(
"/passwd"))
and
137 not f.endswith(
"astropy.log"))
139 diff = now_open.difference(open_files)
142 print(
"File open: %s" % f)
143 self.fail(
"Failed to close %d file%s" % (len(diff),
"s" if len(diff) != 1
else ""))
147 """Test that executables can be run and return good status.
149 The test methods are dynamically created. Callers
150 must subclass this class in their own test file and invoke
151 the create_executable_tests() class method to register the tests.
153 TESTS_DISCOVERED = -1
157 """Abort testing if automated test creation was enabled and
158 no tests were found."""
161 raise Exception(
"No executables discovered.")
164 """This test exists to ensure that there is at least one test to be
165 executed. This allows the test runner to trigger the class set up
166 machinery to test whether there are some executables to test."""
170 """Check an executable runs and returns good status.
172 Prints output to standard out. On bad exit status the test
173 fails. If the executable can not be located the test is skipped.
178 Path to an executable. ``root_dir`` is not used if this is an
180 root_dir : `str`, optional
181 Directory containing executable. Ignored if `None`.
182 args : `list` or `tuple`, optional
183 Arguments to be provided to the executable.
184 msg : `str`, optional
185 Message to use when the test fails. Can be `None` for default
191 The executable did not return 0 exit status.
194 if root_dir
is not None and not os.path.isabs(executable):
195 executable = os.path.join(root_dir, executable)
198 sp_args = [executable]
199 argstr =
"no arguments"
202 argstr =
'arguments "' +
" ".join(args) +
'"'
204 print(
"Running executable '{}' with {}...".
format(executable, argstr))
205 if not os.path.exists(executable):
206 self.skipTest(
"Executable {} is unexpectedly missing".
format(executable))
209 output = subprocess.check_output(sp_args)
210 except subprocess.CalledProcessError
as e:
212 failmsg =
"Bad exit status from '{}': {}".
format(executable, e.returncode)
213 print(output.decode(
'utf-8'))
220 def _build_test_method(cls, executable, root_dir):
221 """Build a test method and attach to class.
223 A test method is created for the supplied excutable located
224 in the supplied root directory. This method is attached to the class
225 so that the test runner will discover the test and run it.
230 The class in which to create the tests.
232 Name of executable. Can be absolute path.
234 Path to executable. Not used if executable path is absolute.
236 if not os.path.isabs(executable):
237 executable = os.path.abspath(os.path.join(root_dir, executable))
240 test_name =
"test_exe_" + executable.replace(
"/",
"_")
243 def test_executable_runs(*args):
245 self.assertExecutable(executable)
248 test_executable_runs.__name__ = test_name
249 setattr(cls, test_name, test_executable_runs)
253 """Discover executables to test and create corresponding test methods.
255 Scans the directory containing the supplied reference file
256 (usually ``__file__`` supplied from the test class) to look for
257 executables. If executables are found a test method is created
258 for each one. That test method will run the executable and
259 check the returned value.
261 Executable scripts with a ``.py`` extension and shared libraries
262 are ignored by the scanner.
264 This class method must be called before test discovery.
269 Path to a file within the directory to be searched.
270 If the files are in the same location as the test file, then
271 ``__file__`` can be used.
272 executables : `list` or `tuple`, optional
273 Sequence of executables that can override the automated
274 detection. If an executable mentioned here is not found, a
275 skipped test will be created for it, rather than a failed
280 >>> cls.create_executable_tests(__file__)
284 ref_dir = os.path.abspath(os.path.dirname(ref_file))
286 if executables
is None:
289 for root, dirs, files
in os.walk(ref_dir):
292 if not f.endswith(
".py")
and not f.endswith(
".so"):
293 full_path = os.path.join(root, f)
294 if os.access(full_path, os.X_OK):
295 executables.append(full_path)
304 for e
in executables:
308 @contextlib.contextmanager
310 """Return a path suitable for a temporary file and try to delete the
313 If the with block completes successfully then the file is deleted,
314 if possible; failure results in a printed warning.
315 If a file is remains when it should not, a RuntimeError exception is
316 raised. This exception is also raised if a file is not present on context
317 manager exit when one is expected to exist.
318 If the block exits with an exception the file if left on disk so it can be
319 examined. The file name has a random component such that nested context
320 managers can be used with the same file suffix.
326 File name extension, e.g. ``.fits``.
327 expectOutput : `bool`, optional
328 If `True`, a file should be created within the context manager.
329 If `False`, a file should not be present when the context manager
335 Path for a temporary file. The path is a combination of the caller's
336 file path and the name of the top-level function
342 # file tests/testFoo.py
344 import lsst.utils.tests
345 class FooTestCase(unittest.TestCase):
346 def testBasics(self):
350 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile:
351 # if tests/.tests exists then
352 # tmpFile = "tests/.tests/testFoo_testBasics.fits"
353 # otherwise tmpFile = "testFoo_testBasics.fits"
355 # at the end of this "with" block the path tmpFile will be
356 # deleted, but only if the file exists and the "with"
357 # block terminated normally (rather than with an exception)
360 stack = inspect.stack()
362 for i
in range(2, len(stack)):
363 frameInfo = inspect.getframeinfo(stack[i][0])
365 callerFilePath = frameInfo.filename
366 callerFuncName = frameInfo.function
367 elif callerFilePath == frameInfo.filename:
369 callerFuncName = frameInfo.function
373 callerDir, callerFileNameWithExt = os.path.split(callerFilePath)
374 callerFileName = os.path.splitext(callerFileNameWithExt)[0]
375 outDir = os.path.join(callerDir,
".tests")
376 if not os.path.isdir(outDir):
378 prefix =
"%s_%s-" % (callerFileName, callerFuncName)
379 outPath = tempfile.mktemp(dir=outDir, suffix=ext, prefix=prefix)
380 if os.path.exists(outPath):
383 warnings.warn(
"Unexpectedly found pre-existing tempfile named %r" % (outPath,),
392 fileExists = os.path.exists(outPath)
395 raise RuntimeError(
"Temp file expected named {} but none found".
format(outPath))
398 raise RuntimeError(
"Unexpectedly discovered temp file named {}".
format(outPath))
405 warnings.warn(
"Warning: could not remove file %r: %s" % (outPath, e), stacklevel=3)
409 """Subclass of unittest.TestCase that adds some custom assertions for
415 """A decorator to add a free function to our custom TestCase class, while also
416 making it available as a free function.
418 setattr(TestCase, func.__name__, func)
423 """Decorator to enter the debugger when there's an uncaught exception
425 To use, just slap a ``@debugger()`` on your function.
427 You may provide specific exception classes to catch as arguments to
428 the decorator function, e.g.,
429 ``@debugger(RuntimeError, NotImplementedError)``.
430 This defaults to just `AssertionError`, for use on `unittest.TestCase`
433 Code provided by "Rosh Oxymoron" on StackOverflow:
434 http://stackoverflow.com/questions/4398967/python-unit-testing-automatically-running-the-debugger-when-a-test-fails
438 Consider using ``pytest --pdb`` instead of this decorator.
441 exceptions = (Exception, )
445 def wrapper(*args, **kwargs):
447 return f(*args, **kwargs)
451 pdb.post_mortem(sys.exc_info()[2])
457 """Plot the comparison of two 2-d NumPy arrays.
461 lhs : `numpy.ndarray`
462 LHS values to compare; a 2-d NumPy array
463 rhs : `numpy.ndarray`
464 RHS values to compare; a 2-d NumPy array
465 bad : `numpy.ndarray`
466 A 2-d boolean NumPy array of values to emphasize in the plots
467 diff : `numpy.ndarray`
468 difference array; a 2-d NumPy array, or None to show lhs-rhs
470 Filename to save the plot to. If None, the plot will be displayed in
475 This method uses `matplotlib` and imports it internally; it should be
476 wrapped in a try/except block within packages that do not depend on
477 `matplotlib` (including `~lsst.utils`).
479 from matplotlib
import pyplot
485 badImage = numpy.zeros(bad.shape + (4,), dtype=numpy.uint8)
486 badImage[:, :, 0] = 255
487 badImage[:, :, 1] = 0
488 badImage[:, :, 2] = 0
489 badImage[:, :, 3] = 255*bad
490 vmin1 = numpy.minimum(numpy.min(lhs), numpy.min(rhs))
491 vmax1 = numpy.maximum(numpy.max(lhs), numpy.max(rhs))
492 vmin2 = numpy.min(diff)
493 vmax2 = numpy.max(diff)
494 for n, (image, title)
in enumerate([(lhs,
"lhs"), (rhs,
"rhs"), (diff,
"diff")]):
495 pyplot.subplot(2, 3, n + 1)
496 im1 = pyplot.imshow(image, cmap=pyplot.cm.gray, interpolation=
'nearest', origin=
'lower',
497 vmin=vmin1, vmax=vmax1)
499 pyplot.imshow(badImage, alpha=0.2, interpolation=
'nearest', origin=
'lower')
502 pyplot.subplot(2, 3, n + 4)
503 im2 = pyplot.imshow(image, cmap=pyplot.cm.gray, interpolation=
'nearest', origin=
'lower',
504 vmin=vmin2, vmax=vmax2)
506 pyplot.imshow(badImage, alpha=0.2, interpolation=
'nearest', origin=
'lower')
509 pyplot.subplots_adjust(left=0.05, bottom=0.05, top=0.92, right=0.75, wspace=0.05, hspace=0.05)
510 cax1 = pyplot.axes([0.8, 0.55, 0.05, 0.4])
511 pyplot.colorbar(im1, cax=cax1)
512 cax2 = pyplot.axes([0.8, 0.05, 0.05, 0.4])
513 pyplot.colorbar(im2, cax=cax2)
515 pyplot.savefig(plotFileName)
522 atol=sys.float_info.epsilon, relTo=None,
523 printFailures=True, plotOnFailure=False,
524 plotFileName=None, invert=False, msg=None):
525 """Highly-configurable floating point comparisons for scalars and arrays.
527 The test assertion will fail if all elements ``lhs`` and ``rhs`` are not
528 equal to within the tolerances specified by ``rtol`` and ``atol``.
529 More precisely, the comparison is:
531 ``abs(lhs - rhs) <= relTo*rtol OR abs(lhs - rhs) <= atol``
533 If ``rtol`` or ``atol`` is `None`, that term in the comparison is not
536 When not specified, ``relTo`` is the elementwise maximum of the absolute
537 values of ``lhs`` and ``rhs``. If set manually, it should usually be set
538 to either ``lhs`` or ``rhs``, or a scalar value typical of what is
543 testCase : `unittest.TestCase`
544 Instance the test is part of.
545 lhs : scalar or array-like
546 LHS value(s) to compare; may be a scalar or array-like of any
548 rhs : scalar or array-like
549 RHS value(s) to compare; may be a scalar or array-like of any
551 rtol : `float`, optional
552 Relative tolerance for comparison; defaults to double-precision
554 atol : `float`, optional
555 Absolute tolerance for comparison; defaults to double-precision
557 relTo : `float`, optional
558 Value to which comparison with rtol is relative.
559 printFailures : `bool`, optional
560 Upon failure, print all inequal elements as part of the message.
561 plotOnFailure : `bool`, optional
562 Upon failure, plot the originals and their residual with matplotlib.
563 Only 2-d arrays are supported.
564 plotFileName : `str`, optional
565 Filename to save the plot to. If `None`, the plot will be displayed in
567 invert : `bool`, optional
568 If `True`, invert the comparison and fail only if any elements *are*
569 equal. Used to implement `~lsst.utils.tests.assertFloatsNotEqual`,
570 which should generally be used instead for clarity.
571 msg : `str`, optional
572 String to append to the error message when assert fails.
577 The values are not almost equal.
579 if not numpy.isfinite(lhs).
all():
580 testCase.fail(
"Non-finite values in lhs")
581 if not numpy.isfinite(rhs).
all():
582 testCase.fail(
"Non-finite values in rhs")
584 absDiff = numpy.abs(lhs - rhs)
587 relTo = numpy.maximum(numpy.abs(lhs), numpy.abs(rhs))
589 relTo = numpy.abs(relTo)
590 bad = absDiff > rtol*relTo
592 bad = numpy.logical_and(bad, absDiff > atol)
595 raise ValueError(
"rtol and atol cannot both be None")
597 failed = numpy.any(bad)
600 bad = numpy.logical_not(bad)
602 failStr =
"are the same"
608 if numpy.isscalar(bad):
610 errMsg = [
"%s %s %s; diff=%s with atol=%s"
611 % (lhs, cmpStr, rhs, absDiff, atol)]
613 errMsg = [
"%s %s %s; diff=%s/%s=%s with rtol=%s"
614 % (lhs, cmpStr, rhs, absDiff, relTo, absDiff/relTo, rtol)]
616 errMsg = [
"%s %s %s; diff=%s/%s=%s with rtol=%s, atol=%s"
617 % (lhs, cmpStr, rhs, absDiff, relTo, absDiff/relTo, rtol, atol)]
619 errMsg = [
"%d/%d elements %s with rtol=%s, atol=%s"
620 % (bad.sum(), bad.size, failStr, rtol, atol)]
622 if len(lhs.shape) != 2
or len(rhs.shape) != 2:
623 raise ValueError(
"plotOnFailure is only valid for 2-d arrays")
625 plotImageDiff(lhs, rhs, bad, diff=diff, plotFileName=plotFileName)
627 errMsg.append(
"Failure plot requested but matplotlib could not be imported.")
632 if numpy.isscalar(relTo):
633 relTo = numpy.ones(bad.shape, dtype=float) * relTo
634 if numpy.isscalar(lhs):
635 lhs = numpy.ones(bad.shape, dtype=float) * lhs
636 if numpy.isscalar(rhs):
637 rhs = numpy.ones(bad.shape, dtype=float) * rhs
639 for a, b, diff
in zip(lhs[bad], rhs[bad], absDiff[bad]):
640 errMsg.append(
"%s %s %s (diff=%s)" % (a, cmpStr, b, diff))
642 for a, b, diff, rel
in zip(lhs[bad], rhs[bad], absDiff[bad], relTo[bad]):
643 errMsg.append(
"%s %s %s (diff=%s/%s=%s)" % (a, cmpStr, b, diff, rel, diff/rel))
647 testCase.assertFalse(failed, msg=
"\n".join(errMsg))
652 """Fail a test if the given floating point values are equal to within the
655 See `~lsst.utils.tests.assertFloatsAlmostEqual` (called with
656 ``rtol=atol=0``) for more information.
660 testCase : `unittest.TestCase`
661 Instance the test is part of.
662 lhs : scalar or array-like
663 LHS value(s) to compare; may be a scalar or array-like of any
665 rhs : scalar or array-like
666 RHS value(s) to compare; may be a scalar or array-like of any
672 The values are almost equal.
680 Assert that lhs == rhs (both numeric types, whether scalar or array).
682 See `~lsst.utils.tests.assertFloatsAlmostEqual` (called with
683 ``rtol=atol=0``) for more information.
687 testCase : `unittest.TestCase`
688 Instance the test is part of.
689 lhs : scalar or array-like
690 LHS value(s) to compare; may be a scalar or array-like of any
692 rhs : scalar or array-like
693 RHS value(s) to compare; may be a scalar or array-like of any
699 The values are not equal.
704 def _settingsIterator(settings):
705 """Return an iterator for the provided test settings
709 settings : `dict` (`str`: iterable)
710 Lists of test parameters. Each should be an iterable of the same length.
711 If a string is provided as an iterable, it will be converted to a list
717 If the ``settings`` are not of the same length.
721 parameters : `dict` (`str`: anything)
724 for name, values
in settings.items():
725 if isinstance(values, str):
727 settings[name] = [values]
728 num = len(
next(
iter(settings.values())))
729 for name, values
in settings.items():
730 assert len(values) == num, f
"Length mismatch for setting {name}: {len(values)} vs {num}"
731 for ii
in range(num):
732 values = [settings[kk][ii]
for kk
in settings]
733 yield dict(zip(settings.keys(), values))
737 """Class decorator for generating unit tests
739 This decorator generates classes with class variables according to the
740 supplied ``settings``.
744 **settings : `dict` (`str`: iterable)
745 The lists of test parameters to set as class variables in turn. Each
746 should be an iterable of the same length.
752 @classParameters(foo=[1, 2], bar=[3, 4])
753 class MyTestCase(unittest.TestCase):
756 will generate two classes, as if you wrote::
758 class MyTestCase_1_3(unittest.TestCase):
763 class MyTestCase_2_4(unittest.TestCase):
768 Note that the values are embedded in the class name.
771 module = sys.modules[cls.__module__].__dict__
772 for params
in _settingsIterator(settings):
773 name = f
"{cls.__name__}_{'_'.join(str(vv) for vv in params.values())}"
774 bindings = dict(cls.__dict__)
775 bindings.update(params)
776 module[name] =
type(name, (cls,), bindings)
781 """Method decorator for unit tests
783 This decorator iterates over the supplied settings, using
784 ``TestCase.subTest`` to communicate the values in the event of a failure.
788 **settings : `dict` (`str`: iterable)
789 The lists of test parameters. Each should be an iterable of the same
796 @methodParameters(foo=[1, 2], bar=[3, 4])
797 def testSomething(self, foo, bar):
802 testSomething(foo=1, bar=3)
803 testSomething(foo=2, bar=4)
806 @functools.wraps(func)
807 def wrapper(self, *args, **kwargs):
808 for params
in _settingsIterator(settings):
809 kwargs.update(params)
810 with self.subTest(**params):
811 func(self, *args, **kwargs)