Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[pytest]
filterwarnings =
ignore::pytest.PytestCollectionWarning
norecursedirs=tests/helpers
norecursedirs=tests/helpers tests/buildkite_test_collector/data
70 changes: 70 additions & 0 deletions src/buildkite_test_collector/pytest_plugin/buildkite_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,33 @@
from .logger import logger
from .failure_reasons import failure_reasons


def _is_subtest_report(report):
"""Detect SubtestReport from pytest>=9.0 built-in subtests.

SubtestReport is a TestReport subclass with a ``context`` attribute
containing ``msg`` and ``kwargs``. We use duck-typing so the check
works without importing any specific class, keeping backwards
compatibility with older pytest versions that lack subtests support.
"""
return (
hasattr(report, "context")
and hasattr(report.context, "msg")
and hasattr(report.context, "kwargs")
)


class BuildkitePlugin:
"""Buildkite test collector plugin for Pytest"""

def __init__(self, payload):
self.payload = payload
self.in_flight = {}
self.spans = {}
# Tracks nodeids whose in-flight result was set to failed by a
# SubtestReport. Used to prevent the parent test's "passed"
# call-phase report from overwriting the failure.
self._failed_by_subtest = set()

def pytest_collection_modifyitems(self, config, items):
"""pytest_collection_modifyitems hook callback to filter tests by execution_tag markers"""
Expand Down Expand Up @@ -53,6 +73,39 @@ def pytest_runtest_logreport(self, report):
"""pytest_runtest_logreport hook callback to get test outcome after test call"""
logger.debug('hook=pytest_runtest_logreport nodeid=%s when=%s', report.nodeid, report.when)

# Handle SubtestReport objects (pytest>=9.0 built-in subtests).
#
# SubtestReport shares the parent test's nodeid, so without
# special handling each subtest's result overwrites the previous
# one in self.in_flight — a last-write-wins race. Worse, the
# parent test's own call-phase report arrives with outcome="passed"
# (exceptions inside subtests are swallowed by the context manager)
# and would overwrite any subtest failure.
#
# Strategy: propagate subtest *failures* to the parent's in-flight
# entry. Ignore passing/skipped subtests (they must not overwrite
# a previous failure). Guard against the parent's "passed" report
# overwriting a subtest-induced failure.
if _is_subtest_report(report) and report.when == "call":
if report.failed:
test_data = self.in_flight.get(report.nodeid)
if test_data:
failure_reason, failure_expanded = failure_reasons(
longrepr=report.longrepr
)
logger.debug(
"-> subtest failed, propagating to parent: %s",
failure_reason,
)
self.in_flight[report.nodeid] = test_data.failed(
failure_reason=failure_reason,
failure_expanded=failure_expanded,
)
self._failed_by_subtest.add(report.nodeid)
else:
logger.debug("-> subtest passed/skipped, ignoring")
return

# This hook is called three times during the lifecycle of a test:
# after the setup phase, the call phase, and the teardown phase.
# We capture outcomes from the call phase, or setup/teardown phase if it failed
Expand All @@ -61,6 +114,19 @@ def pytest_runtest_logreport(self, report):
# See: https://github.com/buildkite/test-collector-python/pull/45
# See: https://github.com/buildkite/test-collector-python/issues/84
if report.when == 'call' or (report.when in ('setup', 'teardown') and report.failed):
# Guard: do not let the parent test's "passed" call-phase
# report overwrite a failure that was set by a SubtestReport.
if (
report.when == "call"
and report.passed
and report.nodeid in self._failed_by_subtest
):
logger.debug(
"-> parent call report is 'passed' but subtest(s) failed; "
"preserving subtest failure"
)
return

self.update_test_result(report)

# This hook only runs in xdist worker thread, not controller thread.
Expand Down Expand Up @@ -140,6 +206,10 @@ def finalize_test(self, nodeid):
test_data = test_data.finish()
logger.debug('-> finalize_test nodeid=%s duration=%s', nodeid, test_data.history.duration)
self.payload = self.payload.push_test_data(test_data)

# Clean up subtest tracking state for this test.
self._failed_by_subtest.discard(nodeid)

return True

def save_payload_as_json(self, path, merge=False):
Expand Down
31 changes: 31 additions & 0 deletions tests/buildkite_test_collector/data/test_sample_subtests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Sample test file used by the integration test.

This file exercises pytest>=9.0 built-in subtests and is run in a subprocess
by test_integration_subtests.py to verify the JSON output.
"""


def test_mixed_subtests(subtests):
"""A test where some subtests pass and one fails."""
with subtests.test(msg="passing check 1"):
assert 1 + 1 == 2

with subtests.test(msg="failing check"):
assert 1 + 1 == 3 # noqa: PLR0133 -- intentional failure

with subtests.test(msg="passing check 2"):
assert 2 + 2 == 4


def test_all_subtests_pass(subtests):
"""A test where all subtests pass."""
with subtests.test(msg="alpha"):
assert True

with subtests.test(msg="beta"):
assert True


def test_no_subtests():
"""A plain test with no subtests — should be unaffected."""
assert 42 == 42
Loading