diff --git a/changelog/5786.bugfix.rst b/changelog/5786.bugfix.rst new file mode 100644 index 000000000..70754c901 --- /dev/null +++ b/changelog/5786.bugfix.rst @@ -0,0 +1,2 @@ +Chained exceptions in test and collection reports are now correctly serialized, allowing plugins like +``pytest-xdist`` to display them properly. diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index b87277c33..56aea248d 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -3,6 +3,7 @@ from typing import Optional import py +from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo from _pytest._code.code import ReprEntry from _pytest._code.code import ReprEntryNative @@ -160,7 +161,7 @@ class BaseReport: Experimental method. """ - return _test_report_to_json(self) + return _report_to_json(self) @classmethod def _from_json(cls, reportdict): @@ -172,7 +173,7 @@ class BaseReport: Experimental method. """ - kwargs = _test_report_kwargs_from_json(reportdict) + kwargs = _report_kwargs_from_json(reportdict) return cls(**kwargs) @@ -340,7 +341,7 @@ def pytest_report_from_serializable(data): ) -def _test_report_to_json(test_report): +def _report_to_json(report): """ This was originally the serialize_report() function from xdist (ca03269). @@ -366,22 +367,35 @@ def _test_report_to_json(test_report): return reprcrash.__dict__.copy() def serialize_longrepr(rep): - return { + result = { "reprcrash": serialize_repr_crash(rep.longrepr.reprcrash), "reprtraceback": serialize_repr_traceback(rep.longrepr.reprtraceback), "sections": rep.longrepr.sections, } - - d = test_report.__dict__.copy() - if hasattr(test_report.longrepr, "toterminal"): - if hasattr(test_report.longrepr, "reprtraceback") and hasattr( - test_report.longrepr, "reprcrash" - ): - d["longrepr"] = serialize_longrepr(test_report) + if isinstance(rep.longrepr, ExceptionChainRepr): + result["chain"] = [] + for repr_traceback, repr_crash, description in rep.longrepr.chain: + result["chain"].append( + ( + serialize_repr_traceback(repr_traceback), + serialize_repr_crash(repr_crash), + description, + ) + ) else: - d["longrepr"] = str(test_report.longrepr) + result["chain"] = None + return result + + d = report.__dict__.copy() + if hasattr(report.longrepr, "toterminal"): + if hasattr(report.longrepr, "reprtraceback") and hasattr( + report.longrepr, "reprcrash" + ): + d["longrepr"] = serialize_longrepr(report) + else: + d["longrepr"] = str(report.longrepr) else: - d["longrepr"] = test_report.longrepr + d["longrepr"] = report.longrepr for name in d: if isinstance(d[name], (py.path.local, Path)): d[name] = str(d[name]) @@ -390,12 +404,11 @@ def _test_report_to_json(test_report): return d -def _test_report_kwargs_from_json(reportdict): +def _report_kwargs_from_json(reportdict): """ This was originally the serialize_report() function from xdist (ca03269). - Factory method that returns either a TestReport or CollectReport, depending on the calling - class. It's the callers responsibility to know which class to pass here. + Returns **kwargs that can be used to construct a TestReport or CollectReport instance. """ def deserialize_repr_entry(entry_data): @@ -439,12 +452,26 @@ def _test_report_kwargs_from_json(reportdict): and "reprcrash" in reportdict["longrepr"] and "reprtraceback" in reportdict["longrepr"] ): - exception_info = ReprExceptionInfo( - reprtraceback=deserialize_repr_traceback( - reportdict["longrepr"]["reprtraceback"] - ), - reprcrash=deserialize_repr_crash(reportdict["longrepr"]["reprcrash"]), + + reprtraceback = deserialize_repr_traceback( + reportdict["longrepr"]["reprtraceback"] ) + reprcrash = deserialize_repr_crash(reportdict["longrepr"]["reprcrash"]) + if reportdict["longrepr"]["chain"]: + chain = [] + for repr_traceback_data, repr_crash_data, description in reportdict[ + "longrepr" + ]["chain"]: + chain.append( + ( + deserialize_repr_traceback(repr_traceback_data), + deserialize_repr_crash(repr_crash_data), + description, + ) + ) + exception_info = ExceptionChainRepr(chain) + else: + exception_info = ReprExceptionInfo(reprtraceback, reprcrash) for section in reportdict["longrepr"]["sections"]: exception_info.addsection(*section) diff --git a/testing/test_reports.py b/testing/test_reports.py index b8b1a5406..8bac0243a 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -1,4 +1,5 @@ import pytest +from _pytest._code.code import ExceptionChainRepr from _pytest.pathlib import Path from _pytest.reports import CollectReport from _pytest.reports import TestReport @@ -220,8 +221,8 @@ class TestReportSerialization: assert data["path1"] == str(testdir.tmpdir) assert data["path2"] == str(testdir.tmpdir) - def test_unserialization_failure(self, testdir): - """Check handling of failure during unserialization of report types.""" + def test_deserialization_failure(self, testdir): + """Check handling of failure during deserialization of report types.""" testdir.makepyfile( """ def test_a(): @@ -242,6 +243,75 @@ class TestReportSerialization: ): TestReport._from_json(data) + @pytest.mark.parametrize("report_class", [TestReport, CollectReport]) + def test_chained_exceptions(self, testdir, tw_mock, report_class): + """Check serialization/deserialization of report objects containing chained exceptions (#5786)""" + testdir.makepyfile( + """ + def foo(): + raise ValueError('value error') + def test_a(): + try: + foo() + except ValueError as e: + raise RuntimeError('runtime error') from e + if {error_during_import}: + test_a() + """.format( + error_during_import=report_class is CollectReport + ) + ) + + reprec = testdir.inline_run() + if report_class is TestReport: + reports = reprec.getreports("pytest_runtest_logreport") + # we have 3 reports: setup/call/teardown + assert len(reports) == 3 + # get the call report + report = reports[1] + else: + assert report_class is CollectReport + # two collection reports: session and test file + reports = reprec.getreports("pytest_collectreport") + assert len(reports) == 2 + report = reports[1] + + def check_longrepr(longrepr): + """Check the attributes of the given longrepr object according to the test file. + + We can get away with testing both CollectReport and TestReport with this function because + the longrepr objects are very similar. + """ + assert isinstance(longrepr, ExceptionChainRepr) + assert longrepr.sections == [("title", "contents", "=")] + assert len(longrepr.chain) == 2 + entry1, entry2 = longrepr.chain + tb1, fileloc1, desc1 = entry1 + tb2, fileloc2, desc2 = entry2 + + assert "ValueError('value error')" in str(tb1) + assert "RuntimeError('runtime error')" in str(tb2) + + assert ( + desc1 + == "The above exception was the direct cause of the following exception:" + ) + assert desc2 is None + + assert report.failed + assert len(report.sections) == 0 + report.longrepr.addsection("title", "contents", "=") + check_longrepr(report.longrepr) + + data = report._to_json() + loaded_report = report_class._from_json(data) + check_longrepr(loaded_report.longrepr) + + # make sure we don't blow up on ``toterminal`` call; we don't test the actual output because it is very + # brittle and hard to maintain, but we can assume it is correct because ``toterminal`` is already tested + # elsewhere and we do check the contents of the longrepr object after loading it. + loaded_report.longrepr.toterminal(tw_mock) + class TestHooks: """Test that the hooks are working correctly for plugins"""