diff --git a/changelog/13963.bugfix.rst b/changelog/13963.bugfix.rst new file mode 100644 index 00000000000..a5f7ebe5c03 --- /dev/null +++ b/changelog/13963.bugfix.rst @@ -0,0 +1,3 @@ +Fixed subtests running with `pytest-xdist `__ when their contexts contain objects that are not JSON-serializable. + +Fixes `pytest-dev/pytest-xdist#1273 `__. diff --git a/src/_pytest/subtests.py b/src/_pytest/subtests.py index a96b11f1fe4..4856f72b9ff 100644 --- a/src/_pytest/subtests.py +++ b/src/_pytest/subtests.py @@ -61,8 +61,15 @@ class SubtestContext: msg: str | None kwargs: Mapping[str, Any] + def __post_init__(self) -> None: + # Brute-force the returned kwargs dict to be JSON serializable (pytest-dev/pytest-xdist#1273). + object.__setattr__( + self, "kwargs", {k: saferepr(v) for (k, v) in self.kwargs.items()} + ) + def _to_json(self) -> dict[str, Any]: - return dataclasses.asdict(self) + result = dataclasses.asdict(self) + return result @classmethod def _from_json(cls, d: dict[str, Any]) -> Self: diff --git a/testing/test_subtests.py b/testing/test_subtests.py index 6849df53622..c480bb01658 100644 --- a/testing/test_subtests.py +++ b/testing/test_subtests.py @@ -1,8 +1,11 @@ from __future__ import annotations +from enum import Enum +import json import sys from typing import Literal +from _pytest._io.saferepr import saferepr from _pytest.subtests import SubtestContext from _pytest.subtests import SubtestReport import pytest @@ -302,10 +305,10 @@ def test_foo(subtests, x): result = pytester.runpytest("-v") result.stdout.fnmatch_lines( [ - "*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i=1) *[[] 50%[]]", - "*.py::test_foo[[]0[]] FAILED *[[] 50%[]]", - "*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i=1) *[[]100%[]]", - "*.py::test_foo[[]1[]] FAILED *[[]100%[]]", + "*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i='1') *[[] 50%[]]", + "*.py::test_foo[[]0[]] FAILED *[[] 50%[]]", + "*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i='1') *[[]100%[]]", + "*.py::test_foo[[]1[]] FAILED *[[]100%[]]", "contains 1 failed subtest", "* 4 failed, 4 subtests passed in *", ] @@ -320,10 +323,10 @@ def test_foo(subtests, x): result = pytester.runpytest("-v") result.stdout.fnmatch_lines( [ - "*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i=1) *[[] 50%[]]", - "*.py::test_foo[[]0[]] FAILED *[[] 50%[]]", - "*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i=1) *[[]100%[]]", - "*.py::test_foo[[]1[]] FAILED *[[]100%[]]", + "*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i='1') *[[] 50%[]]", + "*.py::test_foo[[]0[]] FAILED *[[] 50%[]]", + "*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i='1') *[[]100%[]]", + "*.py::test_foo[[]1[]] FAILED *[[]100%[]]", "contains 1 failed subtest", "* 4 failed in *", ] @@ -650,12 +653,12 @@ def test_capturing(self, pytester: pytest.Pytester, mode: str) -> None: result = pytester.runpytest(f"--capture={mode}") result.stdout.fnmatch_lines( [ - "*__ test (i='A') __*", + "*__ test (i=\"'A'\") __*", "*Captured stdout call*", "hello stdout A", "*Captured stderr call*", "hello stderr A", - "*__ test (i='B') __*", + "*__ test (i=\"'B'\") __*", "*Captured stdout call*", "hello stdout B", "*Captured stderr call*", @@ -676,8 +679,8 @@ def test_no_capture(self, pytester: pytest.Pytester) -> None: "hello stdout A", "uhello stdout B", "uend test", - "*__ test (i='A') __*", - "*__ test (i='B') __*", + "*__ test (i=\"'A'\") __*", + "*__ test (i=\"'B'\") __*", "*__ test __*", ] ) @@ -957,7 +960,14 @@ def test(subtests): ) +class MyEnum(Enum): + """Used in test_serialization, needs to be declared at the module level to be pickled.""" + + A = "A" + + def test_serialization() -> None: + """Ensure subtest's kwargs are serialized using `saferepr` (pytest-dev/pytest-xdist#1273).""" from _pytest.subtests import pytest_report_from_serializable from _pytest.subtests import pytest_report_to_serializable @@ -968,10 +978,41 @@ def test_serialization() -> None: outcome="passed", when="call", longrepr=None, - context=SubtestContext(msg="custom message", kwargs=dict(i=10)), + context=SubtestContext(msg="custom message", kwargs=dict(i=10, a=MyEnum.A)), ) data = pytest_report_to_serializable(report) assert data is not None + # Ensure the report is actually serializable to JSON. + _ = json.dumps(data) new_report = pytest_report_from_serializable(data) assert new_report is not None - assert new_report.context == SubtestContext(msg="custom message", kwargs=dict(i=10)) + assert new_report.context == SubtestContext( + msg="custom message", kwargs=dict(i=saferepr(10), a=saferepr(MyEnum.A)) + ) + + +def test_serialization_xdist(pytester: pytest.Pytester) -> None: # pragma: no cover + """Regression test for pytest-dev/pytest-xdist#1273.""" + pytest.importorskip("xdist") + pytester.makepyfile( + """ + from enum import Enum + import unittest + + class MyEnum(Enum): + A = "A" + + def test(subtests): + with subtests.test(a=MyEnum.A): + pass + + class T(unittest.TestCase): + + def test(self): + with self.subTest(a=MyEnum.A): + pass + """ + ) + pytester.syspathinsert() + result = pytester.runpytest("-n1", "-pxdist.plugin") + result.assert_outcomes(passed=2)