From ec8a64a85c7ce751bbd4fc3b5a2a8224a84c81d0 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 27 Jul 2020 12:09:50 +0000 Subject: [PATCH] [REF] core: move testing-related functions to odoo/tests submodules Attempts to clean up odoo/module and odoo/service a tad, they still invoke testing-related utilities but are more logical in what they *contain*. --- odoo/modules/loading.py | 8 +- odoo/modules/module.py | 223 --------------------------- odoo/service/server.py | 10 +- odoo/tests/loader.py | 122 +++++++++++++++ odoo/tests/runner.py | 116 ++++++++++++++ odoo/tests/test_module_operations.py | 6 +- 6 files changed, 250 insertions(+), 235 deletions(-) create mode 100644 odoo/tests/loader.py create mode 100644 odoo/tests/runner.py diff --git a/odoo/modules/loading.py b/odoo/modules/loading.py index 03c04003275..a3adfe8492b 100644 --- a/odoo/modules/loading.py +++ b/odoo/modules/loading.py @@ -16,10 +16,8 @@ import odoo.modules.db import odoo.modules.graph import odoo.modules.migration import odoo.modules.registry -import odoo.tools as tools - -from odoo import api, SUPERUSER_ID -from odoo.modules.module import adapt_version, initialize_sys_path, load_openerp_module +from .. import SUPERUSER_ID, api, tools +from .module import adapt_version, initialize_sys_path, load_openerp_module _logger = logging.getLogger(__name__) _test_logger = logging.getLogger('odoo.tests') @@ -271,7 +269,7 @@ def load_module_graph(cr, graph, status=None, perform_checks=True, report.record_result(load_test(idref, mode)) # Python tests env['ir.http']._clear_routing_map() # force routing map to be rebuilt - report.record_result(odoo.modules.module.run_unit_tests(module_name)) + report.record_result(odoo.tests.loader.run_unit_tests(module_name)) # tests may have reset the environment env = api.Environment(cr, SUPERUSER_ID, {}) module = env['ir.module.module'].browse(module_id) diff --git a/odoo/modules/module.py b/odoo/modules/module.py index 4859de455ff..6b7065a05ad 100644 --- a/odoo/modules/module.py +++ b/odoo/modules/module.py @@ -4,16 +4,11 @@ import ast import collections.abc import importlib -import inspect -import itertools import logging import os import pkg_resources import re import sys -import time -import unittest -import threading import warnings from os.path import join as opj @@ -419,222 +414,4 @@ def adapt_version(version): version = '%s.%s' % (serie, version) return version -def get_test_modules(module): - """ Return a list of module for the addons potentially containing tests to - feed unittest.TestLoader.loadTestsFromModule() """ - # Try to import the module - results = _get_tests_modules('odoo.addons', module) - - try: - importlib.import_module('odoo.upgrade.%s' % module) - except ImportError: - pass - else: - results += _get_tests_modules('odoo.upgrade', module) - - return results - -def _get_tests_modules(path, module): - modpath = '%s.%s' % (path, module) - try: - mod = importlib.import_module('.tests', modpath) - except ImportError as e: # will also catch subclass ModuleNotFoundError of P3.6 - # Hide ImportErrors on `tests` sub-module, but display other exceptions - if e.name == modpath + '.tests' and e.msg.startswith('No module named'): - return [] - _logger.exception('Can not `import %s`.', module) - return [] - except Exception as e: - _logger.exception('Can not `import %s`.', module) - return [] - if hasattr(mod, 'fast_suite') or hasattr(mod, 'checks'): - _logger.warning( - "Found deprecated fast_suite or checks attribute in test module " - "%s. These have no effect in or after version 8.0.", - mod.__name__) - - result = [mod_obj for name, mod_obj in inspect.getmembers(mod, inspect.ismodule) - if name.startswith('test_')] - return result - - -class OdooTestResult(unittest.result.TestResult): - """ - This class in inspired from TextTestResult (https://github.com/python/cpython/blob/master/Lib/unittest/runner.py) - Instead of using a stream, we are using the logger, - but replacing the "findCaller" in order to give the information we - have based on the test object that is running. - """ - - def log(self, level, msg, *args, test=None, exc_info=None, extra=None, stack_info=False, caller_infos=None): - """ - ``test`` is the running test case, ``caller_infos`` is - (fn, lno, func, sinfo) (logger.findCaller format), see logger.log for - the other parameters. - """ - test = test or self - if isinstance(test, unittest.case._SubTest) and test.test_case: - test = test.test_case - logger = logging.getLogger(test.__module__) - try: - caller_infos = caller_infos or logger.findCaller(stack_info) - except ValueError: - caller_infos = "(unknown file)", 0, "(unknown function)", None - (fn, lno, func, sinfo) = caller_infos - # using logger.log makes it difficult to spot-replace findCaller in - # order to provide useful location information (the problematic spot - # inside the test function), so use lower-level functions instead - if logger.isEnabledFor(level): - record = logger.makeRecord(logger.name, level, fn, lno, msg, args, exc_info, func, extra, sinfo) - logger.handle(record) - - def getDescription(self, test): - if isinstance(test, unittest.case._SubTest): - return 'Subtest %s' % test._subDescription() - if isinstance(test, unittest.TestCase): - # since we have the module name in the logger, this will avoid to duplicate module info in log line - # we only apply this for TestCase since we can receive error handler or other special case - return "%s.%s" % (test.__class__.__qualname__, test._testMethodName) - return str(test) - - def startTest(self, test): - super().startTest(test) - self.log(logging.INFO, 'Starting %s ...', self.getDescription(test), test=test) - - def addError(self, test, err): - super().addError(test, err) - self.logError("ERROR", test, err) - - def addFailure(self, test, err): - super().addFailure(test, err) - self.logError("FAIL", test, err) - - def addSubTest(self, test, subtest, err): - # since addSubTest is not making a call to addFailure or addError we need to manage it too - # https://github.com/python/cpython/blob/3.7/Lib/unittest/result.py#L136 - if err is not None: - if issubclass(err[0], test.failureException): - flavour = "FAIL" - else: - flavour = "ERROR" - self.logError(flavour, subtest, err) - super().addSubTest(test, subtest, err) - - def addSkip(self, test, reason): - super().addSkip(test, reason) - self.log(logging.INFO, 'skipped %s', self.getDescription(test), test=test) - - def addUnexpectedSuccess(self, test): - super().addUnexpectedSuccess(test) - self.log(logging.ERROR, 'unexpected success for %s', self.getDescription(test), test=test) - - def logError(self, flavour, test, error): - err = self._exc_info_to_string(error, test) - caller_infos = self.getErrorCallerInfo(error, test) - self.log(logging.INFO, '=' * 70, test=test, caller_infos=caller_infos) # keep this as info !!!!!! - self.log(logging.ERROR, "%s: %s\n%s", flavour, self.getDescription(test), err, test=test, caller_infos=caller_infos) - - def getErrorCallerInfo(self, error, test): - """ - :param error: A tuple (exctype, value, tb) as returned by sys.exc_info(). - :param test: A TestCase that created this error. - :returns: a tuple (fn, lno, func, sinfo) matching the logger findCaller format or None - """ - - # only test case should be executed in odoo, this is only a safe guard - if isinstance(test, unittest.suite._ErrorHolder): - return - if not isinstance(test, unittest.TestCase): - _logger.warning('%r is not a TestCase' % test) - return - _, _, error_traceback = error - - 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 - error_traceback = error_traceback.tb_next - - -class OdooTestRunner(object): - """A test runner class that displays results in in logger using OdooTestResult. - Simplified verison of TextTestRunner - """ - - def run(self, test): - result = OdooTestResult() - test(result) - return result - current_test = None -def run_unit_tests(module_name, position='at_install'): - """ - :returns: ``True`` if all of ``module_name``'s tests succeeded, ``False`` - if any of them failed, ``None`` if no tests were ran. - :rtype: bool | None - """ - global current_test - # avoid dependency hell - from odoo.tests.common import TagsSelector, OdooSuite - current_test = module_name - mods = get_test_modules(module_name) - threading.currentThread().testing = True - config_tags = TagsSelector(tools.config['test_tags']) - position_tag = TagsSelector(position) - ran_tests = failures = False - for m in mods: - tests = unwrap_suite(unittest.TestLoader().loadTestsFromModule(m)) - suite = OdooSuite(t for t in tests if position_tag.check(t) and config_tags.check(t)) - - if suite.countTestCases(): - t0 = time.time() - t0_sql = odoo.sql_db.sql_counter - _logger.info('%s running tests.', m.__name__) - result = OdooTestRunner().run(suite) - log_level = logging.INFO - if time.time() - t0 > 5: - log_level = logging.RUNBOT - _logger.log(log_level, "%s ran %s tests in %.2fs, %s queries", m.__name__, result.testsRun, time.time() - t0, odoo.sql_db.sql_counter - t0_sql) - ran_tests = True - if not result.wasSuccessful(): - failures = True - _logger.error("Module %s: %d failures, %d errors", module_name, len(result.failures), len(result.errors)) - - current_test = None - threading.currentThread().testing = False - - if failures: - return False - if ran_tests: - return True - return None - -def unwrap_suite(test): - """ - Attempts to unpack testsuites (holding suites or cases) in order to - generate a single stream of terminals (either test cases or customized - test suites). These can then be checked for run/skip attributes - individually. - - An alternative would be to use a variant of @unittest.skipIf with a state - flag of some sort e.g. @unittest.skipIf(common.runstate != 'at_install'), - but then things become weird with post_install as tests should *not* run - by default there - """ - if isinstance(test, unittest.TestCase): - yield test - return - - subtests = list(test) - # custom test suite (no test cases) - if not len(subtests): - yield test - return - - for item in itertools.chain.from_iterable( - unwrap_suite(t) for t in subtests): - yield item diff --git a/odoo/service/server.py b/odoo/service/server.py index 87e9b8ddf42..3f9d7376901 100644 --- a/odoo/service/server.py +++ b/odoo/service/server.py @@ -53,11 +53,11 @@ except ImportError: import odoo from odoo.modules import get_modules -from odoo.modules.module import run_unit_tests, get_test_modules from odoo.modules.registry import Registry from odoo.release import nt_service_name from odoo.tools import config from odoo.tools import stripped_sys_argv, dumpstacks, log_ormcache_stats +from ..tests import loader, runner _logger = logging.getLogger(__name__) @@ -1171,14 +1171,14 @@ def load_test_file_py(registry, test_file): try: test_path, _ = os.path.splitext(os.path.abspath(test_file)) for mod in [m for m in get_modules() if '/%s/' % m in test_file]: - for mod_mod in get_test_modules(mod): + for mod_mod in loader.get_test_modules(mod): mod_path, _ = os.path.splitext(getattr(mod_mod, '__file__', '')) if test_path == mod_path: - tests = odoo.modules.module.unwrap_suite( + tests = loader.unwrap_suite( unittest.TestLoader().loadTestsFromModule(mod_mod)) suite = OdooSuite(tests) _logger.log(logging.INFO, 'running tests %s.', mod_mod.__name__) - result = odoo.modules.module.OdooTestRunner().run(suite) + result = runner.OdooTestRunner().run(suite) success = result.wasSuccessful() if hasattr(registry._assertion_report,'report_result'): registry._assertion_report.report_result(success) @@ -1219,7 +1219,7 @@ def preload_registries(dbnames): _logger.info("Starting post tests") with odoo.api.Environment.manage(): for module_name in module_names: - result = run_unit_tests(module_name, position='post_install') + result = loader.run_unit_tests(module_name, position='post_install') registry._assertion_report.record_result(result) _logger.info("All post-tested in %.2fs, %s queries", time.time() - t0, odoo.sql_db.sql_counter - t0_sql) diff --git a/odoo/tests/loader.py b/odoo/tests/loader.py new file mode 100644 index 00000000000..c2cc53452fa --- /dev/null +++ b/odoo/tests/loader.py @@ -0,0 +1,122 @@ +import importlib +import inspect +import itertools +import logging +import threading +import time +import unittest + +import odoo +from .. import tools +from .common import TagsSelector, OdooSuite +from .runner import OdooTestRunner + +# backwards compatibility +_logger = logging.getLogger('odoo.modules.module') + +def get_test_modules(module): + """ Return a list of module for the addons potentially containing tests to + feed unittest.TestLoader.loadTestsFromModule() """ + # Try to import the module + results = _get_tests_modules('odoo.addons', module) + + try: + importlib.import_module('odoo.upgrade.%s' % module) + except ImportError: + pass + else: + results += _get_tests_modules('odoo.upgrade', module) + + return results + + +def _get_tests_modules(path, module): + modpath = '%s.%s' % (path, module) + try: + mod = importlib.import_module('.tests', modpath) + except ImportError as e: # will also catch subclass ModuleNotFoundError of P3.6 + # Hide ImportErrors on `tests` sub-module, but display other exceptions + if e.name == modpath + '.tests' and e.msg.startswith('No module named'): + return [] + _logger.exception('Can not `import %s`.', module) + return [] + except Exception as e: + _logger.exception('Can not `import %s`.', module) + return [] + if hasattr(mod, 'fast_suite') or hasattr(mod, 'checks'): + _logger.warning( + "Found deprecated fast_suite or checks attribute in test module " + "%s. These have no effect in or after version 8.0.", + mod.__name__) + + result = [mod_obj for name, mod_obj in inspect.getmembers(mod, inspect.ismodule) + if name.startswith('test_')] + return result + + +def run_unit_tests(module_name, position='at_install'): + """ + :returns: ``True`` if all of ``module_name``'s tests succeeded, ``False`` + if any of them failed, ``None`` if no tests were ran. + :rtype: bool | None + """ + from ..modules import module + # avoid dependency hell + module.current_test = module_name + mods = get_test_modules(module_name) + threading.currentThread().testing = True + config_tags = TagsSelector(tools.config['test_tags']) + position_tag = TagsSelector(position) + ran_tests = failures = False + for m in mods: + tests = unwrap_suite(unittest.TestLoader().loadTestsFromModule(m)) + suite = OdooSuite(t for t in tests if position_tag.check(t) and config_tags.check(t)) + + if suite.countTestCases(): + t0 = time.time() + t0_sql = odoo.sql_db.sql_counter + _logger.info('%s running tests.', m.__name__) + result = OdooTestRunner().run(suite) + log_level = logging.INFO + if time.time() - t0 > 5: + log_level = logging.RUNBOT + _logger.log(log_level, "%s ran %s tests in %.2fs, %s queries", m.__name__, result.testsRun, time.time() - t0, odoo.sql_db.sql_counter - t0_sql) + ran_tests = True + if not result.wasSuccessful(): + failures = True + _logger.error("Module %s: %d failures, %d errors", module_name, len(result.failures), len(result.errors)) + + module.current_test = None + threading.currentThread().testing = False + + if failures: + return False + if ran_tests: + return True + return None + + +def unwrap_suite(test): + """ + Attempts to unpack testsuites (holding suites or cases) in order to + generate a single stream of terminals (either test cases or customized + test suites). These can then be checked for run/skip attributes + individually. + + An alternative would be to use a variant of @unittest.skipIf with a state + flag of some sort e.g. @unittest.skipIf(common.runstate != 'at_install'), + but then things become weird with post_install as tests should *not* run + by default there + """ + if isinstance(test, unittest.TestCase): + yield test + return + + subtests = list(test) + # custom test suite (no test cases) + if not len(subtests): + yield test + return + + for item in itertools.chain.from_iterable(unwrap_suite(t) for t in subtests): + yield item diff --git a/odoo/tests/runner.py b/odoo/tests/runner.py new file mode 100644 index 00000000000..23e285005bd --- /dev/null +++ b/odoo/tests/runner.py @@ -0,0 +1,116 @@ +import logging +import unittest + +# backwards compatibility +_logger = logging.getLogger('odoo.modules.module') +class OdooTestResult(unittest.result.TestResult): + """ + This class in inspired from TextTestResult (https://github.com/python/cpython/blob/master/Lib/unittest/runner.py) + Instead of using a stream, we are using the logger, + but replacing the "findCaller" in order to give the information we + have based on the test object that is running. + """ + + def log(self, level, msg, *args, test=None, exc_info=None, extra=None, stack_info=False, caller_infos=None): + """ + ``test`` is the running test case, ``caller_infos`` is + (fn, lno, func, sinfo) (logger.findCaller format), see logger.log for + the other parameters. + """ + test = test or self + if isinstance(test, unittest.case._SubTest) and test.test_case: + test = test.test_case + logger = logging.getLogger(test.__module__) + try: + caller_infos = caller_infos or logger.findCaller(stack_info) + except ValueError: + caller_infos = "(unknown file)", 0, "(unknown function)", None + (fn, lno, func, sinfo) = caller_infos + # using logger.log makes it difficult to spot-replace findCaller in + # order to provide useful location information (the problematic spot + # inside the test function), so use lower-level functions instead + if logger.isEnabledFor(level): + record = logger.makeRecord(logger.name, level, fn, lno, msg, args, exc_info, func, extra, sinfo) + logger.handle(record) + + def getDescription(self, test): + if isinstance(test, unittest.case._SubTest): + return 'Subtest %s' % test._subDescription() + if isinstance(test, unittest.TestCase): + # since we have the module name in the logger, this will avoid to duplicate module info in log line + # we only apply this for TestCase since we can receive error handler or other special case + return "%s.%s" % (test.__class__.__qualname__, test._testMethodName) + return str(test) + + def startTest(self, test): + super().startTest(test) + self.log(logging.INFO, 'Starting %s ...', self.getDescription(test), test=test) + + def addError(self, test, err): + super().addError(test, err) + self.logError("ERROR", test, err) + + def addFailure(self, test, err): + super().addFailure(test, err) + self.logError("FAIL", test, err) + + def addSubTest(self, test, subtest, err): + # since addSubTest is not making a call to addFailure or addError we need to manage it too + # https://github.com/python/cpython/blob/3.7/Lib/unittest/result.py#L136 + if err is not None: + if issubclass(err[0], test.failureException): + flavour = "FAIL" + else: + flavour = "ERROR" + self.logError(flavour, subtest, err) + super().addSubTest(test, subtest, err) + + def addSkip(self, test, reason): + super().addSkip(test, reason) + self.log(logging.INFO, 'skipped %s', self.getDescription(test), test=test) + + def addUnexpectedSuccess(self, test): + super().addUnexpectedSuccess(test) + self.log(logging.ERROR, 'unexpected success for %s', self.getDescription(test), test=test) + + def logError(self, flavour, test, error): + err = self._exc_info_to_string(error, test) + caller_infos = self.getErrorCallerInfo(error, test) + self.log(logging.INFO, '=' * 70, test=test, caller_infos=caller_infos) # keep this as info !!!!!! + self.log(logging.ERROR, "%s: %s\n%s", flavour, self.getDescription(test), err, test=test, caller_infos=caller_infos) + + def getErrorCallerInfo(self, error, test): + """ + :param error: A tuple (exctype, value, tb) as returned by sys.exc_info(). + :param test: A TestCase that created this error. + :returns: a tuple (fn, lno, func, sinfo) matching the logger findCaller format or None + """ + + # only test case should be executed in odoo, this is only a safe guard + if isinstance(test, unittest.suite._ErrorHolder): + return + if not isinstance(test, unittest.TestCase): + _logger.warning('%r is not a TestCase' % test) + return + _, _, error_traceback = error + + 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 + error_traceback = error_traceback.tb_next + + +class OdooTestRunner(object): + """A test runner class that displays results in in logger using OdooTestResult. + Simplified verison of TextTestRunner + """ + + def run(self, test): + result = OdooTestResult() + test(result) + return result diff --git a/odoo/tests/test_module_operations.py b/odoo/tests/test_module_operations.py index 1ce9d4e3226..fca6dc274c4 100755 --- a/odoo/tests/test_module_operations.py +++ b/odoo/tests/test_module_operations.py @@ -5,14 +5,16 @@ import os import sys import time + sys.path.append(os.path.abspath(os.path.join(__file__,'../../../'))) import odoo from odoo.tools import topological_sort, unique from odoo.netsvc import init_logger from odoo.tests import standalone_tests +import odoo.tests.loader -_logger = logging.getLogger('test_module_operations') +_logger = logging.getLogger('odoo.tests.test_module_operations') BLACKLIST = { 'auth_ldap', 'document_ftp', 'base_gengo', 'website_gengo', 'website_instantclick', 'pad', @@ -119,7 +121,7 @@ def test_scripts(args): registry = odoo.registry(args.database) for module_name in registry._init_modules: # import tests for loaded modules - odoo.modules.module.get_test_modules(module_name) + odoo.tests.loader.get_test_modules(module_name) # fetch and filter scripts to test funcs = list(unique(