Skip to content

Commit 42f910d

Browse files
committed
Code review
1 parent 889dcd9 commit 42f910d

File tree

6 files changed

+79
-44
lines changed

6 files changed

+79
-44
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 & 29 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,7 +11,9 @@
1011
from contextlib import nullcontext
1112
import dataclasses
1213
import time
14+
from types import TracebackType
1315
from typing import Any
16+
from typing import Literal
1417
from typing import TYPE_CHECKING
1518

1619
import pluggy
@@ -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
@@ -32,8 +36,7 @@
3236

3337

3438
if TYPE_CHECKING:
35-
from types import TracebackType
36-
from typing import Literal
39+
from typing_extensions import Self
3740

3841

3942
def pytest_addoption(parser: Parser) -> None:
@@ -54,24 +57,31 @@ def pytest_addoption(parser: Parser) -> None:
5457
)
5558

5659

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

6164
msg: str | None
62-
kwargs: dict[str, Any]
65+
kwargs: Mapping[str, Any]
66+
67+
def _to_json(self) -> dict[str, Any]:
68+
return dataclasses.asdict(self)
69+
70+
@classmethod
71+
def _from_json(cls, d: dict[str, Any]) -> Self:
72+
return cls(msg=d["msg"], kwargs=d["kwargs"])
6373

6474

6575
@dataclasses.dataclass(init=False)
66-
class SubTestReport(TestReport):
67-
context: SubTestContext
76+
class SubtestReport(TestReport):
77+
context: SubtestContext
6878

6979
@property
7080
def head_line(self) -> str:
7181
_, _, domain = self.location
72-
return f"{domain} {self.sub_test_description()}"
82+
return f"{domain} {self._sub_test_description()}"
7383

74-
def sub_test_description(self) -> str:
84+
def _sub_test_description(self) -> str:
7585
parts = []
7686
if isinstance(self.context.msg, str):
7787
parts.append(f"[{self.context.msg}]")
@@ -86,45 +96,45 @@ def _to_json(self) -> dict[str, Any]:
8696
data = super()._to_json()
8797
del data["context"]
8898
data["_report_type"] = "SubTestReport"
89-
data["_subtest.context"] = dataclasses.asdict(self.context)
99+
data["_subtest.context"] = self.context._to_json()
90100
return data
91101

92102
@classmethod
93-
def _from_json(cls, reportdict: dict[str, Any]) -> SubTestReport:
103+
def _from_json(cls, reportdict: dict[str, Any]) -> SubtestReport:
94104
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-
)
105+
report.context = SubtestContext._from_json(reportdict["_subtest.context"])
99106
return report
100107

101108
@classmethod
102-
def _from_test_report(cls, test_report: TestReport) -> SubTestReport:
109+
def _from_test_report(cls, test_report: TestReport) -> SubtestReport:
103110
return super()._from_json(test_report._to_json())
104111

105112

106113
@fixture
107-
def subtests(request: SubRequest) -> Generator[SubTests, None, None]:
114+
def subtests(request: SubRequest) -> Subtests:
108115
"""Provides subtests functionality."""
109116
capmam = request.node.config.pluginmanager.get_plugin("capturemanager")
110117
if capmam is not None:
111118
suspend_capture_ctx = capmam.global_and_fixture_disabled
112119
else:
113120
suspend_capture_ctx = nullcontext
114-
yield SubTests(request.node.ihook, suspend_capture_ctx, request)
121+
return Subtests(request.node.ihook, suspend_capture_ctx, request, _ispytest=True)
115122

116123

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

122129
def __init__(
123130
self,
124131
ihook: pluggy.HookRelay,
125132
suspend_capture_ctx: Callable[[], AbstractContextManager[None]],
126133
request: SubRequest,
134+
*,
135+
_ispytest: bool = False,
127136
) -> None:
137+
check_ispytest(_ispytest)
128138
self._ihook = ihook
129139
self._suspend_capture_ctx = suspend_capture_ctx
130140
self._request = request
@@ -169,7 +179,7 @@ class _SubTestContextManager:
169179
Context manager for subtests, capturing exceptions raised inside the subtest scope and handling
170180
them through the pytest machinery.
171181
172-
Note: initially this logic was implemented directly in SubTests.test() as a @contextmanager, however
182+
Note: initially this logic was implemented directly in Subtests.test() as a @contextmanager, however
173183
it is not possible to control the output fully when exiting from it due to an exception when
174184
in --exitfirst mode, so this was refactored into an explicit context manager class (pytest-dev/pytest-subtests#134).
175185
"""
@@ -220,8 +230,8 @@ def __exit__(
220230
report = self.ihook.pytest_runtest_makereport(
221231
item=self.request.node, call=call_info
222232
)
223-
sub_report = SubTestReport._from_test_report(report)
224-
sub_report.context = SubTestContext(self.msg, self.kwargs.copy())
233+
sub_report = SubtestReport._from_test_report(report)
234+
sub_report.context = SubtestContext(msg=self.msg, kwargs=self.kwargs.copy())
225235

226236
self._captured_output.update_report(sub_report)
227237
self._captured_logs.update_report(sub_report)
@@ -330,14 +340,14 @@ def update_report(self, report: TestReport) -> None:
330340

331341

332342
def pytest_report_to_serializable(report: TestReport) -> dict[str, Any] | None:
333-
if isinstance(report, SubTestReport):
343+
if isinstance(report, SubtestReport):
334344
return report._to_json()
335345
return None
336346

337347

338-
def pytest_report_from_serializable(data: dict[str, Any]) -> SubTestReport | None:
348+
def pytest_report_from_serializable(data: dict[str, Any]) -> SubtestReport | None:
339349
if data.get("_report_type") == "SubTestReport":
340-
return SubTestReport._from_json(data)
350+
return SubtestReport._from_json(data)
341351
return None
342352

343353

@@ -346,11 +356,11 @@ def pytest_report_teststatus(
346356
report: TestReport,
347357
config: Config,
348358
) -> tuple[str, str, str | Mapping[str, bool]] | None:
349-
if report.when != "call" or not isinstance(report, SubTestReport):
359+
if report.when != "call" or not isinstance(report, SubtestReport):
350360
return None
351361

352362
outcome = report.outcome
353-
description = report.sub_test_description()
363+
description = report._sub_test_description()
354364
no_output = ("", "", "")
355365

356366
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)