Skip to content

Commit c287dd7

Browse files
authored
Ensure subtest's context kwargs are JSON serializable (#13963)
Convert all the values of `SubtestContext.kwargs` to strings using `saferepr`. This complies with the requirement that the returned dict from `pytest_report_to_serializable` is serializable to JSON, at the cost of losing type information for objects that are natively supported by JSON. Fixes pytest-dev/pytest-xdist#1273
1 parent 2fb64da commit c287dd7

3 files changed

Lines changed: 66 additions & 15 deletions

File tree

changelog/13963.bugfix.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fixed subtests running with `pytest-xdist <https://github.com/pytest-dev/pytest>`__ when their contexts contain objects that are not JSON-serializable.
2+
3+
Fixes `pytest-dev/pytest-xdist#1273 <https://github.com/pytest-dev/pytest-xdist/issues/1273>`__.

src/_pytest/subtests.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,15 @@ class SubtestContext:
6161
msg: str | None
6262
kwargs: Mapping[str, Any]
6363

64+
def __post_init__(self) -> None:
65+
# Brute-force the returned kwargs dict to be JSON serializable (pytest-dev/pytest-xdist#1273).
66+
object.__setattr__(
67+
self, "kwargs", {k: saferepr(v) for (k, v) in self.kwargs.items()}
68+
)
69+
6470
def _to_json(self) -> dict[str, Any]:
65-
return dataclasses.asdict(self)
71+
result = dataclasses.asdict(self)
72+
return result
6673

6774
@classmethod
6875
def _from_json(cls, d: dict[str, Any]) -> Self:

testing/test_subtests.py

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from __future__ import annotations
22

3+
from enum import Enum
4+
import json
35
import sys
46
from typing import Literal
57

8+
from _pytest._io.saferepr import saferepr
69
from _pytest.subtests import SubtestContext
710
from _pytest.subtests import SubtestReport
811
import pytest
@@ -302,10 +305,10 @@ def test_foo(subtests, x):
302305
result = pytester.runpytest("-v")
303306
result.stdout.fnmatch_lines(
304307
[
305-
"*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i=1) *[[] 50%[]]",
306-
"*.py::test_foo[[]0[]] FAILED *[[] 50%[]]",
307-
"*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i=1) *[[]100%[]]",
308-
"*.py::test_foo[[]1[]] FAILED *[[]100%[]]",
308+
"*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i='1') *[[] 50%[]]",
309+
"*.py::test_foo[[]0[]] FAILED *[[] 50%[]]",
310+
"*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i='1') *[[]100%[]]",
311+
"*.py::test_foo[[]1[]] FAILED *[[]100%[]]",
309312
"contains 1 failed subtest",
310313
"* 4 failed, 4 subtests passed in *",
311314
]
@@ -320,10 +323,10 @@ def test_foo(subtests, x):
320323
result = pytester.runpytest("-v")
321324
result.stdout.fnmatch_lines(
322325
[
323-
"*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i=1) *[[] 50%[]]",
324-
"*.py::test_foo[[]0[]] FAILED *[[] 50%[]]",
325-
"*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i=1) *[[]100%[]]",
326-
"*.py::test_foo[[]1[]] FAILED *[[]100%[]]",
326+
"*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i='1') *[[] 50%[]]",
327+
"*.py::test_foo[[]0[]] FAILED *[[] 50%[]]",
328+
"*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i='1') *[[]100%[]]",
329+
"*.py::test_foo[[]1[]] FAILED *[[]100%[]]",
327330
"contains 1 failed subtest",
328331
"* 4 failed in *",
329332
]
@@ -650,12 +653,12 @@ def test_capturing(self, pytester: pytest.Pytester, mode: str) -> None:
650653
result = pytester.runpytest(f"--capture={mode}")
651654
result.stdout.fnmatch_lines(
652655
[
653-
"*__ test (i='A') __*",
656+
"*__ test (i=\"'A'\") __*",
654657
"*Captured stdout call*",
655658
"hello stdout A",
656659
"*Captured stderr call*",
657660
"hello stderr A",
658-
"*__ test (i='B') __*",
661+
"*__ test (i=\"'B'\") __*",
659662
"*Captured stdout call*",
660663
"hello stdout B",
661664
"*Captured stderr call*",
@@ -676,8 +679,8 @@ def test_no_capture(self, pytester: pytest.Pytester) -> None:
676679
"hello stdout A",
677680
"uhello stdout B",
678681
"uend test",
679-
"*__ test (i='A') __*",
680-
"*__ test (i='B') __*",
682+
"*__ test (i=\"'A'\") __*",
683+
"*__ test (i=\"'B'\") __*",
681684
"*__ test __*",
682685
]
683686
)
@@ -957,7 +960,14 @@ def test(subtests):
957960
)
958961

959962

963+
class MyEnum(Enum):
964+
"""Used in test_serialization, needs to be declared at the module level to be pickled."""
965+
966+
A = "A"
967+
968+
960969
def test_serialization() -> None:
970+
"""Ensure subtest's kwargs are serialized using `saferepr` (pytest-dev/pytest-xdist#1273)."""
961971
from _pytest.subtests import pytest_report_from_serializable
962972
from _pytest.subtests import pytest_report_to_serializable
963973

@@ -968,10 +978,41 @@ def test_serialization() -> None:
968978
outcome="passed",
969979
when="call",
970980
longrepr=None,
971-
context=SubtestContext(msg="custom message", kwargs=dict(i=10)),
981+
context=SubtestContext(msg="custom message", kwargs=dict(i=10, a=MyEnum.A)),
972982
)
973983
data = pytest_report_to_serializable(report)
974984
assert data is not None
985+
# Ensure the report is actually serializable to JSON.
986+
_ = json.dumps(data)
975987
new_report = pytest_report_from_serializable(data)
976988
assert new_report is not None
977-
assert new_report.context == SubtestContext(msg="custom message", kwargs=dict(i=10))
989+
assert new_report.context == SubtestContext(
990+
msg="custom message", kwargs=dict(i=saferepr(10), a=saferepr(MyEnum.A))
991+
)
992+
993+
994+
def test_serialization_xdist(pytester: pytest.Pytester) -> None: # pragma: no cover
995+
"""Regression test for pytest-dev/pytest-xdist#1273."""
996+
pytest.importorskip("xdist")
997+
pytester.makepyfile(
998+
"""
999+
from enum import Enum
1000+
import unittest
1001+
1002+
class MyEnum(Enum):
1003+
A = "A"
1004+
1005+
def test(subtests):
1006+
with subtests.test(a=MyEnum.A):
1007+
pass
1008+
1009+
class T(unittest.TestCase):
1010+
1011+
def test(self):
1012+
with self.subTest(a=MyEnum.A):
1013+
pass
1014+
"""
1015+
)
1016+
pytester.syspathinsert()
1017+
result = pytester.runpytest("-n1", "-pxdist.plugin")
1018+
result.assert_outcomes(passed=2)

0 commit comments

Comments
 (0)