Skip to content

Commit 506c75b

Browse files
committed
Code review
1 parent 889dcd9 commit 506c75b

File tree

6 files changed

+79
-48
lines changed

6 files changed

+79
-48
lines changed

changelog/1367.feature.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
**Support for subtests** has been added.
22

3-
:ref:`subtests <subtests>` are an alternative to parametrization, useful in situations where test setup is expensive or the parametrization values are not all known at collection time.
3+
:ref:`subtests <subtests>` are an alternative to parametrization, useful in situations where the parametrization values are not all known at collection time.
44

55
**Example**
66

doc/en/how-to/subtests.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ How to use subtests
1111

1212
pytest allows for grouping assertions within a normal test, known as *subtests*.
1313

14-
Subtests are an alternative to parametrization, particularly useful when test setup is expensive or when the exact parametrization values are not known at collection time.
14+
Subtests are an alternative to parametrization, particularly useful when the exact parametrization values are not known at collection time.
1515

1616

1717
.. code-block:: python
@@ -55,11 +55,11 @@ outside the ``subtests.test`` block:
5555
Typing
5656
------
5757

58-
:class:`pytest.SubTests` is exported so it can be used in type annotations:
58+
:class:`pytest.Subtests` is exported so it can be used in type annotations:
5959

6060
.. code-block:: python
6161
62-
def test(subtests: pytest.SubTests) -> None: ...
62+
def test(subtests: pytest.Subtests) -> None: ...
6363
6464
.. _parametrize_vs_subtests:
6565

src/_pytest/subtests.py

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
"""Builtin plugin that adds subtests support."""
2+
13
from __future__ import annotations
24

35
from collections.abc import Callable
4-
from collections.abc import Generator
56
from collections.abc import Iterator
67
from collections.abc import Mapping
78
from contextlib import AbstractContextManager
@@ -10,10 +11,12 @@
1011
from contextlib import nullcontext
1112
import dataclasses
1213
import time
14+
from types import TracebackType
1315
from typing import Any
14-
from typing import TYPE_CHECKING
16+
from typing import Literal
1517

1618
import pluggy
19+
from typing_extensions import Self
1720

1821
from _pytest._code import ExceptionInfo
1922
from _pytest.capture import CaptureFixture
@@ -22,6 +25,7 @@
2225
from _pytest.config import Config
2326
from _pytest.config import hookimpl
2427
from _pytest.config.argparsing import Parser
28+
from _pytest.deprecated import check_ispytest
2529
from _pytest.fixtures import fixture
2630
from _pytest.fixtures import SubRequest
2731
from _pytest.logging import catching_logs
@@ -31,11 +35,6 @@
3135
from _pytest.runner import check_interactive_exception
3236

3337

34-
if TYPE_CHECKING:
35-
from types import TracebackType
36-
from typing import Literal
37-
38-
3938
def pytest_addoption(parser: Parser) -> None:
4039
group = parser.getgroup("subtests")
4140
group.addoption(
@@ -54,24 +53,31 @@ def pytest_addoption(parser: Parser) -> None:
5453
)
5554

5655

57-
@dataclasses.dataclass
58-
class SubTestContext:
59-
"""The values passed to SubTests.test() that are included in the test report."""
56+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
57+
class SubtestContext:
58+
"""The values passed to Subtests.test() that are included in the test report."""
6059

6160
msg: str | None
62-
kwargs: dict[str, Any]
61+
kwargs: Mapping[str, Any]
62+
63+
def _to_json(self) -> dict[str, Any]:
64+
return dataclasses.asdict(self)
65+
66+
@classmethod
67+
def _from_json(cls, d: dict[str, Any]) -> Self:
68+
return cls(msg=d["msg"], kwargs=d["kwargs"])
6369

6470

6571
@dataclasses.dataclass(init=False)
66-
class SubTestReport(TestReport):
67-
context: SubTestContext
72+
class SubtestReport(TestReport):
73+
context: SubtestContext
6874

6975
@property
7076
def head_line(self) -> str:
7177
_, _, domain = self.location
72-
return f"{domain} {self.sub_test_description()}"
78+
return f"{domain} {self._sub_test_description()}"
7379

74-
def sub_test_description(self) -> str:
80+
def _sub_test_description(self) -> str:
7581
parts = []
7682
if isinstance(self.context.msg, str):
7783
parts.append(f"[{self.context.msg}]")
@@ -86,45 +92,45 @@ def _to_json(self) -> dict[str, Any]:
8692
data = super()._to_json()
8793
del data["context"]
8894
data["_report_type"] = "SubTestReport"
89-
data["_subtest.context"] = dataclasses.asdict(self.context)
95+
data["_subtest.context"] = self.context._to_json()
9096
return data
9197

9298
@classmethod
93-
def _from_json(cls, reportdict: dict[str, Any]) -> SubTestReport:
99+
def _from_json(cls, reportdict: dict[str, Any]) -> SubtestReport:
94100
report = super()._from_json(reportdict)
95-
context_data = reportdict["_subtest.context"]
96-
report.context = SubTestContext(
97-
msg=context_data["msg"], kwargs=context_data["kwargs"]
98-
)
101+
report.context = SubtestContext._from_json(reportdict["_subtest.context"])
99102
return report
100103

101104
@classmethod
102-
def _from_test_report(cls, test_report: TestReport) -> SubTestReport:
105+
def _from_test_report(cls, test_report: TestReport) -> SubtestReport:
103106
return super()._from_json(test_report._to_json())
104107

105108

106109
@fixture
107-
def subtests(request: SubRequest) -> Generator[SubTests, None, None]:
110+
def subtests(request: SubRequest) -> Subtests:
108111
"""Provides subtests functionality."""
109112
capmam = request.node.config.pluginmanager.get_plugin("capturemanager")
110113
if capmam is not None:
111114
suspend_capture_ctx = capmam.global_and_fixture_disabled
112115
else:
113116
suspend_capture_ctx = nullcontext
114-
yield SubTests(request.node.ihook, suspend_capture_ctx, request)
117+
return Subtests(request.node.ihook, suspend_capture_ctx, request, _ispytest=True)
115118

116119

117120
# Note: cannot use a dataclass here because Sphinx insists on showing up the __init__ method in the documentation,
118121
# even if we explicitly use :exclude-members: __init__.
119-
class SubTests:
122+
class Subtests:
120123
"""Subtests fixture, enables declaring subtests inside test functions via the :meth:`test` method."""
121124

122125
def __init__(
123126
self,
124127
ihook: pluggy.HookRelay,
125128
suspend_capture_ctx: Callable[[], AbstractContextManager[None]],
126129
request: SubRequest,
130+
*,
131+
_ispytest: bool = False,
127132
) -> None:
133+
check_ispytest(_ispytest)
128134
self._ihook = ihook
129135
self._suspend_capture_ctx = suspend_capture_ctx
130136
self._request = request
@@ -169,7 +175,7 @@ class _SubTestContextManager:
169175
Context manager for subtests, capturing exceptions raised inside the subtest scope and handling
170176
them through the pytest machinery.
171177
172-
Note: initially this logic was implemented directly in SubTests.test() as a @contextmanager, however
178+
Note: initially this logic was implemented directly in Subtests.test() as a @contextmanager, however
173179
it is not possible to control the output fully when exiting from it due to an exception when
174180
in --exitfirst mode, so this was refactored into an explicit context manager class (pytest-dev/pytest-subtests#134).
175181
"""
@@ -220,8 +226,8 @@ def __exit__(
220226
report = self.ihook.pytest_runtest_makereport(
221227
item=self.request.node, call=call_info
222228
)
223-
sub_report = SubTestReport._from_test_report(report)
224-
sub_report.context = SubTestContext(self.msg, self.kwargs.copy())
229+
sub_report = SubtestReport._from_test_report(report)
230+
sub_report.context = SubtestContext(msg=self.msg, kwargs=self.kwargs.copy())
225231

226232
self._captured_output.update_report(sub_report)
227233
self._captured_logs.update_report(sub_report)
@@ -330,14 +336,14 @@ def update_report(self, report: TestReport) -> None:
330336

331337

332338
def pytest_report_to_serializable(report: TestReport) -> dict[str, Any] | None:
333-
if isinstance(report, SubTestReport):
339+
if isinstance(report, SubtestReport):
334340
return report._to_json()
335341
return None
336342

337343

338-
def pytest_report_from_serializable(data: dict[str, Any]) -> SubTestReport | None:
344+
def pytest_report_from_serializable(data: dict[str, Any]) -> SubtestReport | None:
339345
if data.get("_report_type") == "SubTestReport":
340-
return SubTestReport._from_json(data)
346+
return SubtestReport._from_json(data)
341347
return None
342348

343349

@@ -346,11 +352,11 @@ def pytest_report_teststatus(
346352
report: TestReport,
347353
config: Config,
348354
) -> tuple[str, str, str | Mapping[str, bool]] | None:
349-
if report.when != "call" or not isinstance(report, SubTestReport):
355+
if report.when != "call" or not isinstance(report, SubtestReport):
350356
return None
351357

352358
outcome = report.outcome
353-
description = report.sub_test_description()
359+
description = report._sub_test_description()
354360
no_output = ("", "", "")
355361

356362
if hasattr(report, "wasxfail"):

src/_pytest/unittest.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@
3636
from _pytest.runner import CallInfo
3737
from _pytest.runner import check_interactive_exception
3838
from _pytest.subtests import make_call_info
39-
from _pytest.subtests import SubTestContext
40-
from _pytest.subtests import SubTestReport
39+
from _pytest.subtests import SubtestContext
40+
from _pytest.subtests import SubtestReport
4141

4242

4343
if sys.version_info[:2] < (3, 11):
@@ -428,8 +428,8 @@ def addSubTest(
428428
)
429429
msg = test._message if isinstance(test._message, str) else None # type: ignore[attr-defined]
430430
report = self.ihook.pytest_runtest_makereport(item=self, call=call_info)
431-
sub_report = SubTestReport._from_test_report(report)
432-
sub_report.context = SubTestContext(msg, dict(test.params)) # type: ignore[attr-defined]
431+
sub_report = SubtestReport._from_test_report(report)
432+
sub_report.context = SubtestContext(msg=msg, kwargs=dict(test.params)) # type: ignore[attr-defined]
433433
self.ihook.pytest_runtest_logreport(report=sub_report)
434434
if check_interactive_exception(call_info, sub_report):
435435
self.ihook.pytest_exception_interact(

src/pytest/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
from _pytest.runner import CallInfo
7272
from _pytest.stash import Stash
7373
from _pytest.stash import StashKey
74-
from _pytest.subtests import SubTests
74+
from _pytest.subtests import Subtests
7575
from _pytest.terminal import TerminalReporter
7676
from _pytest.terminal import TestShortLogReport
7777
from _pytest.tmpdir import TempPathFactory
@@ -149,7 +149,7 @@
149149
"Session",
150150
"Stash",
151151
"StashKey",
152-
"SubTests",
152+
"Subtests",
153153
"TempPathFactory",
154154
"TempdirFactory",
155155
"TerminalReporter",

testing/test_subtests.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,10 @@ def test_typing_exported(
138138
) -> None:
139139
pytester.makepyfile(
140140
"""
141-
from pytest import SubTests
141+
from pytest import Subtests
142142
143-
def test_typing_exported(subtests: SubTests) -> None:
144-
assert isinstance(subtests, SubTests)
143+
def test_typing_exported(subtests: Subtests) -> None:
144+
assert isinstance(subtests, Subtests)
145145
"""
146146
)
147147
if mode == "normal":
@@ -212,8 +212,33 @@ def test_foo(subtests):
212212
)
213213

214214

215-
class TestSubTest:
216-
"""Test.subTest functionality."""
215+
def test_subtests_and_parametrization(pytester: pytest.Pytester) -> None:
216+
pytester.makepyfile(
217+
"""
218+
import pytest
219+
220+
@pytest.mark.parametrize("x", [0, 1])
221+
def test_foo(subtests, x):
222+
for i in range(3):
223+
with subtests.test(msg="custom", i=i):
224+
assert i % 2 == 0
225+
assert x == 0
226+
"""
227+
)
228+
result = pytester.runpytest("-v", "--no-subtests-reports")
229+
result.stdout.fnmatch_lines(
230+
[
231+
"test_subtests_and_parametrization.py::test_foo[[]0[]] [[]custom[]] (i=1) SUBFAIL*[[] 50%[]]",
232+
"test_subtests_and_parametrization.py::test_foo[[]0[]] PASSED *[[] 50%[]]",
233+
"test_subtests_and_parametrization.py::test_foo[[]1[]] [[]custom[]] (i=1) SUBFAIL *[[]100%[]]",
234+
"test_subtests_and_parametrization.py::test_foo[[]1[]] FAILED *[[]100%[]]",
235+
"* 3 failed, 1 passed in *",
236+
]
237+
)
238+
239+
240+
class TestUnittestSubTest:
241+
"""Test unittest.TestCase.subTest functionality."""
217242

218243
@pytest.fixture
219244
def simple_script(self, pytester: pytest.Pytester) -> Path:

0 commit comments

Comments
 (0)