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

Commit d695ae2

Browse files
committed
Support failure_expanded
Currently, the entire failure from PyTest (PyTest calls it longreprtext) is set as failure_reason. However, failure_reason is intended for a short one-line summary, and gets truncated to ~1KIB, which isn't great for longreprtext. Buildkite also supports failure_expanded, to included detailed information and backtraces on errors. So, derive that from the various shapes that longrepr can take, and include them in the uploaded JSON payload.
1 parent 5716341 commit d695ae2

6 files changed

Lines changed: 215 additions & 7 deletions

File tree

src/buildkite_test_collector/collector/payload.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Buildkite Test Analytics payload"""
22

33
from dataclasses import dataclass, replace, field
4-
from typing import Dict, Tuple, Optional, Union, Literal, List
4+
from typing import Dict, Tuple, Optional, Union, Literal, List, Iterable, Mapping
55
from datetime import timedelta
66
from uuid import UUID
77

@@ -25,6 +25,7 @@ class TestResultPassed:
2525
class TestResultFailed:
2626
"""Represents a failed test result"""
2727
failure_reason: Optional[str]
28+
failure_expanded: Optional[Iterable[Mapping[str, Iterable[str]]]] = None
2829

2930

3031
@dataclass(frozen=True)
@@ -162,9 +163,10 @@ def passed(self) -> 'TestData':
162163
"""Mark this test as passed"""
163164
return replace(self, result=TestResultPassed())
164165

165-
def failed(self, failure_reason=None) -> 'TestData':
166+
def failed(self, failure_reason=None, failure_expanded=None) -> 'TestData':
166167
"""Mark this test as failed"""
167-
return replace(self, result=TestResultFailed(failure_reason=failure_reason))
168+
result = TestResultFailed(failure_reason=failure_reason, failure_expanded=failure_expanded)
169+
return replace(self, result=result)
168170

169171
def skipped(self) -> 'TestData':
170172
"""Mark this test as skipped"""
@@ -199,6 +201,8 @@ def as_json(self, started_at: Instant) -> JsonDict:
199201
attrs["result"] = "failed"
200202
if self.result.failure_reason is not None:
201203
attrs["failure_reason"] = self.result.failure_reason
204+
if self.result.failure_expanded is not None:
205+
attrs["failure_expanded"] = self.result.failure_expanded
202206

203207
if isinstance(self.result, TestResultSkipped):
204208
attrs["result"] = "skipped"

src/buildkite_test_collector/pytest_plugin/buildkite_plugin.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from ..collector.payload import TestData
99
from .logger import logger
10+
from .failure_reasons import failure_reasons
1011

1112
class BuildkitePlugin:
1213
"""Buildkite test collector plugin for Pytest"""
@@ -53,7 +54,11 @@ def pytest_runtest_logreport(self, report):
5354
test_data = test_data.passed()
5455

5556
if report.failed:
56-
test_data = test_data.failed(report.longreprtext)
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+
)
5762

5863
if report.skipped:
5964
test_data = test_data.skipped()
@@ -103,6 +108,7 @@ def pytest_runtest_logfinish(self, nodeid, location): # pylint: disable=unused-
103108

104109
def finalize_test(self, nodeid):
105110
""" Attempting to move test data for a nodeid to payload area for upload """
111+
logger.debug('Entering finalize_test for %s', nodeid)
106112
test_data = self.in_flight.get(nodeid)
107113
if test_data:
108114
del self.in_flight[nodeid]
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Buildkite Test Engine PyTest failure reason mapping"""
2+
3+
from typing import Iterable, Mapping
4+
5+
# importing these privates isn't ideal, but we're only using them for type checking
6+
from _pytest._code.code import ExceptionInfo, ExceptionRepr, TerminalRepr
7+
8+
# pylint: disable=too-many-locals
9+
# pylint: disable=too-many-return-statements
10+
def failure_reasons(
11+
longrepr: None | ExceptionInfo[BaseException] | tuple[str, int, str] | str | TerminalRepr
12+
) -> tuple[str | None, Iterable[Mapping[str, Iterable[str]]] | None]:
13+
"""
14+
Derives Buildkite's failure_reason & failure_expanded from PyTest's longrepr.
15+
16+
Args:
17+
longrepr: The PyTest longrepr object containing failure information
18+
19+
Returns:
20+
A tuple containing:
21+
- A string with the failure reason or None if not available
22+
- A list of mappings with additional failure details or None if not available
23+
"""
24+
match longrepr:
25+
case None:
26+
return None, None
27+
28+
case str() as s:
29+
lines = s.splitlines()
30+
failure_reason = lines[0] if lines else s
31+
return failure_reason, [{"expanded": lines[1:]}]
32+
33+
case (path, line, msg) if \
34+
isinstance(path, str) and isinstance(line, int) and isinstance(msg, str):
35+
failure_reason = msg
36+
return failure_reason, [{"expanded": [], "backtrace": [f"{path}:{line}"]}]
37+
38+
case ExceptionInfo() as exc_info:
39+
failure_reason = exc_info.exconly()
40+
expanded = []
41+
backtrace = []
42+
43+
if hasattr(exc_info, "traceback") and exc_info.traceback:
44+
for entry in exc_info.traceback:
45+
backtrace.append(f"{entry.path}:{entry.lineno}: {entry.name}")
46+
source = entry.getsource() if hasattr(entry, "getsource") else None
47+
if source:
48+
expanded.extend(str(line) for line in source)
49+
50+
failure_expanded = {}
51+
if len(expanded) > 0:
52+
failure_expanded["expanded"] = expanded
53+
if len(backtrace) > 0:
54+
failure_expanded["backtrace"] = backtrace
55+
56+
return failure_reason, [failure_expanded]
57+
58+
case ExceptionRepr() as er if er.reprcrash is not None:
59+
failure_reason = er.reprcrash.message # e.g. "ZeroDivisionError: division by zero"
60+
failure_expanded = [{"expanded": str(er).splitlines()}]
61+
try:
62+
failure_expanded[0]["backtrace"] = [
63+
str(getattr(entry, 'reprfileloc', entry))
64+
for entry in er.reprtraceback.reprentries
65+
]
66+
except AttributeError:
67+
pass
68+
return failure_reason, failure_expanded
69+
70+
case _:
71+
lines = str(longrepr).splitlines()
72+
if len(lines) == 0:
73+
return None, None
74+
if len(lines) == 1:
75+
return lines[0], None
76+
return lines[0], [{"expanded": lines[1:]}]

tests/buildkite_test_collector/collector/test_payload.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,8 @@ def test_test_data_as_json_when_failed(failed_test):
161161
json = failed_test.as_json(Instant.now())
162162

163163
assert json["result"] == "failed"
164-
assert json["failure_reason"] == failed_test.result.failure_reason
164+
assert json["failure_reason"] == "bogus"
165+
assert json["failure_expanded"] == [{'expanded': ['test failed'], 'backtrace': ['test.py:1']}]
165166

166167

167168
def test_test_data_as_json_when_skipped(skipped_test):

tests/buildkite_test_collector/conftest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ def successful_test(history_finished) -> TestData:
2626

2727
@pytest.fixture
2828
def failed_test(successful_test) -> TestData:
29-
return replace(successful_test, result=TestResultFailed("bogus"))
29+
return replace(
30+
successful_test,
31+
result=TestResultFailed("bogus", [{"expanded": ["test failed"], "backtrace": ["test.py:1"]}])
32+
)
3033

3134

3235
@pytest.fixture

tests/buildkite_test_collector/pytest_plugin/test_plugin.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import json
22
import pytest
33

4-
from buildkite_test_collector.collector.payload import Payload
4+
from buildkite_test_collector.collector.payload import Payload, TestData, TestResultFailed, TestResultPassed, TestResultSkipped
55
from buildkite_test_collector.pytest_plugin import BuildkitePlugin
66

7+
from _pytest._code.code import ExceptionInfo
8+
from _pytest.reports import TestReport
79

810
def test_runtest_logstart_with_unstarted_payload(fake_env):
911
payload = Payload.init(fake_env)
@@ -16,6 +18,122 @@ def test_runtest_logstart_with_unstarted_payload(fake_env):
1618
assert plugin.payload.started_at is not None
1719

1820

21+
def test_pytest_runtest_logreport_simple_pass(fake_env):
22+
payload = Payload.init(fake_env)
23+
plugin = BuildkitePlugin(payload)
24+
25+
location = ("", None, "")
26+
report = TestReport(nodeid="", location=location, keywords={}, outcome="passed", longrepr=None, when="call")
27+
28+
plugin.pytest_runtest_logstart(report.nodeid, location)
29+
plugin.pytest_runtest_logreport(report)
30+
31+
test_data = plugin.in_flight.get(report.nodeid)
32+
assert test_data is not None
33+
34+
assert isinstance(test_data.result, TestResultPassed)
35+
36+
37+
def test_pytest_runtest_logreport_fail_oneline(fake_env):
38+
payload = Payload.init(fake_env)
39+
plugin = BuildkitePlugin(payload)
40+
41+
location = ("", None, "")
42+
longrepr = "the reason the test failed"
43+
report = TestReport(nodeid="", location=location, keywords={}, outcome="failed", longrepr=longrepr, when="call")
44+
45+
plugin.pytest_runtest_logstart(report.nodeid, location)
46+
plugin.pytest_runtest_logreport(report)
47+
test_data = plugin.in_flight.get(report.nodeid)
48+
plugin.pytest_runtest_logfinish(report.nodeid, location)
49+
50+
assert isinstance(test_data, TestData)
51+
assert isinstance(test_data.result, TestResultFailed)
52+
assert test_data.result.failure_reason == "the reason the test failed"
53+
54+
55+
def test_pytest_runtest_logreport_fail_multiline(fake_env):
56+
payload = Payload.init(fake_env)
57+
plugin = BuildkitePlugin(payload)
58+
59+
location = ("", None, "")
60+
longrepr = "the reason the test failed\n.. is quite complicated\nso here is more detail"
61+
report = TestReport(nodeid="", location=location, keywords={}, outcome="failed", longrepr=longrepr, when="call")
62+
63+
plugin.pytest_runtest_logstart(report.nodeid, location)
64+
plugin.pytest_runtest_logreport(report)
65+
test_data = plugin.in_flight.get(report.nodeid)
66+
plugin.pytest_runtest_logfinish(report.nodeid, location)
67+
68+
assert isinstance(test_data, TestData)
69+
assert isinstance(test_data.result, TestResultFailed)
70+
assert test_data.result.failure_reason == "the reason the test failed"
71+
assert test_data.result.failure_expanded == [{"expanded": [".. is quite complicated", "so here is more detail"]}]
72+
73+
74+
def test_pytest_runtest_logreport_fail_exception(fake_env):
75+
payload = Payload.init(fake_env)
76+
plugin = BuildkitePlugin(payload)
77+
78+
location = ("", None, "")
79+
try:
80+
raise Exception("a fake exception for testing")
81+
except Exception as e:
82+
longrepr = ExceptionInfo.from_exception(e)
83+
report = TestReport(nodeid="", location=location, keywords={}, outcome="failed", longrepr=longrepr, when="call")
84+
85+
plugin.pytest_runtest_logstart(report.nodeid, location)
86+
plugin.pytest_runtest_logreport(report)
87+
test_data = plugin.in_flight.get(report.nodeid)
88+
plugin.pytest_runtest_logfinish(report.nodeid, location)
89+
90+
assert isinstance(test_data, TestData)
91+
assert isinstance(test_data.result, TestResultFailed)
92+
assert test_data.result.failure_reason == "Exception: a fake exception for testing"
93+
94+
assert isinstance(test_data.result.failure_expanded, list)
95+
fe = test_data.result.failure_expanded[0]
96+
assert list(fe.keys()) == ["expanded", "backtrace"]
97+
assert isinstance(fe["expanded"], list)
98+
assert len(fe["expanded"]) > 0
99+
assert isinstance(fe["backtrace"], list)
100+
assert len(fe["backtrace"]) > 0
101+
102+
103+
def test_pytest_runtest_logreport_simple_fail(fake_env):
104+
payload = Payload.init(fake_env)
105+
plugin = BuildkitePlugin(payload)
106+
107+
location = ("", None, "")
108+
report = TestReport(nodeid="", location=location, keywords={}, outcome="failed", longrepr=None, when="call")
109+
110+
plugin.pytest_runtest_logstart(report.nodeid, location)
111+
plugin.pytest_runtest_logreport(report)
112+
113+
test_data = plugin.in_flight.get(report.nodeid)
114+
assert test_data is not None
115+
116+
assert isinstance(test_data.result, TestResultFailed)
117+
118+
119+
def test_pytest_runtest_logreport_simple_skip(fake_env):
120+
payload = Payload.init(fake_env)
121+
plugin = BuildkitePlugin(payload)
122+
123+
location = ("path/to/test.py", 100, "")
124+
longrepr = ("path/to/test.py", 123, "skippy")
125+
report = TestReport(nodeid="", location=location, keywords={}, outcome="skipped", longrepr=longrepr, when="call")
126+
127+
plugin.pytest_runtest_logstart(report.nodeid, location)
128+
plugin.pytest_runtest_logreport(report)
129+
130+
test_data = plugin.in_flight.get(report.nodeid)
131+
assert isinstance(test_data, TestData)
132+
133+
assert isinstance(test_data.result, TestResultSkipped)
134+
# TODO: track skip reason as failure_reason via longrepr
135+
136+
19137
def test_save_json_payload_without_merge(fake_env, tmp_path, successful_test):
20138
payload = Payload.init(fake_env)
21139
payload = Payload.started(payload)

0 commit comments

Comments
 (0)