Skip to content

Commit 1c31ff1

Browse files
committed
Make top-level tests fail when there are failing subtests
1 parent a63b021 commit 1c31ff1

File tree

2 files changed

+193
-80
lines changed

2 files changed

+193
-80
lines changed

src/_pytest/subtests.py

Lines changed: 85 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from collections import defaultdict
56
from collections.abc import Callable
67
from collections.abc import Iterator
78
from collections.abc import Mapping
@@ -13,6 +14,7 @@
1314
import time
1415
from types import TracebackType
1516
from typing import Any
17+
from typing import ClassVar
1618
from typing import TYPE_CHECKING
1719

1820
import pluggy
@@ -33,6 +35,7 @@
3335
from _pytest.reports import TestReport
3436
from _pytest.runner import CallInfo
3537
from _pytest.runner import check_interactive_exception
38+
import pytest
3639

3740

3841
if TYPE_CHECKING:
@@ -172,6 +175,9 @@ def test(subtests):
172175
kwargs,
173176
request=self._request,
174177
suspend_capture_ctx=self._suspend_capture_ctx,
178+
reporter=self._request.config.pluginmanager.getplugin(
179+
SubtestsReporterPlugin.NAME
180+
),
175181
)
176182

177183

@@ -192,6 +198,7 @@ class _SubTestContextManager:
192198
kwargs: dict[str, Any]
193199
suspend_capture_ctx: Callable[[], AbstractContextManager[None]]
194200
request: SubRequest
201+
reporter: SubtestsReporterPlugin
195202

196203
def __enter__(self) -> None:
197204
__tracebackhide__ = True
@@ -243,6 +250,9 @@ def __exit__(
243250
report, SubtestContext(msg=self.msg, kwargs=self.kwargs.copy())
244251
)
245252

253+
if sub_report.failed:
254+
self.reporter.contains_failed_subtests[self.request.node.nodeid] += 1
255+
246256
self._captured_output.update_report(sub_report)
247257
self._captured_logs.update_report(sub_report)
248258

@@ -322,7 +332,9 @@ class CapturedLogs:
322332
handler: LogCaptureHandler
323333

324334
def update_report(self, report: TestReport) -> None:
325-
report.sections.append(("Captured log call", self.handler.stream.getvalue()))
335+
captured_log = self.handler.stream.getvalue()
336+
if captured_log:
337+
report.sections.append(("Captured log call", captured_log))
326338

327339

328340
class NullCapturedLogs:
@@ -342,47 +354,78 @@ def pytest_report_from_serializable(data: dict[str, Any]) -> SubtestReport | Non
342354
return None
343355

344356

345-
@hookimpl(tryfirst=True)
346-
def pytest_report_teststatus(
347-
report: TestReport,
348-
config: Config,
349-
) -> tuple[str, str, str | Mapping[str, bool]] | None:
350-
if report.when != "call" or not isinstance(report, SubtestReport):
351-
return None
357+
def pytest_configure(config: pytest.Config) -> None:
358+
config.pluginmanager.register(SubtestsReporterPlugin(), SubtestsReporterPlugin.NAME)
352359

353-
outcome = report.outcome
354-
description = report._sub_test_description()
355-
no_output = ("", "", "")
356-
357-
if hasattr(report, "wasxfail"):
358-
if config.option.no_subtests_reports and outcome != "skipped":
359-
return no_output
360-
elif outcome == "skipped":
361-
category = "xfailed"
362-
short = "y" # x letter is used for regular xfail, y for subtest xfail
363-
status = "SUBXFAIL"
364-
elif outcome == "passed":
365-
category = "xpassed"
366-
short = "Y" # X letter is used for regular xpass, Y for subtest xpass
367-
status = "SUBXPASS"
368-
else:
369-
# This should not normally happen, unless some plugin is setting wasxfail without
370-
# the correct outcome. Pytest expects the call outcome to be either skipped or passed in case of xfail.
371-
# Let's pass this report to the next hook.
360+
361+
@dataclasses.dataclass()
362+
class SubtestsReporterPlugin:
363+
NAME: ClassVar[str] = "subtests-reporter"
364+
# Tracks node-ids -> number of failed subtests.
365+
contains_failed_subtests: defaultdict[str, int] = dataclasses.field(
366+
default_factory=lambda: defaultdict(lambda: 0)
367+
)
368+
369+
def __hash__(self) -> int:
370+
return id(self)
371+
372+
@hookimpl(tryfirst=True)
373+
def pytest_report_teststatus(
374+
self,
375+
report: TestReport,
376+
config: Config,
377+
) -> tuple[str, str, str | Mapping[str, bool]] | None:
378+
if report.when != "call":
372379
return None
373-
short = "" if config.option.no_subtests_shortletter else short
374-
return f"subtests {category}", short, f"{description} {status}"
375-
376-
if config.option.no_subtests_reports and outcome != "failed":
377-
return no_output
378-
elif report.passed:
379-
short = "" if config.option.no_subtests_shortletter else ","
380-
return f"subtests {outcome}", short, f"{description} SUBPASS"
381-
elif report.skipped:
382-
short = "" if config.option.no_subtests_shortletter else "-"
383-
return outcome, short, f"{description} SUBSKIP"
384-
elif outcome == "failed":
385-
short = "" if config.option.no_subtests_shortletter else "u"
386-
return outcome, short, f"{description} SUBFAIL"
387380

388-
return None
381+
if isinstance(report, SubtestReport):
382+
outcome = report.outcome
383+
description = report._sub_test_description()
384+
no_output = ("", "", "")
385+
386+
if hasattr(report, "wasxfail"):
387+
if config.option.no_subtests_reports and outcome != "skipped":
388+
return no_output
389+
elif outcome == "skipped":
390+
category = "xfailed"
391+
short = (
392+
"y" # x letter is used for regular xfail, y for subtest xfail
393+
)
394+
status = "SUBXFAIL"
395+
elif outcome == "passed":
396+
category = "xpassed"
397+
short = (
398+
"Y" # X letter is used for regular xpass, Y for subtest xpass
399+
)
400+
status = "SUBXPASS"
401+
else:
402+
# This should not normally happen, unless some plugin is setting wasxfail without
403+
# the correct outcome. Pytest expects the call outcome to be either skipped or
404+
# passed in case of xfail.
405+
# Let's pass this report to the next hook.
406+
return None
407+
short = "" if config.option.no_subtests_shortletter else short
408+
return f"subtests {category}", short, f"{description} {status}"
409+
410+
if config.option.no_subtests_reports and outcome != "failed":
411+
return no_output
412+
elif report.passed:
413+
short = "" if config.option.no_subtests_shortletter else ","
414+
return f"subtests {outcome}", short, f"{description} SUBPASSED"
415+
elif report.skipped:
416+
short = "" if config.option.no_subtests_shortletter else "-"
417+
return outcome, short, f"{description} SUBSKIPPED"
418+
elif outcome == "failed":
419+
short = "" if config.option.no_subtests_shortletter else "u"
420+
return outcome, short, f"{description} SUBFAILED"
421+
else:
422+
# Top-level test, fail it it contains failed subtests and it has passed.
423+
if (
424+
report.passed
425+
and (count := self.contains_failed_subtests.get(report.nodeid, 0)) > 0
426+
):
427+
report.outcome = "failed"
428+
suffix = "s" if count > 1 else ""
429+
report.longrepr = f"Contains {count} failed subtest{suffix}"
430+
431+
return None

0 commit comments

Comments
 (0)