diff options
author | Eli Collins <elic@assurancetechnologies.com> | 2020-10-07 21:33:57 -0400 |
---|---|---|
committer | Eli Collins <elic@assurancetechnologies.com> | 2020-10-07 21:33:57 -0400 |
commit | cc21426e0f7a93e5dbd2c66d9752123924a1a4dc (patch) | |
tree | 38dbb2753e341cdbe1637e82c06ec5542037892d | |
parent | a4f23dd8fed25cefc93cb257e00b16502a87dbd4 (diff) | |
download | passlib-cc21426e0f7a93e5dbd2c66d9752123924a1a4dc.tar.gz |
passlib.ext.django: refactored tests to give more granular results.
* refactored DjangoBehaviorTest so instead of a monolothic "test_config()" method;
there are now a bunch of different test methods. additionally, the ones with
loops now use TestCase.subTest() to parameter things appropriately.
*no changes to logic of the tests*
* added TestCase.subTest() stub & wrapper to make debugging subtests easier.
* added some documentation to test classes.
-rw-r--r-- | passlib/tests/test_ext_django.py | 562 | ||||
-rw-r--r-- | passlib/tests/utils.py | 62 |
2 files changed, 424 insertions, 200 deletions
diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py index 8464fa0..2a0b418 100644 --- a/passlib/tests/test_ext_django.py +++ b/passlib/tests/test_ext_django.py @@ -24,7 +24,10 @@ from passlib.tests.test_handlers import get_handler_case __all__ = [ "DjangoBehaviorTest", "ExtensionBehaviorTest", + "DjangoExtensionTest", + "_ExtensionSupport", + "_ExtensionTest", ] #============================================================================= # configure django settings for testcases @@ -200,11 +203,16 @@ sample_hashes = dict( #============================================================================= # test utils #============================================================================= + class _ExtensionSupport(object): - """support funcs for loading/unloading extension""" + """ + test support funcs for loading/unloading extension. + this class is mixed in to various TestCase subclasses. + """ #=================================================================== # support funcs #=================================================================== + @classmethod def _iter_patch_candidates(cls): """helper to scan for monkeypatches. @@ -241,8 +249,11 @@ class _ExtensionSupport(object): #=================================================================== # verify current patch state #=================================================================== + def assert_unpatched(self): - """test that django is in unpatched state""" + """ + test that django is in unpatched state + """ # make sure we aren't currently patched mod = sys.modules.get("passlib.ext.django.models") self.assertFalse(mod and mod.adapter.patched, "patch should not be enabled") @@ -259,7 +270,9 @@ class _ExtensionSupport(object): (obj, attr, source)) def assert_patched(self, context=None): - """helper to ensure django HAS been patched, and is using specified config""" + """ + helper to ensure django HAS been patched, and is using specified config + """ # make sure we're currently patched mod = sys.modules.get("passlib.ext.django.models") self.assertTrue(mod and mod.adapter.patched, "patch should have been enabled") @@ -284,9 +297,13 @@ class _ExtensionSupport(object): #=================================================================== # load / unload the extension (and verify it worked) #=================================================================== + _config_keys = ["PASSLIB_CONFIG", "PASSLIB_CONTEXT", "PASSLIB_GET_CATEGORY"] + def load_extension(self, check=True, **kwds): - """helper to load extension with specified config & patch django""" + """ + helper to load extension with specified config & patch django + """ self.unload_extension() if check: config = kwds.get("PASSLIB_CONFIG") or kwds.get("PASSLIB_CONTEXT") @@ -298,7 +315,9 @@ class _ExtensionSupport(object): self.assert_patched(context=config) def unload_extension(self): - """helper to remove patches and unload extension""" + """ + helper to remove patches and unload extension + """ # remove patches and unload module mod = sys.modules.get("passlib.ext.django.models") if mod: @@ -313,8 +332,19 @@ class _ExtensionSupport(object): # eoc #=================================================================== + # XXX: rename to ExtensionFixture? +# NOTE: would roll this into _ExtensionSupport class; +# but we have to mix that one into django's TestCase classes as well; +# and our TestCase class (and this setUp() method) would foul things up. class _ExtensionTest(TestCase, _ExtensionSupport): + """ + TestCase mixin which makes sure extension is unloaded before test; + and make sure it's unloaded after test as well. + """ + #============================================================================= + # setup + #============================================================================= def setUp(self): super(_ExtensionTest, self).setUp() @@ -332,13 +362,40 @@ class _ExtensionTest(TestCase, _ExtensionSupport): # and do the same when the test exits self.addCleanup(self.unload_extension) + #============================================================================= + # eoc + #============================================================================= + #============================================================================= # extension tests #============================================================================= + +#: static passwords used by DjangoBehaviorTest methods +PASS1 = "toomanysecrets" +WRONG1 = "letmein" + + class DjangoBehaviorTest(_ExtensionTest): - """tests model to verify it matches django's behavior""" + """ + tests model to verify it matches django's behavior. + + running this class verifies the tests correctly assert what Django itself does. + + running the ExtensionBehaviorTest subclass below verifies "passlib.ext.django" + matches what the tests assert. + """ + #============================================================================= + # class attrs + #============================================================================= + descriptionPrefix = "verify django behavior" + + #: tracks whether tests should assume "passlib.ext.django" monkeypatch is applied. + #: (set to True by ExtensionBehaviorTest subclass) patched = False + + #: dict containing CryptContext() config which should match current django deploy. + #: used by tests to verify expected behavior. config = stock_config # NOTE: if this test fails, it means we're not accounting for @@ -346,22 +403,30 @@ class DjangoBehaviorTest(_ExtensionTest): # running against an untested version of django with a new # hashing policy. - @property + #============================================================================= + # test helpers + #============================================================================= + + @memoized_property def context(self): + """ + per-test CryptContext() created from .config. + """ return CryptContext._norm_source(self.config) def assert_unusable_password(self, user): - """check that user object is set to 'unusable password' constant""" + """ + check that user object is set to 'unusable password' constant + """ self.assertTrue(user.password.startswith("!")) self.assertFalse(user.has_usable_password()) self.assertEqual(user.pop_saved_passwords(), []) def assert_valid_password(self, user, hash=UNSET, saved=None): - """check that user object has a usuable password hash. - + """ + check that user object has a usable password hash. :param hash: optionally check it has this exact hash - :param saved: check that mock commit history - for user.password matches this list + :param saved: check that mock commit history for user.password matches this list """ if hash is UNSET: self.assertNotEqual(user.password, "!") @@ -373,55 +438,54 @@ class DjangoBehaviorTest(_ExtensionTest): self.assertEqual(user.pop_saved_passwords(), [] if saved is None else [saved]) - def test_config(self): - """test hashing interface + #============================================================================= + # test hashing interface + #----------------------------------------------------------------------------- + # these functions are run against both the actual django code, + # to verify the assumptions of the unittests are correct; + # and run against the passlib extension, to verify it matches those assumptions. + # + # these tests check the following django methods: + # User.set_password() + # User.check_password() + # make_password() -- 1.4 only + # check_password() + # identify_hasher() + # User.has_usable_password() + # User.set_unusable_password() + # + # XXX: this take a while to run. what could be trimmed? + # + # TODO: add get_hasher() checks where appropriate in tests below. + #============================================================================= - this function is run against both the actual django code, to - verify the assumptions of the unittests are correct; - and run against the passlib extension, to verify it matches - those assumptions. + def test_extension_config(self): """ - log = self.getLogger() - patched, config = self.patched, self.config - # this tests the following methods: - # User.set_password() - # User.check_password() - # make_password() -- 1.4 only - # check_password() - # identify_hasher() - # User.has_usable_password() - # User.set_unusable_password() - # XXX: this take a while to run. what could be trimmed? - - # TODO: get_hasher() - - #======================================================= - # setup helpers & imports - #======================================================= + test extension config is loaded correctly + """ + if not self.patched: + raise self.skipTest("extension not loaded") + ctx = self.context - setter = create_mock_setter() - PASS1 = "toomanysecrets" - WRONG1 = "letmein" - - from django.contrib.auth.hashers import (check_password, make_password, - is_password_usable, identify_hasher) - - #======================================================= - # make sure extension is configured correctly - #======================================================= - if patched: - # contexts should match - from passlib.ext.django.models import password_context - self.assertEqual(password_context.to_dict(resolve=True), - ctx.to_dict(resolve=True)) - - # should have patched both places - from django.contrib.auth.models import check_password as check_password2 - self.assertEqual(check_password2, check_password) - - #======================================================= - # default algorithm - #======================================================= + + # contexts should match + from django.contrib.auth.hashers import check_password + from passlib.ext.django.models import password_context + self.assertEqual(password_context.to_dict(resolve=True), ctx.to_dict(resolve=True)) + + # should have patched both places + from django.contrib.auth.models import check_password as check_password2 + self.assertEqual(check_password2, check_password) + + def test_default_algorithm(self): + """ + test django's default algorithm + """ + ctx = self.context + + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import make_password + # User.set_password() should use default alg user = FakeUser() user.set_password(PASS1) @@ -436,9 +500,19 @@ class DjangoBehaviorTest(_ExtensionTest): # check_password() - n/a - #======================================================= - # empty password behavior - #======================================================= + def test_empty_password(self): + """ + test how methods handle empty string as password + """ + ctx = self.context + + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import ( + check_password, + make_password, + is_password_usable, + identify_hasher, + ) # User.set_password() should use default alg user = FakeUser() @@ -451,14 +525,26 @@ class DjangoBehaviorTest(_ExtensionTest): self.assertTrue(user.check_password("")) self.assert_valid_password(user, hash) - # no make_password() + # XXX: test make_password() ? + + # TODO: is_password_usable() + + # identify_hasher() -- na # check_password() should return True self.assertTrue(check_password("", hash)) - #======================================================= - # 'unusable flag' behavior - #======================================================= + def test_unusable_flag(self): + """ + test how methods handle 'unusable flag' in hash + """ + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import ( + check_password, + make_password, + is_password_usable, + identify_hasher, + ) # sanity check via user.set_unusable_password() user = FakeUser() @@ -488,9 +574,20 @@ class DjangoBehaviorTest(_ExtensionTest): self.assertFalse(is_password_usable(user.password)) self.assertRaises(ValueError, identify_hasher, user.password) - #======================================================= - # hash=None - #======================================================= + def test_none_hash_value(self): + """ + test how methods handle None as hash value + """ + patched = self.patched + + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import ( + check_password, + make_password, + is_password_usable, + identify_hasher, + ) + # User.set_password() - n/a # User.check_password() - returns False @@ -505,6 +602,8 @@ class DjangoBehaviorTest(_ExtensionTest): self.assertEqual(user.has_usable_password(), quirks.empty_is_usable_password) + # TODO: is_password_usable() + # make_password() - n/a # check_password() - error @@ -516,9 +615,17 @@ class DjangoBehaviorTest(_ExtensionTest): # identify_hasher() - error self.assertRaises(TypeError, identify_hasher, None) - #======================================================= - # empty hash values - #======================================================= + def test_empty_hash_value(self): + """ + test how methods handle empty string as hash value + """ + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import ( + check_password, + make_password, + is_password_usable, + identify_hasher, + ) # User.set_password() - n/a @@ -535,6 +642,8 @@ class DjangoBehaviorTest(_ExtensionTest): # User.has_usable_password() self.assertEqual(user.has_usable_password(), quirks.empty_is_usable_password) + # TODO: is_password_usable() + # make_password() - n/a # check_password() @@ -543,151 +652,192 @@ class DjangoBehaviorTest(_ExtensionTest): # identify_hasher() - throws error self.assertRaises(ValueError, identify_hasher, "") - #======================================================= - # invalid hash values - #======================================================= + def test_invalid_hash_values(self): + """ + test how methods handle invalid hash values. + """ for hash in [ "$789$foo", # empty identifier ]: - # User.set_password() - n/a + with self.subTest(hash=hash): + self._do_test_invalid_hash_value(hash) - # User.check_password() - # As of django 1.5, invalid hash returns False (side effect of django issue 18453) - user = FakeUser() - user.password = hash - self.assertFalse(user.check_password(PASS1)) + def _do_test_invalid_hash_value(self, hash): - # verify hash wasn't changed/upgraded during check_password() call - self.assertEqual(user.password, hash) - self.assertEqual(user.pop_saved_passwords(), []) + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import ( + check_password, + make_password, + is_password_usable, + identify_hasher, + ) + + # User.set_password() - n/a - # User.has_usable_password() - self.assertEqual(user.has_usable_password(), quirks.invalid_is_usable_password) + # User.check_password() + # As of django 1.5, invalid hash returns False (side effect of django issue 18453) + user = FakeUser() + user.password = hash + self.assertFalse(user.check_password(PASS1)) + + # verify hash wasn't changed/upgraded during check_password() call + self.assertEqual(user.password, hash) + self.assertEqual(user.pop_saved_passwords(), []) - # make_password() - n/a + # User.has_usable_password() + self.assertEqual(user.has_usable_password(), quirks.invalid_is_usable_password) - # check_password() - self.assertFalse(check_password(PASS1, hash)) + # TODO: is_password_usable() - # identify_hasher() - throws error - self.assertRaises(ValueError, identify_hasher, hash) + # make_password() - n/a - #======================================================= - # run through all the schemes in the context, - # testing various bits of per-scheme behavior. - #======================================================= - for scheme in ctx.schemes(): + # check_password() + self.assertFalse(check_password(PASS1, hash)) - # - # TODO: break this loop up into separate parameterized tests. - # + # identify_hasher() - throws error + self.assertRaises(ValueError, identify_hasher, hash) - #------------------------------------------------------- - # setup constants & imports, pick a sample secret/hash combo - #------------------------------------------------------- + def test_available_schemes(self): + """ + run a bunch of subtests for each hasher available in the default django setup + (as determined by reading self.context) + """ + for scheme in self.context.schemes(): + with self.subTest(scheme=scheme): + self._do_test_available_scheme(scheme) - handler = ctx.handler(scheme) - log.debug("testing scheme: %r => %r", scheme, handler) - deprecated = ctx.handler(scheme).deprecated - assert not deprecated or scheme != ctx.default_scheme() - try: - testcase = get_handler_case(scheme) - except exc.MissingBackendError: - continue - assert handler_derived_from(handler, testcase.handler) - if handler.is_disabled: - continue - - # verify that django has a backend available - # (since our hasher may use different set of backends, - # get_handler_case() above may work, but django will have nothing) - if not patched and not check_django_hasher_has_backend(handler.django_name): - assert scheme in ["django_bcrypt", "django_bcrypt_sha256", "django_argon2"], \ - "%r scheme should always have active backend" % scheme - # TODO: make this a SkipTest() once this loop has been parameterized. - log.warn("skipping scheme %r due to missing django dependancy", scheme) - continue - - # find a sample (secret, hash) pair to test with - try: - secret, hash = sample_hashes[scheme] - except KeyError: - get_sample_hash = testcase("setUp").get_sample_hash - while True: - secret, hash = get_sample_hash() - if secret: # don't select blank passwords - break - other = 'dontletmein' - - #------------------------------------------------------- - # User.set_password() - not tested here - #------------------------------------------------------- - - #------------------------------------------------------- - # User.check_password()+migration against known hash - #------------------------------------------------------- - user = FakeUser() - user.password = hash - - # check against invalid password - self.assertFalse(user.check_password(None)) - ##self.assertFalse(user.check_password('')) - self.assertFalse(user.check_password(other)) + def _do_test_available_scheme(self, scheme): + """ + helper to test how specific hasher behaves. + :param scheme: *passlib* name of hasher (e.g. "django_pbkdf2_sha256") + """ + log = self.getLogger() + ctx = self.context + patched = self.patched + setter = create_mock_setter() + + # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp() + from django.contrib.auth.hashers import ( + check_password, + make_password, + is_password_usable, + identify_hasher, + ) + + #------------------------------------------------------- + # setup constants & imports, pick a sample secret/hash combo + #------------------------------------------------------- + handler = ctx.handler(scheme) + log.debug("testing scheme: %r => %r", scheme, handler) + deprecated = ctx.handler(scheme).deprecated + assert not deprecated or scheme != ctx.default_scheme() + try: + testcase = get_handler_case(scheme) + except exc.MissingBackendError: + raise self.skipTest("backend not available") + assert handler_derived_from(handler, testcase.handler) + if handler.is_disabled: + raise self.skipTest("skip disabled hasher") + + # verify that django has a backend available + # (since our hasher may use different set of backends, + # get_handler_case() above may work, but django will have nothing) + if not patched and not check_django_hasher_has_backend(handler.django_name): + assert scheme in ["django_bcrypt", "django_bcrypt_sha256", "django_argon2"], \ + "%r scheme should always have active backend" % scheme + log.warning("skipping scheme %r due to missing django dependency", scheme) + raise self.skipTest("skip due to missing dependency") + + # find a sample (secret, hash) pair to test with + try: + secret, hash = sample_hashes[scheme] + except KeyError: + get_sample_hash = testcase("setUp").get_sample_hash + while True: + secret, hash = get_sample_hash() + if secret: # don't select blank passwords + break + other = 'dontletmein' + + #------------------------------------------------------- + # User.set_password() - not tested here + #------------------------------------------------------- + + #------------------------------------------------------- + # User.check_password()+migration against known hash + #------------------------------------------------------- + user = FakeUser() + user.password = hash + + # check against invalid password + self.assertFalse(user.check_password(None)) + ##self.assertFalse(user.check_password('')) + self.assertFalse(user.check_password(other)) + self.assert_valid_password(user, hash) + + # check against valid password + self.assertTrue(user.check_password(secret)) + + # check if it upgraded the hash + # NOTE: needs_update kept separate in case we need to test rounds. + needs_update = deprecated + if needs_update: + self.assertNotEqual(user.password, hash) + self.assertFalse(handler.identify(user.password)) + self.assertTrue(ctx.handler().verify(secret, user.password)) + self.assert_valid_password(user, saved=user.password) + else: self.assert_valid_password(user, hash) - # check against valid password - self.assertTrue(user.check_password(secret)) - - # check if it upgraded the hash - # NOTE: needs_update kept separate in case we need to test rounds. - needs_update = deprecated - if needs_update: - self.assertNotEqual(user.password, hash) - self.assertFalse(handler.identify(user.password)) - self.assertTrue(ctx.handler().verify(secret, user.password)) - self.assert_valid_password(user, saved=user.password) - else: - self.assert_valid_password(user, hash) - - # don't need to check rest for most deployments - if TEST_MODE(max="default"): - continue - - #------------------------------------------------------- - # make_password() correctly selects algorithm - #------------------------------------------------------- - alg = DjangoTranslator().passlib_to_django_name(scheme) - hash2 = make_password(secret, hasher=alg) - self.assertTrue(handler.verify(secret, hash2)) - - #------------------------------------------------------- - # check_password()+setter against known hash - #------------------------------------------------------- - # should call setter only if it needs_update - self.assertTrue(check_password(secret, hash, setter=setter)) - self.assertEqual(setter.popstate(), [secret] if needs_update else []) - - # should not call setter - self.assertFalse(check_password(other, hash, setter=setter)) - self.assertEqual(setter.popstate(), []) - - ### check preferred kwd is ignored (feature we don't currently support fully) - ##self.assertTrue(check_password(secret, hash, setter=setter, preferred='fooey')) - ##self.assertEqual(setter.popstate(), [secret]) - - # TODO: get_hasher() - - #------------------------------------------------------- - # identify_hasher() recognizes known hash - #------------------------------------------------------- - self.assertTrue(is_password_usable(hash)) - name = DjangoTranslator().django_to_passlib_name(identify_hasher(hash).algorithm) - self.assertEqual(name, scheme) + # don't need to check rest for most deployments + if TEST_MODE(max="default"): + return + + #------------------------------------------------------- + # make_password() correctly selects algorithm + #------------------------------------------------------- + alg = DjangoTranslator().passlib_to_django_name(scheme) + hash2 = make_password(secret, hasher=alg) + self.assertTrue(handler.verify(secret, hash2)) + + #------------------------------------------------------- + # check_password()+setter against known hash + #------------------------------------------------------- + # should call setter only if it needs_update + self.assertTrue(check_password(secret, hash, setter=setter)) + self.assertEqual(setter.popstate(), [secret] if needs_update else []) + + # should not call setter + self.assertFalse(check_password(other, hash, setter=setter)) + self.assertEqual(setter.popstate(), []) + + ### check preferred kwd is ignored (feature we don't currently support fully) + ##self.assertTrue(check_password(secret, hash, setter=setter, preferred='fooey')) + ##self.assertEqual(setter.popstate(), [secret]) + + # TODO: get_hasher() + + #------------------------------------------------------- + # identify_hasher() recognizes known hash + #------------------------------------------------------- + self.assertTrue(is_password_usable(hash)) + name = DjangoTranslator().django_to_passlib_name(identify_hasher(hash).algorithm) + self.assertEqual(name, scheme) + + #=================================================================== + # eoc + #=================================================================== + +#=================================================================== +# extension fidelity tests +#=================================================================== class ExtensionBehaviorTest(DjangoBehaviorTest): - """test model to verify passlib.ext.django conforms to it""" + """ + test that "passlib.ext.django" conforms to behavioral assertions in DjangoBehaviorTest + """ descriptionPrefix = "verify extension behavior" - patched = True + config = dict( schemes="sha256_crypt,md5_crypt,des_crypt", deprecated="des_crypt", @@ -695,15 +845,29 @@ class ExtensionBehaviorTest(DjangoBehaviorTest): def setUp(self): super(ExtensionBehaviorTest, self).setUp() + + # always load extension before each test self.load_extension(PASSLIB_CONFIG=self.config) + self.patched = True + +#=================================================================== +# extension internal tests +#=================================================================== class DjangoExtensionTest(_ExtensionTest): - """test the ``passlib.ext.django`` plugin""" + """ + test the ``passlib.ext.django`` plugin + """ + #=================================================================== + # class attrs + #=================================================================== + descriptionPrefix = "passlib.ext.django plugin" #=================================================================== # monkeypatch testing #=================================================================== + def test_00_patch_control(self): """test set_django_password_context patch/unpatch""" diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py index 2534050..79a9f9f 100644 --- a/passlib/tests/utils.py +++ b/passlib/tests/utils.py @@ -29,7 +29,7 @@ from passlib.tests.backports import TestCase as _TestCase, skip, skipIf, skipUnl from passlib.utils import has_rounds_info, has_salt_info, rounds_cost_values, \ rng as sys_rng, getrandstr, is_ascii_safe, to_native_str, \ repeat_string, tick, batch -from passlib.utils.compat import iteritems, irange, u, unicode, PY2 +from passlib.utils.compat import iteritems, irange, u, unicode, PY2, nullcontext from passlib.utils.decor import classproperty import passlib.utils.handlers as uh # local @@ -628,6 +628,66 @@ class TestCase(_TestCase): return value #=================================================================== + # subtests + #=================================================================== + + has_real_subtest = hasattr(_TestCase, "subTest") + + @contextlib.contextmanager + def subTest(self, *args, **kwds): + """ + wrapper/backport for .subTest() which also traps SkipTest errors. + (see source for details) + """ + # this function works around two things: + # * TestCase.subTest() wasn't added until Py34; so for older python versions, + # we either need unittest2 installed, or provide stub of our own. + # this method provides a stub if needed (based on .has_real_subtest check) + # + # * as 2020-10-08, .subTest() doesn't play nicely w/ .skipTest(); + # and also makes it hard to debug which subtest had a failure. + # (see https://bugs.python.org/issue25894 and https://bugs.python.org/issue35327) + # this method traps skipTest exceptions, and adds some logging to help debug + # which subtest caused the issue. + + # setup way to log subtest info + # XXX: would like better way to inject messages into test output; + # but this at least gets us something for debugging... + # NOTE: this hack will miss parent params if called from nested .subTest() + def _render_title(_msg=None, **params): + out = ("[%s] " % _msg if _msg else "") + if params: + out += "(%s)" % " ".join("%s=%r" % tuple(item) for item in params.items()) + return out.strip() or "<subtest>" + + test_log = self.getLogger() + title = _render_title(*args, **kwds) + + # use real subtest manager if available + if self.has_real_subtest: + ctx = super(TestCase, self).subTest(*args, **kwds) + else: + ctx = nullcontext() + + # run the subtest + with ctx: + test_log.info("running subtest: %s", title) + try: + yield + except SkipTest: + # silence "SkipTest" exceptions, want to keep running next subtest. + test_log.info("subtest skipped: %s", title) + pass + except Exception as err: + # log unhandled exception occurred + # (assuming traceback will be reported up higher, so not bothering here) + test_log.warning("subtest failed: %s: %s: %r", title, type(err).__name__, str(err)) + raise + + # XXX: check for "failed" state in ``self._outcome`` before writing this? + test_log.info("subtest passed: %s", title) + + #=================================================================== # other #=================================================================== _mktemp_queue = None |