Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 83 additions & 1 deletion odoo_test_xmlrunner/odoo_tests/loader.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -49,3 +53,81 @@ def update(self, other):
unpatched_update(self, other)

OdooTestResult.update = update

_ERROR_HOLDER_PATTERN = re.compile(
r"\((?P<module>[a-zA-Z_][a-zA-Z0-9_.]*)\."
r"(?P<classname>[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
1 change: 1 addition & 0 deletions odoo_test_xmlrunner/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_xmlrunner
111 changes: 111 additions & 0 deletions odoo_test_xmlrunner/tests/test_xmlrunner.py
Original file line number Diff line number Diff line change
@@ -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)
Loading