Skip to content
This repository was archived by the owner on Aug 24, 2026. It is now read-only.

Commit a69be0c

Browse files
committed
PyTest plugin handles failure in setup e.g. fixtures
Otherwise, these are left without a result or failure reason set on them, and come through as 'unknown'.
1 parent d695ae2 commit a69be0c

2 files changed

Lines changed: 78 additions & 38 deletions

File tree

src/buildkite_test_collector/pytest_plugin/buildkite_plugin.py

Lines changed: 49 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,13 @@ def __init__(self, payload):
1919

2020
def pytest_runtest_logstart(self, nodeid, location):
2121
"""pytest_runtest_logstart hook callback"""
22-
logger.debug('Enter pytest_runtest_logstart for %s', nodeid)
23-
if not self.payload.is_started():
22+
logger.debug('hook=pytest_runtest_logstart nodeid=%s', nodeid)
23+
24+
if self.payload.is_started():
25+
logger.debug('-> already started_at=%s(monotonic)', self.payload.started_at)
26+
else:
2427
self.payload = self.payload.started()
28+
logger.debug('-> started_at=%s(monotonic)', self.payload.started_at)
2529

2630
chunks = nodeid.split("::")
2731

@@ -36,48 +40,26 @@ def pytest_runtest_logstart(self, nodeid, location):
3640

3741
def pytest_runtest_logreport(self, report):
3842
"""pytest_runtest_logreport hook callback to get test outcome after test call"""
43+
logger.debug('hook=pytest_runtest_logreport nodeid=%s when=%s', report.nodeid, report.when)
3944

4045
# This hook is called three times during the lifecycle of a test:
4146
# after the setup phase, the call phase, and the teardown phase.
42-
# Since we want to capture the outcome from the call phase,
43-
# we only proceed when this hook is triggered following the call phase.
47+
# We capture outcomes from the call phase, or setup phase if it failed
48+
# (since setup failures prevent the call phase from running).
4449
# See: https://github.com/buildkite/test-collector-python/pull/45
45-
if report.when != 'call':
46-
return
47-
48-
nodeid = report.nodeid
49-
test_data = self.in_flight.get(nodeid)
50-
logger.debug('Enter pytest_runtest_logreport for %s', nodeid)
51-
52-
if test_data:
53-
if report.passed:
54-
test_data = test_data.passed()
55-
56-
if report.failed:
57-
failure_reason, failure_expanded = failure_reasons(longrepr=report.longrepr)
58-
test_data = test_data.failed(
59-
failure_reason=failure_reason,
60-
failure_expanded=failure_expanded
61-
)
62-
63-
if report.skipped:
64-
test_data = test_data.skipped()
65-
66-
# TestData is immutable.
67-
# We need to replace the test_data in `in_flight` with updated test_data,
68-
# so we can get the correct result when we process it during the teardown hook.
69-
self.in_flight[nodeid] = test_data
50+
if report.when == 'call' or (report.when == 'setup' and report.failed):
51+
self.update_test_result(report)
7052

7153
# This hook only runs in xdist worker thread, not controller thread.
7254
# We used to rely on pytest_runtest_teardown, but somehow xdist will ignore it
7355
# in both controller and worker thread.
7456
def pytest_runtest_makereport(self, item, call):
7557
"""pytest_runtest_hook hook callback to mark test as finished and add it to the payload"""
58+
logger.debug('hook=pytest_runtest_makereport nodeid=%s when=%s', item.nodeid, call.when)
7659

7760
if call.when != 'teardown':
7861
return
7962

80-
logger.debug('Enter pytest_runtest_makereport for %s', item.nodeid)
8163
test_data = self.in_flight.get(item.nodeid)
8264

8365
if test_data:
@@ -97,7 +79,8 @@ def pytest_runtest_makereport(self, item, call):
9779
# In that case, it's necessary for this hook to work as a fallback mechanism.
9880
def pytest_runtest_logfinish(self, nodeid, location): # pylint: disable=unused-argument
9981
"""pytest_runtest_logfinish hook always runs in the very end"""
100-
logger.debug('Enter pytest_runtest_logfinish for %s', nodeid)
82+
logger.debug('hook=pytest_runtest_logfinish nodeid=%s', nodeid)
83+
10184
if self.finalize_test(nodeid):
10285
# This is expected to happen in xdist controller thread.
10386
# Where it would skip many pytest_runtest_xxx hooks
@@ -106,16 +89,44 @@ def pytest_runtest_logfinish(self, nodeid, location): # pylint: disable=unused-
10689
'Falling back to pytest_runtest_logfinish'
10790
)
10891

92+
def update_test_result(self, report):
93+
test_data = self.in_flight.get(report.nodeid)
94+
95+
if test_data:
96+
if report.passed:
97+
logger.debug('-> test passed')
98+
test_data = test_data.passed()
99+
100+
if report.failed:
101+
failure_reason, failure_expanded = failure_reasons(longrepr=report.longrepr)
102+
logger.debug('-> test failed: %s', failure_reason)
103+
test_data = test_data.failed(
104+
failure_reason=failure_reason,
105+
failure_expanded=failure_expanded
106+
)
107+
108+
if report.skipped:
109+
logger.debug('-> test skipped')
110+
test_data = test_data.skipped()
111+
112+
# TestData is immutable.
113+
# We need to replace the test_data in `in_flight` with updated test_data,
114+
# so we can get the correct result when we process it during the teardown hook.
115+
self.in_flight[report.nodeid] = test_data
116+
117+
109118
def finalize_test(self, nodeid):
110119
""" Attempting to move test data for a nodeid to payload area for upload """
111-
logger.debug('Entering finalize_test for %s', nodeid)
120+
logger.debug('-> finalize_test nodeid=%s', nodeid)
112121
test_data = self.in_flight.get(nodeid)
113-
if test_data:
114-
del self.in_flight[nodeid]
115-
test_data = test_data.finish()
116-
self.payload = self.payload.push_test_data(test_data)
117-
return True
118-
return False
122+
if not test_data:
123+
logger.debug('-> finalize_test: not in flight: %s', nodeid)
124+
return False
125+
del self.in_flight[nodeid]
126+
test_data = test_data.finish()
127+
logger.debug('-> finalize_test nodeid=%s duration=%s', nodeid, test_data.history.duration)
128+
self.payload = self.payload.push_test_data(test_data)
129+
return True
119130

120131
def save_payload_as_json(self, path, merge=False):
121132
"""Save payload into a json file, merging with existing data if merge is True"""

tests/buildkite_test_collector/pytest_plugin/test_plugin.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,35 @@ def test_pytest_runtest_logreport_simple_fail(fake_env):
116116
assert isinstance(test_data.result, TestResultFailed)
117117

118118

119+
def test_pytest_runtest_logreport_fail_exception_in_setup(fake_env):
120+
payload = Payload.init(fake_env)
121+
plugin = BuildkitePlugin(payload)
122+
123+
location = ("", None, "")
124+
try:
125+
raise Exception("a fake fixture exception")
126+
except Exception as e:
127+
longrepr = ExceptionInfo.from_exception(e)
128+
report = TestReport(nodeid="", location=location, keywords={}, outcome="failed", longrepr=longrepr, when="setup")
129+
130+
plugin.pytest_runtest_logstart(report.nodeid, location)
131+
plugin.pytest_runtest_logreport(report)
132+
test_data = plugin.in_flight.get(report.nodeid)
133+
plugin.pytest_runtest_logfinish(report.nodeid, location)
134+
135+
assert isinstance(test_data, TestData)
136+
assert isinstance(test_data.result, TestResultFailed)
137+
assert test_data.result.failure_reason == "Exception: a fake fixture exception"
138+
139+
assert isinstance(test_data.result.failure_expanded, list)
140+
fe = test_data.result.failure_expanded[0]
141+
assert list(fe.keys()) == ["expanded", "backtrace"]
142+
assert isinstance(fe["expanded"], list)
143+
assert len(fe["expanded"]) > 0
144+
assert isinstance(fe["backtrace"], list)
145+
assert len(fe["backtrace"]) > 0
146+
147+
119148
def test_pytest_runtest_logreport_simple_skip(fake_env):
120149
payload = Payload.init(fake_env)
121150
plugin = BuildkitePlugin(payload)

0 commit comments

Comments
 (0)