Skip to content

Commit b7e82f2

Browse files
authored
fix(ci_visibility): honor attempt-to-fix failures (#17784)
## Description This fixes a regression in the new `ddtrace.testing` pytest plugin where tests marked as Attempt to Fix could still have failures hidden when combined with test management states such as quarantine or disable. Attempt to Fix now takes precedence over quarantine/disable handling in the new plugin, so failing attempts are reported as pytest failures instead of being masked. The final Attempt to Fix status is also computed as passing only when all attempts pass. ## Testing Unit tests ## Risks Low. The change is scoped to the new `ddtrace.testing` pytest plugin path and updates the behavior to expose failures that were previously hidden. ## Additional Notes This is the first PR in a two-part stack. A follow-up PR will refactor and clean up, but doesn't need to be backported. Co-authored-by: federico.mon <federico.mon@datadoghq.com>
1 parent cbe166c commit b7e82f2

5 files changed

Lines changed: 80 additions & 34 deletions

File tree

ddtrace/testing/internal/pytest/plugin.py

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -459,17 +459,18 @@ def _apply_test_management_markers(self, item: pytest.Item, test: "Test") -> Non
459459
"""Apply test management markers for the base plugin (used when an external rerun plugin drives execution).
460460
461461
ATF retries are not supported in this mode — the external plugin controls the protocol and we cannot intercept
462-
individual test runs to capture real FAIL statuses. Disabled+ATF tests therefore get the same xfail treatment
463-
as quarantined tests: failures won't break the pipeline, but ATF retry semantics are silently unavailable.
464-
If ATF support is needed, disable the external rerun plugin (see the warning emitted at configure time).
462+
individual test runs to capture real FAIL statuses. ATF therefore takes precedence over quarantine/disable
463+
markers so failures are still reported by pytest.
465464
466465
Overridden in TestOptPluginWithProtocol, which drives retries itself and needs real FAIL outcomes for ATF.
467466
"""
468467
if not self.manager.settings.test_management.enabled:
469468
return
470469
if test.is_disabled() and not test.is_attempt_to_fix():
471470
item.add_marker(pytest.mark.skip(reason=DISABLED_BY_TEST_MANAGEMENT_REASON))
472-
elif test.is_quarantined() or (test.is_disabled() and test.is_attempt_to_fix()):
471+
elif test.is_attempt_to_fix():
472+
return
473+
elif test.is_quarantined():
473474
# Use xfail so failures don't break the pipeline. Works regardless of who drives test execution.
474475
item.add_marker(pytest.mark.xfail(strict=False, reason="dd_quarantined", run=True))
475476

@@ -882,27 +883,17 @@ class TestOptPluginWithProtocol(TestOptPlugin):
882883
def _apply_test_management_markers(self, item: pytest.Item, test: "Test") -> None:
883884
"""Apply test management markers for the plugin that drives retries itself.
884885
885-
ATF tests must NOT use xfail here: xfail converts failures into SKIP outcomes, which prevents
886-
AttemptToFixHandler.get_final_status from seeing real FAIL counts and computing correct tags
887-
(HAS_FAILED_ALL_RETRIES, ATTEMPT_TO_FIX_PASSED). Instead, ATF tests set a user property so
888-
_get_test_outcome captures the true status, and reports are mangled to "skipped" afterwards so
889-
failures still don't break the pipeline.
890-
891-
The outer condition mirrors the original logic: only quarantined tests and disabled+ATF tests
892-
are treated as "run but don't fail the pipeline" — plain ATF (neither disabled nor quarantined)
893-
runs normally without any report suppression.
886+
ATF tests must NOT use skip or xfail here: ATF takes precedence over quarantine/disable markers, and any
887+
failed attempt should fail the test from pytest's point of view.
894888
"""
895889
if not self.manager.settings.test_management.enabled:
896890
return
897891
if test.is_disabled() and not test.is_attempt_to_fix():
898892
item.add_marker(pytest.mark.skip(reason=DISABLED_BY_TEST_MANAGEMENT_REASON))
899-
elif test.is_quarantined() or (test.is_disabled() and test.is_attempt_to_fix()):
900-
# NOTE: keep is_attempt_to_fix() check inside this branch, not outside — plain ATF tests that are
901-
# neither disabled nor quarantined run normally and must not get report mangling or a user property.
902-
if test.is_attempt_to_fix():
903-
item.user_properties += [("dd_disabled_attempt_to_fix", True)]
904-
else:
905-
item.add_marker(pytest.mark.xfail(strict=False, reason="dd_quarantined", run=True))
893+
elif test.is_attempt_to_fix():
894+
return
895+
elif test.is_quarantined():
896+
item.add_marker(pytest.mark.xfail(strict=False, reason="dd_quarantined", run=True))
906897

907898
@catch_and_log_exceptions()
908899
def pytest_runtest_protocol(self, item: pytest.Item, nextitem: t.Optional[pytest.Item]) -> bool:

ddtrace/testing/internal/retry_handlers.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,12 @@ def get_final_status(self, test: Test) -> TestStatus:
198198
status_counts[test_run.get_status()] += 1
199199
total_count += 1
200200

201-
if status_counts[TestStatus.PASS] > 0:
201+
if status_counts[TestStatus.PASS] == total_count:
202202
final_status = TestStatus.PASS
203-
elif status_counts[TestStatus.FAIL] > 0:
204-
final_status = TestStatus.FAIL
205-
else:
203+
elif status_counts[TestStatus.SKIP] == total_count:
206204
final_status = TestStatus.SKIP
205+
else:
206+
final_status = TestStatus.FAIL
207207

208208
if status_counts[TestStatus.PASS] == total_count:
209209
final_tags[TestTag.ATTEMPT_TO_FIX_PASSED] = TAG_TRUE
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
fixes:
3+
- |
4+
CI Visibility: Fixes an issue where tests marked as attempt-to-fix could have failures hidden when they were also quarantined or disabled.

tests/contrib/pytest/test_pytest_configure_plugin_selection.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,10 @@ def test_quarantine_and_disabled_applied_when_test_management_enabled(self, is_q
173173
"is_quarantined,is_disabled,is_attempt_to_fix",
174174
[
175175
(True, False, False), # quarantined
176-
(False, True, True), # disabled + ATF (ATF retries unavailable, treated same as quarantine)
177-
(True, False, True), # quarantined + ATF (same: ATF retries unavailable in base class)
178176
],
179177
)
180-
def test_base_plugin_uses_xfail_for_quarantine_and_atf(self, is_quarantined, is_disabled, is_attempt_to_fix):
181-
"""Base plugin (no retry control) always uses xfail for quarantine/ATF — ATF retries can't fire anyway."""
178+
def test_base_plugin_uses_xfail_for_quarantine(self, is_quarantined, is_disabled, is_attempt_to_fix):
179+
"""Base plugin (no retry control) uses xfail for quarantined non-ATF tests."""
182180
plugin = mock.MagicMock()
183181
plugin.manager.settings.test_management.enabled = True
184182

@@ -208,8 +206,10 @@ def test_base_plugin_uses_xfail_for_quarantine_and_atf(self, is_quarantined, is_
208206
(True, False, True), # quarantined + ATF
209207
],
210208
)
211-
def test_child_plugin_uses_user_property_for_atf(self, is_quarantined, is_disabled, is_attempt_to_fix):
212-
"""Child plugin (drives retries) uses user property for ATF so real FAIL status is captured."""
209+
def test_attempt_to_fix_takes_precedence_over_test_management_markers(
210+
self, is_quarantined, is_disabled, is_attempt_to_fix
211+
):
212+
"""ATF tests are not skipped or xfailed, even when quarantined or disabled."""
213213
plugin = mock.MagicMock()
214214
plugin.manager.settings.test_management.enabled = True
215215

@@ -221,10 +221,14 @@ def test_child_plugin_uses_user_property_for_atf(self, is_quarantined, is_disabl
221221
item = mock.MagicMock()
222222
item.user_properties = []
223223

224-
TestOptPluginWithProtocol._apply_test_management_markers(plugin, item=item, test=test)
224+
for plugin_class in (TestOptPlugin, TestOptPluginWithProtocol):
225+
item.add_marker.reset_mock()
226+
item.user_properties = []
225227

226-
item.add_marker.assert_not_called()
227-
assert ("dd_disabled_attempt_to_fix", True) in item.user_properties
228+
plugin_class._apply_test_management_markers(plugin, item=item, test=test)
229+
230+
item.add_marker.assert_not_called()
231+
assert item.user_properties == []
228232

229233
def test_child_plugin_uses_xfail_for_quarantine_without_atf(self):
230234
"""Child plugin uses xfail for quarantined non-ATF tests (same as base)."""

tests/testing/internal/pytest/test_pytest_atf.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from unittest.mock import patch
44

55
from _pytest.pytester import Pytester
6+
import pytest
67

78
from ddtrace.testing.internal.settings_data import TestProperties
89
from ddtrace.testing.internal.test_data import ModuleRef
@@ -94,7 +95,9 @@ def test_broken(self):
9495
setup_standard_mocks(),
9596
):
9697
with EventCapture.capture() as event_capture:
97-
pytester.inline_run("--ddtrace", "-v", "-s")
98+
result = pytester.inline_run("--ddtrace", "-v", "-s")
99+
100+
assert result.ret == 1
98101

99102
test_events = list(event_capture.events_by_test_name("TestNotFixed::test_broken"))
100103
# 1 initial (pass) + 1 retry (fail) = 2 events, NOT 1 + 20 retries
@@ -106,6 +109,7 @@ def test_broken(self):
106109
assert test_events[1]["content"]["meta"].get("test.status") == "fail"
107110
assert test_events[1]["content"]["meta"].get("test.is_retry") == "true"
108111
assert test_events[1]["content"]["meta"].get("test.retry_reason") == "attempt_to_fix"
112+
assert test_events[1]["content"]["meta"].get("test.test_management.attempt_to_fix_passed") == "false"
109113

110114
def test_atf_always_failing_exits_after_first(self, pytester: Pytester) -> None:
111115
"""Test that ATF stops retrying an always-failing test after the first run."""
@@ -144,3 +148,46 @@ def test_always_fails():
144148
# Final ATF tags must still be set even without retries
145149
assert test_events[0]["content"]["meta"].get("test.test_management.attempt_to_fix_passed") == "false"
146150
assert test_events[0]["content"]["meta"].get("test.has_failed_all_retries") == "true"
151+
152+
@pytest.mark.parametrize(
153+
"test_properties",
154+
[
155+
TestProperties(quarantined=True, attempt_to_fix=True),
156+
TestProperties(disabled=True, attempt_to_fix=True),
157+
],
158+
)
159+
def test_atf_failures_are_not_masked_by_quarantine_or_disable(
160+
self, pytester: Pytester, test_properties: TestProperties
161+
) -> None:
162+
"""Test that ATF takes precedence over quarantine/disable and failed attempts fail pytest."""
163+
pytester.makepyfile(
164+
test_foo="""
165+
def test_not_fixed():
166+
assert False
167+
"""
168+
)
169+
170+
test_ref = TestRef(SuiteRef(ModuleRef(""), "test_foo.py"), "test_not_fixed")
171+
known_tests: set[TestRef] = {test_ref}
172+
173+
with (
174+
patch(
175+
"ddtrace.testing.internal.session_manager.APIClient",
176+
return_value=mock_api_client_settings(
177+
test_management_enabled=True,
178+
known_tests_enabled=True,
179+
known_tests=known_tests,
180+
test_management_properties={test_ref: test_properties},
181+
),
182+
),
183+
setup_standard_mocks(),
184+
):
185+
with EventCapture.capture() as event_capture:
186+
result = pytester.inline_run("--ddtrace", "-v", "-s")
187+
188+
assert result.ret == 1
189+
190+
test_events = list(event_capture.events_by_test_name("test_not_fixed"))
191+
assert len(test_events) == 1
192+
assert test_events[0]["content"]["meta"].get("test.status") == "fail"
193+
assert test_events[0]["content"]["meta"].get("test.test_management.attempt_to_fix_passed") == "false"

0 commit comments

Comments
 (0)