Odoo Test environments requires to modify many parts of the unittest TestCase, Suite and Result. The main initial reason is to **avoid to postpone result at the end of the test suite**, because even if it is convenient to have all errors visible after the tests in some case, odoo logs adds information during the execution that can be useful to debug when a test fail, to have context for an error. (see **OdooTestResult**) We are also fixing the stack trace comming from a unittest and since there is no proper way to hook inside the TestPartExecutor, a dirty hack injects anoter result on the outcome to manage the error and complete the stack trace. This was also a way to avoid to postpone subtest logs at the end of the test case (see _ErrorCatcher) `_feedErrorsToResult` was used to test the test suite behavior since there are many customization and this is quite fragile, especially if unittest changes behavior in other python version. **Python 3.11** introduced python/cpython#664448d8 That, in a way, goes in the same direction of the changed introduced with _ErrorCatcher: immediately feed errors to resut instead of postponing it. But this also removes `_feedErrorsToResult` that was used to test this behaviors, as well as other ones. Since odoo should remain multi-version, this amount of changes on the initial behavior become to complicate to keep cross-version and the (already in our mind for a while) solution to **vendor unittest** will help to simplify most of our test code base. This commit modified the vendored unittest files to simplify them as much as possible to suite our needs. Since the runner is still the unittest one, we need to inherit from unittest.Testcase in order to have the right type. This also means that we still have access to all TestCase methods without overriding them all. This is convenient for assertion methods as an example but the initial idea is to vendor our own version of TestCase to avoid having trouble to adapte our miscommunications to future python versions. A trade-off must be done to chose what should remain in our code base. The idea is to keep logic closely linked to our changes in our code base, mainly around the run method, but also addClassCleanup wich need to be vendored for python 3.7, but assertions methods are independent. Any logic can be moved fom unittest to our vendored version in the future if needed. X-original-commit: 9a5d1ea54be49e4cc8208c33e76a6bbd2414d5d0 Part-of: odoo/odoo#113850
278 lines
10 KiB
Python
278 lines
10 KiB
Python
"""Test case implementation"""
|
|
|
|
import sys
|
|
import inspect
|
|
import logging
|
|
import contextlib
|
|
from unittest import SkipTest, TestCase as _TestCase
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
__unittest = True
|
|
|
|
_subtest_msg_sentinel = object()
|
|
|
|
|
|
class _Outcome(object):
|
|
def __init__(self, test, result):
|
|
self.result = result
|
|
self.success = True
|
|
self.test = test
|
|
|
|
@contextlib.contextmanager
|
|
def testPartExecutor(self, test_case, isTest=False):
|
|
try:
|
|
yield
|
|
except KeyboardInterrupt:
|
|
raise
|
|
except SkipTest as e:
|
|
self.success = False
|
|
self.result.addSkip(test_case, str(e))
|
|
except: # pylint: disable=bare-except
|
|
exc_info = sys.exc_info()
|
|
self.success = False
|
|
|
|
if exc_info is not None:
|
|
exception_type, exception, tb = exc_info
|
|
tb = self._complete_traceback(tb)
|
|
exc_info = (exception_type, exception, tb)
|
|
self.test._addError(self.result, test_case, exc_info)
|
|
|
|
# explicitly break a reference cycle:
|
|
# exc_info -> frame -> exc_info
|
|
exc_info = None
|
|
|
|
def _complete_traceback(self, initial_tb):
|
|
Traceback = type(initial_tb)
|
|
|
|
# make the set of frames in the traceback
|
|
tb_frames = set()
|
|
tb = initial_tb
|
|
while tb:
|
|
tb_frames.add(tb.tb_frame)
|
|
tb = tb.tb_next
|
|
tb = initial_tb
|
|
|
|
# find the common frame by searching the last frame of the current_stack present in the traceback.
|
|
current_frame = inspect.currentframe()
|
|
common_frame = None
|
|
while current_frame:
|
|
if current_frame in tb_frames:
|
|
common_frame = current_frame # we want to find the last frame in common
|
|
current_frame = current_frame.f_back
|
|
|
|
if not common_frame: # not really useful but safer
|
|
_logger.warning('No common frame found with current stack, displaying full stack')
|
|
tb = initial_tb
|
|
else:
|
|
# remove the tb_frames until the common_frame is reached (keep the current_frame tb since the line is more accurate)
|
|
while tb and tb.tb_frame != common_frame:
|
|
tb = tb.tb_next
|
|
|
|
# add all current frame elements under the common_frame to tb
|
|
current_frame = common_frame.f_back
|
|
while current_frame:
|
|
tb = Traceback(tb, current_frame, current_frame.f_lasti, current_frame.f_lineno)
|
|
current_frame = current_frame.f_back
|
|
|
|
# remove traceback root part (odoo_bin, main, loading, ...), as
|
|
# everything under the testCase is not useful. Using '_callTestMethod',
|
|
# '_callSetUp', '_callTearDown', '_callCleanup' instead of the test
|
|
# method since the error does not comme especially from the test method.
|
|
while tb:
|
|
code = tb.tb_frame.f_code
|
|
if code.co_filename.endswith('/case.py') and code.co_name in ('_callTestMethod', '_callSetUp', '_callTearDown', '_callCleanup'):
|
|
return tb.tb_next
|
|
tb = tb.tb_next
|
|
|
|
_logger.warning('No root frame found, displaying full stacks')
|
|
return initial_tb # this shouldn't be reached
|
|
|
|
|
|
class TestCase(_TestCase):
|
|
_class_cleanups = [] # needed, backport for versions < 3.8
|
|
__unittest_skip__ = False
|
|
__unittest_skip_why__ = ''
|
|
_moduleSetUpFailed = False
|
|
|
|
# pylint: disable=super-init-not-called
|
|
def __init__(self, methodName='runTest'):
|
|
"""Create an instance of the class that will use the named test
|
|
method when executed. Raises a ValueError if the instance does
|
|
not have a method with the specified name.
|
|
"""
|
|
self._testMethodName = methodName
|
|
self._outcome = None
|
|
if methodName != 'runTest' and not hasattr(self, methodName):
|
|
# we allow instantiation with no explicit method name
|
|
# but not an *incorrect* or missing method name
|
|
raise ValueError("no such test method in %s: %s" %
|
|
(self.__class__, methodName))
|
|
self._cleanups = []
|
|
self._subtest = None
|
|
|
|
# Map types to custom assertEqual functions that will compare
|
|
# instances of said type in more detail to generate a more useful
|
|
# error message.
|
|
self._type_equality_funcs = {}
|
|
self.addTypeEqualityFunc(dict, 'assertDictEqual')
|
|
self.addTypeEqualityFunc(list, 'assertListEqual')
|
|
self.addTypeEqualityFunc(tuple, 'assertTupleEqual')
|
|
self.addTypeEqualityFunc(set, 'assertSetEqual')
|
|
self.addTypeEqualityFunc(frozenset, 'assertSetEqual')
|
|
self.addTypeEqualityFunc(str, 'assertMultiLineEqual')
|
|
|
|
def addCleanup(self, function, *args, **kwargs):
|
|
"""Add a function, with arguments, to be called when the test is
|
|
completed. Functions added are called on a LIFO basis and are
|
|
called after tearDown on test failure or success.
|
|
|
|
Cleanup items are called even if setUp fails (unlike tearDown)."""
|
|
self._cleanups.append((function, args, kwargs))
|
|
|
|
@classmethod
|
|
def addClassCleanup(cls, function, *args, **kwargs):
|
|
"""Same as addCleanup, except the cleanup items are called even if
|
|
setUpClass fails (unlike tearDownClass)."""
|
|
cls._class_cleanups.append((function, args, kwargs))
|
|
|
|
def shortDescription(self):
|
|
return None
|
|
|
|
@contextlib.contextmanager
|
|
def subTest(self, msg=_subtest_msg_sentinel, **params):
|
|
"""Return a context manager that will return the enclosed block
|
|
of code in a subtest identified by the optional message and
|
|
keyword parameters. A failure in the subtest marks the test
|
|
case as failed but resumes execution at the end of the enclosed
|
|
block, allowing further test code to be executed.
|
|
"""
|
|
parent = self._subtest
|
|
if parent:
|
|
params = {**params, **{k: v for k, v in parent.params.items() if k not in params}}
|
|
self._subtest = _SubTest(self, msg, params)
|
|
try:
|
|
with self._outcome.testPartExecutor(self._subtest, isTest=True):
|
|
yield
|
|
finally:
|
|
self._subtest = parent
|
|
|
|
def _addError(self, result, test, exc_info):
|
|
"""
|
|
This method is similar to feed_errors_to_result in python<=3.10
|
|
but only manage one error at a time
|
|
This is also inspired from python 3.11 _addError but still manages
|
|
subtests errors as in python 3.7-3.10 for minimal changes.
|
|
The method remains on the test to easily override it in test_test_suite
|
|
|
|
"""
|
|
if isinstance(test, _SubTest):
|
|
result.addSubTest(test.test_case, test, exc_info)
|
|
elif exc_info is not None:
|
|
if issubclass(exc_info[0], self.failureException):
|
|
result.addFailure(test, exc_info)
|
|
else:
|
|
result.addError(test, exc_info)
|
|
|
|
def _callSetUp(self):
|
|
self.setUp()
|
|
|
|
def _callTestMethod(self, method):
|
|
method()
|
|
|
|
def _callTearDown(self):
|
|
self.tearDown()
|
|
|
|
def _callCleanup(self, function, *args, **kwargs):
|
|
function(*args, **kwargs)
|
|
|
|
def run(self, result):
|
|
result.startTest(self)
|
|
|
|
testMethod = getattr(self, self._testMethodName)
|
|
|
|
skip = False
|
|
skip_why = ''
|
|
try:
|
|
skip = self.__class__.__unittest_skip__ or testMethod.__unittest_skip__
|
|
skip_why = self.__class__.__unittest_skip_why__ or testMethod.__unittest_skip_why__ or ''
|
|
except AttributeError: # testMethod may not have a __unittest_skip__ or __unittest_skip_why__
|
|
pass
|
|
if skip:
|
|
result.addSkip(self, skip_why)
|
|
result.stopTest(self)
|
|
return
|
|
|
|
outcome = _Outcome(self, result)
|
|
try:
|
|
self._outcome = outcome
|
|
with outcome.testPartExecutor(self):
|
|
self._callSetUp()
|
|
if outcome.success:
|
|
with outcome.testPartExecutor(self, isTest=True):
|
|
self._callTestMethod(testMethod)
|
|
with outcome.testPartExecutor(self):
|
|
self._callTearDown()
|
|
|
|
self.doCleanups()
|
|
if outcome.success:
|
|
result.addSuccess(self)
|
|
return result
|
|
finally:
|
|
result.stopTest(self)
|
|
|
|
# clear the outcome, no more needed
|
|
self._outcome = None
|
|
|
|
def doCleanups(self):
|
|
"""Execute all cleanup functions. Normally called for you after
|
|
tearDown."""
|
|
|
|
while self._cleanups:
|
|
function, args, kwargs = self._cleanups.pop()
|
|
with self._outcome.testPartExecutor(self):
|
|
self._callCleanup(function, *args, **kwargs)
|
|
|
|
@classmethod
|
|
def doClassCleanups(cls):
|
|
"""Execute all class cleanup functions. Normally called for you after
|
|
tearDownClass."""
|
|
cls.tearDown_exceptions = []
|
|
while cls._class_cleanups:
|
|
function, args, kwargs = cls._class_cleanups.pop()
|
|
try:
|
|
function(*args, **kwargs)
|
|
except Exception:
|
|
cls.tearDown_exceptions.append(sys.exc_info())
|
|
|
|
|
|
class _SubTest(TestCase):
|
|
|
|
def __init__(self, test_case, message, params):
|
|
super().__init__()
|
|
self._message = message
|
|
self.test_case = test_case
|
|
self.params = params
|
|
self.failureException = test_case.failureException
|
|
|
|
def runTest(self):
|
|
raise NotImplementedError("subtests cannot be run directly")
|
|
|
|
def _subDescription(self):
|
|
parts = []
|
|
if self._message is not _subtest_msg_sentinel:
|
|
parts.append("[{}]".format(self._message))
|
|
if self.params:
|
|
params_desc = ', '.join(
|
|
"{}={!r}".format(k, v)
|
|
for (k, v) in self.params.items())
|
|
parts.append("({})".format(params_desc))
|
|
return " ".join(parts) or '(<subtest>)'
|
|
|
|
def id(self):
|
|
return "{} {}".format(self.test_case.id(), self._subDescription())
|
|
|
|
def __str__(self):
|
|
return "{} {}".format(self.test_case, self._subDescription())
|