diff --git a/odoo_test_xmlrunner/odoo_tests/loader.py b/odoo_test_xmlrunner/odoo_tests/loader.py index 5ce61cc4e23..5a44d9add53 100644 --- a/odoo_test_xmlrunner/odoo_tests/loader.py +++ b/odoo_test_xmlrunner/odoo_tests/loader.py @@ -1,10 +1,14 @@ +import inspect import os +import re from odoo.tools import config if config["test_enable"]: + from unittest.suite import _ErrorHolder + from xmlrunner import XMLTestRunner - from xmlrunner.result import _XMLTestResult + from xmlrunner.result import _XMLTestResult, failfast from odoo.tests.result import OdooTestResult from odoo.tests.suite import OdooSuite @@ -49,3 +53,81 @@ def update(self, other): unpatched_update(self, other) OdooTestResult.update = update + + _ERROR_HOLDER_PATTERN = re.compile( + r"\((?P[a-zA-Z_][a-zA-Z0-9_.]*)\." + r"(?P[a-zA-Z_][a-zA-Z0-9_]*)\)" + ) + + def _get_error_holder_source(test): + """Return the source file and line for an unittest ``_ErrorHolder``. + + ``_ErrorHolder`` instances are created by :class:`unittest.suite.TestSuite` + when a class-level ``setUpClass`` or ``tearDownClass`` fails. They carry a + description such as ``setUpClass (module.ClassName)`` but have no reference + to the actual class, so :class:`xmlrunner.result._XMLTestResult` cannot + determine their source location and falls back to the previously recorded + file (often belonging to a completely different test). This helper parses the + description and uses :mod:`inspect` to locate the real class. + """ + match = _ERROR_HOLDER_PATTERN.search(str(test)) + if not match: + return None, None + try: + module = __import__( + match.group("module"), fromlist=[match.group("classname")] + ) + test_class = getattr(module, match.group("classname")) + filename = inspect.getsourcefile(test_class) + _, lineno = inspect.getsourcelines(test_class) + return filename, lineno + except Exception: + return None, None + + unpatched_xml_start_test = _XMLTestResult.startTest + + def xml_start_test(self, test): + unpatched_xml_start_test(self, test) + if isinstance(test, _ErrorHolder) or test.__class__.__name__ == "_ErrorHolder": + filename, lineno = _get_error_holder_source(test) + if filename is not None: + self.filename = filename + self.lineno = lineno + + _XMLTestResult.startTest = xml_start_test + + unpatched_xml_add_error = _XMLTestResult.addError + + @failfast + def xml_add_error(self, test, err): + # ``_XMLTestResult.startTest`` is never called for ``_ErrorHolder`` + # instances created when ``setUpClass``/``tearDownClass`` fails, so + # ``self.filename`` retains the value from a previous test and the + # produced ``_TestInfo`` ends up with the wrong source file. Compute + # the real source file from the error holder description before + # delegating to the original implementation, then patch the recorded + # test info. + filename = lineno = None + if getattr(test, "__class__", None).__name__ == "_ErrorHolder": + filename, lineno = _get_error_holder_source(test) + unpatched_xml_add_error(self, test, err) + if filename is not None: + self.errors[-1][0].filename = filename + self.errors[-1][0].lineno = lineno + + _XMLTestResult.addError = xml_add_error + + unpatched_xml_add_failure = _XMLTestResult.addFailure + + @failfast + def xml_add_failure(self, test, err): + # Same fix as ``addError`` for class-level failures. + filename = lineno = None + if getattr(test, "__class__", None).__name__ == "_ErrorHolder": + filename, lineno = _get_error_holder_source(test) + unpatched_xml_add_failure(self, test, err) + if filename is not None: + self.failures[-1][0].filename = filename + self.failures[-1][0].lineno = lineno + + _XMLTestResult.addFailure = xml_add_failure diff --git a/odoo_test_xmlrunner/tests/__init__.py b/odoo_test_xmlrunner/tests/__init__.py new file mode 100644 index 00000000000..7ebaf434909 --- /dev/null +++ b/odoo_test_xmlrunner/tests/__init__.py @@ -0,0 +1 @@ +from . import test_xmlrunner diff --git a/odoo_test_xmlrunner/tests/test_xmlrunner.py b/odoo_test_xmlrunner/tests/test_xmlrunner.py new file mode 100644 index 00000000000..f4af60f8ea0 --- /dev/null +++ b/odoo_test_xmlrunner/tests/test_xmlrunner.py @@ -0,0 +1,111 @@ +# Copyright 2026 Moduon Team SL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import io +import os +import tempfile +import unittest +from unittest.suite import _ErrorHolder + +from xmlrunner import XMLTestRunner + +from odoo.tests.common import BaseCase + +from ..odoo_tests.loader import _get_error_holder_source + + +# Helper classes used by ``test_class_level_error_reported_in_own_file``. +# They are declared at module level so ``_get_error_holder_source`` can +# import them from their description. The ``_`` prefix keeps unittest +# discovery from picking them up as standalone test cases. +class _PassingTest(unittest.TestCase): + __unittest_skip__ = False + + def test_pass(self): + pass + + +class _FailingTest(unittest.TestCase): + __unittest_skip__ = False + + @classmethod + def setUpClass(cls): + super().setUpClass() + raise ValueError("boom") + + def test_method(self): + pass + + +class TestXMLRunnerFix(BaseCase): + """Check that JUnit reports attribute errors to the right file.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._output_dir = tempfile.mkdtemp() + + def test_error_holder_source_lookup(self): + """_get_error_holder_source resolves module.ClassName descriptions.""" + err = _ErrorHolder( + "setUpClass (odoo.addons.odoo_test_xmlrunner.tests.test_xmlrunner" + ".TestXMLRunnerFix)" + ) + filename, lineno = _get_error_holder_source(err) + self.assertTrue(filename.endswith("/test_xmlrunner.py")) + self.assertIsInstance(lineno, int) + return True + + def test_class_level_error_reported_in_own_file(self): + """A failing setUpClass is reported against its own test file.""" + + # Use a plain unittest suite to avoid re-entering the OdooSuite + # monkey patch installed by this addon, which would run an inner + # XMLTestRunner and return a result object lacking the ``update`` + # method used by the outer runner. + suite = unittest.TestSuite( + [_FailingTest("test_method"), _PassingTest("test_pass")] + ) + runner = XMLTestRunner( + output=self._output_dir, + verbosity=0, + stream=io.StringIO(), + ) + result = runner.run(suite) + self.assertEqual(len(result.errors), 1) + # The failing class is the first one executed, so the result object + # did not have a chance to pick up a stale filename from a previous + # test. Verify that the produced XML file still points to this test + # module rather than to ``unittest/suite.py``. + error_holder_files = [ + fn for fn in os.listdir(self._output_dir) if "_ErrorHolder" in fn + ] + self.assertTrue( + error_holder_files, + "Expected an XML file for the _ErrorHolder test case", + ) + xml_path = os.path.join(self._output_dir, error_holder_files[0]) + with open(xml_path) as xml_file: + xml_content = xml_file.read() + # Ensure the test case 'file' attribute points to this module + # rather than the generic 'unittest/suite.py'. + self.assertIn( + 'file="odoo_test_xmlrunner/tests/test_xmlrunner.py"', + xml_content, + ) + # Ensure the test case's name attribute is correctly populated with + # the class name and method, verifying that the _ErrorHolder is + # treated as a legitimate test case. + self.assertIn( + 'testcase classname="" name="setUpClass ' + '(odoo.addons.odoo_test_xmlrunner.tests.test_xmlrunner._FailingTest)"', + xml_content, + ) + return True + + +class TestXMLRunner(BaseCase): + """Backward-compatible class name used by the original upstream tests.""" + + def test_run(self): + """Smoke test that the XML test runner still works.""" + self.assertTrue(True)