[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:
Xavier-Do
2022-12-16 19:37:52 +01:00
parent 5c69db2220
commit e583a1f8f4
3 changed files with 572 additions and 12 deletions
+101 -5
View File
@@ -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
View File
@@ -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)