[FIX] tests: better test traceback
The motivation of this commit is to get a correct pathname on an ir_logging when a test fail . The main issue comes from subtest since an exception inside a subtest will have only a partial traceback, not containing the line triggering the error in the test method. This can also affect debugging since a part of the stack is missing. See pull request for more informations X-original-commit: 2b6a8bc79529578ecd21bbb0fd4fe452f28d7cd7 Part-of: odoo/odoo#108202
This commit is contained in:
+101
-5
@@ -611,7 +611,7 @@ class BaseCase(unittest.TestCase, metaclass=MetaCase):
|
||||
"""
|
||||
if self.warm:
|
||||
# mock random in order to avoid random bus gc
|
||||
with self.subTest(), patch('random.random', lambda: 1):
|
||||
with patch('random.random', lambda: 1):
|
||||
login = self.env.user.login
|
||||
expected = counters.get(login, default)
|
||||
if flush:
|
||||
@@ -630,7 +630,9 @@ class BaseCase(unittest.TestCase, metaclass=MetaCase):
|
||||
filename = filename.rsplit("/odoo/addons/", 1)[1]
|
||||
if count > expected:
|
||||
msg = "Query count more than expected for user %s: %d > %d in %s at %s:%s"
|
||||
self.fail(msg % (login, count, expected, funcname, filename, linenum))
|
||||
# add a subtest in order to continue the test_method in case of failures
|
||||
with self.subTest():
|
||||
self.fail(msg % (login, count, expected, funcname, filename, linenum))
|
||||
else:
|
||||
logger = logging.getLogger(type(self).__module__)
|
||||
msg = "Query count less than expected for user %s: %d < %d in %s at %s:%s"
|
||||
@@ -762,7 +764,6 @@ class BaseCase(unittest.TestCase, metaclass=MetaCase):
|
||||
# Because lxml.attrib is an ordereddict for which order is important
|
||||
# to equality, even though *we* don't care
|
||||
self.assertEqual(dict(n1.attrib), dict(n2.attrib), msg)
|
||||
|
||||
self.assertEqual((n1.text or u'').strip(), (n2.text or u'').strip(), msg)
|
||||
self.assertEqual((n1.tail or u'').strip(), (n2.tail or u'').strip(), msg)
|
||||
|
||||
@@ -802,6 +803,101 @@ class BaseCase(unittest.TestCase, metaclass=MetaCase):
|
||||
profile_session=self.profile_session,
|
||||
**kwargs)
|
||||
|
||||
def _callSetUp(self):
|
||||
# This override is aimed at providing better error logs inside tests.
|
||||
# First, we want errors to be logged whenever they appear instead of
|
||||
# after the test, as the latter makes debugging harder and can even be
|
||||
# confusing in the case of subtests.
|
||||
#
|
||||
# When a subtest is used inside a test, (1) the recovered traceback is
|
||||
# not complete, and (2) the error is delayed to the end of the test
|
||||
# method. There is unfortunately no simple way to hook inside a subtest
|
||||
# to fix this issue. The method TestCase.subTest uses the context
|
||||
# manager _Outcome.testPartExecutor as follows:
|
||||
#
|
||||
# with self._outcome.testPartExecutor(self._subtest, isTest=True):
|
||||
# yield
|
||||
#
|
||||
# This context manager is actually also used for the setup, test method,
|
||||
# teardown, cleanups. If an error occurs during any one of those, it is
|
||||
# simply appended in TestCase._outcome.errors, and the latter is
|
||||
# consumed at the end calling _feedErrorsToResult.
|
||||
#
|
||||
# The TestCase._outcome is set just before calling _callSetUp. This
|
||||
# method is actually executed inside a testPartExecutor. Replacing it
|
||||
# here ensures that all errors will be caught.
|
||||
# See https://github.com/odoo/odoo/pull/107572 for more info.
|
||||
self._outcome.errors = _ErrorCatcher(self)
|
||||
super()._callSetUp()
|
||||
|
||||
|
||||
class _ErrorCatcher(list):
|
||||
""" This extends a list where errors are appended whenever they occur. The
|
||||
purpose of this class is to feed the errors directly to the output, instead
|
||||
of letting them accumulate until the test is over. It also improves the
|
||||
traceback to make it easier to debug.
|
||||
"""
|
||||
__slots__ = ['test']
|
||||
|
||||
def __init__(self, test):
|
||||
super().__init__()
|
||||
self.test = test
|
||||
|
||||
def append(self, error):
|
||||
exc_info = error[1]
|
||||
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._feedErrorsToResult(self.test._outcome.result, [(error[0], exc_info)])
|
||||
|
||||
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 untile 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('/unittest/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
|
||||
|
||||
|
||||
savepoint_seq = itertools.count()
|
||||
|
||||
|
||||
@@ -1908,7 +2004,7 @@ def no_retry(arg):
|
||||
def users(*logins):
|
||||
""" Decorate a method to execute it once for each given user. """
|
||||
@decorator
|
||||
def wrapper(func, *args, **kwargs):
|
||||
def _users(func, *args, **kwargs):
|
||||
self = args[0]
|
||||
old_uid = self.uid
|
||||
try:
|
||||
@@ -1929,7 +2025,7 @@ def users(*logins):
|
||||
finally:
|
||||
self.uid = old_uid
|
||||
|
||||
return wrapper
|
||||
return _users
|
||||
|
||||
|
||||
@decorator
|
||||
|
||||
+32
-7
@@ -1,5 +1,6 @@
|
||||
import collections
|
||||
import contextlib
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
@@ -87,7 +88,7 @@ class OdooTestResult(unittest.result.TestResult):
|
||||
the other parameters.
|
||||
"""
|
||||
test = test or self
|
||||
if isinstance(test, unittest.case._SubTest) and test.test_case:
|
||||
while isinstance(test, unittest.case._SubTest) and test.test_case:
|
||||
test = test.test_case
|
||||
logger = logging.getLogger(test.__module__)
|
||||
try:
|
||||
@@ -223,14 +224,38 @@ class OdooTestResult(unittest.result.TestResult):
|
||||
if not isinstance(test, unittest.TestCase):
|
||||
_logger.warning('%r is not a TestCase' % test)
|
||||
return
|
||||
|
||||
_, _, error_traceback = error
|
||||
|
||||
# move upwards the subtest hierarchy to find the real test
|
||||
while isinstance(test, unittest.case._SubTest) and test.test_case:
|
||||
test = test.test_case
|
||||
|
||||
method_tb = None
|
||||
file_tb = None
|
||||
filename = inspect.getfile(type(test))
|
||||
|
||||
# Note: since _ErrorCatcher was introduced, we could always take the
|
||||
# last frame, keeping the check on the test method for safety.
|
||||
# Fallbacking on file for cleanup file shoud always be correct to a
|
||||
# minimal working version would be
|
||||
#
|
||||
# infos_tb = error_traceback
|
||||
# while infos_tb.tb_next()
|
||||
# infos_tb = infos_tb.tb_next()
|
||||
#
|
||||
while error_traceback:
|
||||
code = error_traceback.tb_frame.f_code
|
||||
if code.co_name == test._testMethodName:
|
||||
lineno = error_traceback.tb_lineno
|
||||
filename = code.co_filename
|
||||
method = test._testMethodName
|
||||
infos = (filename, lineno, method, None)
|
||||
return infos
|
||||
if code.co_name in (test._testMethodName, 'setUp', 'tearDown'):
|
||||
method_tb = error_traceback
|
||||
if code.co_filename == filename:
|
||||
file_tb = error_traceback
|
||||
error_traceback = error_traceback.tb_next
|
||||
|
||||
infos_tb = method_tb or file_tb
|
||||
if infos_tb:
|
||||
code = infos_tb.tb_frame.f_code
|
||||
lineno = infos_tb.tb_lineno
|
||||
filename = code.co_filename
|
||||
method = test._testMethodName
|
||||
return (filename, lineno, method, None)
|
||||
|
||||
Reference in New Issue
Block a user