[IMP] core: reintroduce test stats

Uses a dedicated logger (for easier filtering / silencing) for
results output, and provides rough (module-level) stats in INFO but
detailed (test-level) in DEBUG.

Also updates the global query counter (`odoo.sql_db.query_counter`) to
update after each query rather than on close: with test cursors the
actual underlying counter is only rarely flushed.

closes odoo/odoo#95420

Signed-off-by: Xavier Morel (xmo) <xmo@odoo.com>
This commit is contained in:
Xavier Morel
2022-07-11 09:04:51 +02:00
parent f76205322e
commit ba37803064
4 changed files with 123 additions and 8 deletions
+1
View File
@@ -1271,6 +1271,7 @@ def preload_registries(dbnames):
time.time() - t0,
odoo.sql_db.sql_counter - t0_sql)
registry._assertion_report.log_stats()
if not registry._assertion_report.wasSuccessful():
rc += 1
except Exception:
+3 -5
View File
@@ -344,6 +344,7 @@ class Cursor(BaseCursor):
return self._obj.mogrify(query, params).decode(encoding, 'replace')
def execute(self, query, params=None, log_exceptions=None):
global sql_counter
if params and not isinstance(params, (tuple, list, dict)):
# psycopg2's TypeError is not clear if you mess up the params
raise ValueError("SQL query parameters should be a tuple, list or dict; got %r" % (params,))
@@ -361,6 +362,8 @@ class Cursor(BaseCursor):
# simple query count is always computed
self.sql_log_count += 1
sql_counter += 1
delay = (real_time() - start)
current_thread = threading.current_thread()
if hasattr(current_thread, 'query_count'):
@@ -437,16 +440,11 @@ class Cursor(BaseCursor):
return self._close(False)
def _close(self, leak=False):
global sql_counter
if not self._obj:
return
del self.cache
# simple query count is always computed
sql_counter += self.sql_log_count
# advanced stats only if sql_log is enabled
self.print_log()
+36 -3
View File
@@ -66,6 +66,7 @@ except ImportError:
# chrome headless tests will be skipped
websocket = None
from .runner import stats_logger
_logger = logging.getLogger(__name__)
@@ -188,9 +189,10 @@ class RecordCapturer:
# ------------------------------------------------------------
# Main classes
# ------------------------------------------------------------
class OdooSuite(unittest.suite.TestSuite):
if sys.version_info < (3, 8):
if sys.version_info >= (3, 8):
BackportSuite = unittest.suite.TestSuite
else:
class BackportSuite(unittest.suite.TestSuite):
# Partial backport of bpo-24412, merged in CPython 3.8
def _handleClassSetUp(self, test, result):
@@ -285,6 +287,37 @@ class OdooSuite(unittest.suite.TestSuite):
className,
info=exc)
class OdooSuite(BackportSuite):
def _handleClassSetUp(self, test, result):
previous_test_class = getattr(result, '_previousTestClass', None)
if not (
previous_test_class != type(test)
and hasattr(result, 'stats')
and stats_logger.isEnabledFor(logging.INFO)
):
super()._handleClassSetUp(test, result)
return
test_class = type(test)
test_id = f'{test_class.__module__}.{test_class.__qualname__}.setUpClass'
with result.collectStats(test_id):
super()._handleClassSetUp(test, result)
def _tearDownPreviousClass(self, test, result):
previous_test_class = getattr(result, '_previousTestClass', None)
if not (
previous_test_class
and previous_test_class != type(test)
and hasattr(result, 'stats')
and stats_logger.isEnabledFor(logging.INFO)
):
super()._tearDownPreviousClass(test, result)
return
test_id = f'{previous_test_class.__module__}.{previous_test_class.__qualname__}.tearDownClass'
with result.collectStats(test_id):
super()._tearDownPreviousClass(test, result)
class MetaCase(type):
""" Metaclass of test case classes to assign default 'test_tags':
+83
View File
@@ -1,12 +1,42 @@
import collections
import contextlib
import logging
import re
import time
import unittest
from typing import NamedTuple
from .. import sql_db
stats_logger = logging.getLogger('odoo.tests.stats')
class Stat(NamedTuple):
time: float = 0.0
queries: int = 0
def __add__(self, other: 'Stat') -> 'Stat':
if other == 0:
return self
if not isinstance(other, Stat):
return NotImplemented
return Stat(
self.time + other.time,
self.queries + other.queries,
)
_logger = logging.getLogger(__name__)
_TEST_ID = re.compile(r"""
^
odoo\.addons\.
(?P<module>[^.]+)
\.tests\.
(?P<class>.+)
\.
(?P<method>[^.]+)
$
""", re.VERBOSE)
class OdooTestResult(unittest.result.TestResult):
"""
This class in inspired from TextTestResult (https://github.com/python/cpython/blob/master/Lib/unittest/runner.py)
@@ -21,6 +51,7 @@ class OdooTestResult(unittest.result.TestResult):
self.queries_start = None
self._soft_fail = False
self.had_failure = False
self.stats = collections.defaultdict(Stat)
def __str__(self):
return f'{len(self.failures)} failed, {len(self.errors)} error(s) of {self.testsRun} tests'
@@ -47,6 +78,7 @@ class OdooTestResult(unittest.result.TestResult):
self.expectedFailures.extend(other.expectedFailures)
self.unexpectedSuccesses.extend(other.unexpectedSuccesses)
self.shouldStop = self.shouldStop or other.shouldStop
self.stats.update(other.stats)
def log(self, level, msg, *args, test=None, exc_info=None, extra=None, stack_info=False, caller_infos=None):
"""
@@ -70,6 +102,37 @@ class OdooTestResult(unittest.result.TestResult):
record = logger.makeRecord(logger.name, level, fn, lno, msg, args, exc_info, func, extra, sinfo)
logger.handle(record)
def log_stats(self):
if not stats_logger.isEnabledFor(logging.INFO):
return
details = stats_logger.isEnabledFor(logging.DEBUG)
stats_tree = collections.defaultdict(Stat)
counts = collections.Counter()
for test, stat in self.stats.items():
r = _TEST_ID.match(test)
if not r: # upgrade has tests at weird paths, ignore them
continue
stats_tree[r['module']] += stat
counts[r['module']] += 1
if details:
stats_tree['%(module)s.%(class)s' % r] += stat
stats_tree['%(module)s.%(class)s.%(method)s' % r] += stat
if details:
stats_logger.debug('Detailed Tests Report:\n%s', ''.join(
f'\t{test}: {stats.time:.2f}s {stats.queries} queries\n'
for test, stats in sorted(stats_tree.items())
))
else:
for module, stat in sorted(stats_tree.items()):
stats_logger.info(
"%s: %d tests %.2fs %d queries",
module, counts[module],
stat.time, stat.queries
)
def getDescription(self, test):
if isinstance(test, unittest.case._SubTest):
return 'Subtest %s.%s %s' % (test.test_case.__class__.__qualname__, test.test_case._testMethodName, test._subDescription())
@@ -85,6 +148,26 @@ class OdooTestResult(unittest.result.TestResult):
self.time_start = time.time()
self.queries_start = sql_db.sql_counter
def stopTest(self, test):
if stats_logger.isEnabledFor(logging.INFO):
self.stats[test.id()] = Stat(
time=time.time() - self.time_start,
queries=sql_db.sql_counter - self.queries_start,
)
super().stopTest(test)
@contextlib.contextmanager
def collectStats(self, test_id):
queries_before = sql_db.sql_counter
time_start = time.time()
yield
self.stats[test_id] += Stat(
time=time.time() - time_start,
queries=sql_db.sql_counter - queries_before,
)
def addError(self, test, err):
if self._soft_fail:
self.had_failure = True