Skip to content

Commit 5dd85b4

Browse files
github-actions[bot]vitor-de-araujobrettlangdon
authored
fix(ci_visibility): handle pytest ATR retry failures in unittest classes [backport 2.19] (#12047)
Co-authored-by: Vítor De Araújo <[email protected]> Co-authored-by: Brett Langdon <[email protected]>
1 parent 067d6a7 commit 5dd85b4

File tree

3 files changed

+89
-7
lines changed

3 files changed

+89
-7
lines changed

ddtrace/contrib/pytest/_retry_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def _retry_run_when(item, when, outcomes: RetryOutcomes) -> t.Tuple[CallInfo, _p
115115
)
116116
else:
117117
call = CallInfo.from_call(lambda: hook(item=item), when=when)
118-
report = pytest.TestReport.from_item_and_call(item=item, call=call)
118+
report = item.ihook.pytest_runtest_makereport(item=item, call=call)
119119
if report.outcome == "passed":
120120
report.outcome = outcomes.PASSED
121121
elif report.outcome == "failed" or report.outcome == "error":
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
fixes:
3+
- |
4+
CI Visibility: fixes an issue where Auto Test Retries with pytest would always consider retries of tests defined
5+
inside unittest classes to be successful.

tests/contrib/pytest/test_pytest_atr.py

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,50 @@
2424
)
2525

2626
_TEST_PASS_CONTENT = """
27+
import unittest
28+
2729
def test_func_pass():
2830
assert True
31+
32+
class SomeTestCase(unittest.TestCase):
33+
def test_class_func_pass(self):
34+
assert True
2935
"""
3036

3137
_TEST_FAIL_CONTENT = """
3238
import pytest
39+
import unittest
3340
3441
def test_func_fail():
3542
assert False
3643
3744
_test_func_retries_skip_count = 0
45+
3846
def test_func_retries_skip():
3947
global _test_func_retries_skip_count
4048
_test_func_retries_skip_count += 1
4149
if _test_func_retries_skip_count > 1:
4250
pytest.skip()
4351
assert False
4452
53+
_test_class_func_retries_skip_count = 0
54+
55+
class SomeTestCase(unittest.TestCase):
56+
def test_class_func_fail(self):
57+
assert False
58+
59+
def test_class_func_retries_skip(self):
60+
global _test_class_func_retries_skip_count
61+
_test_class_func_retries_skip_count += 1
62+
if _test_class_func_retries_skip_count > 1:
63+
pytest.skip()
64+
assert False
4565
"""
4666

67+
4768
_TEST_PASS_ON_RETRIES_CONTENT = """
69+
import unittest
70+
4871
_test_func_passes_4th_retry_count = 0
4972
def test_func_passes_4th_retry():
5073
global _test_func_passes_4th_retry_count
@@ -56,10 +79,19 @@ def test_func_passes_1st_retry():
5679
global _test_func_passes_1st_retry_count
5780
_test_func_passes_1st_retry_count += 1
5881
assert _test_func_passes_1st_retry_count == 2
82+
83+
class SomeTestCase(unittest.TestCase):
84+
_test_func_passes_4th_retry_count = 0
85+
86+
def test_func_passes_4th_retry(self):
87+
SomeTestCase._test_func_passes_4th_retry_count += 1
88+
assert SomeTestCase._test_func_passes_4th_retry_count == 5
89+
5990
"""
6091

6192
_TEST_ERRORS_CONTENT = """
6293
import pytest
94+
import unittest
6395
6496
@pytest.fixture
6597
def fixture_fails_setup():
@@ -79,13 +111,22 @@ def test_func_fails_teardown(fixture_fails_teardown):
79111

80112
_TEST_SKIP_CONTENT = """
81113
import pytest
114+
import unittest
82115
83116
@pytest.mark.skip
84117
def test_func_skip_mark():
85118
assert True
86119
87120
def test_func_skip_inside():
88121
pytest.skip()
122+
123+
class SomeTestCase(unittest.TestCase):
124+
@pytest.mark.skip
125+
def test_class_func_skip_mark(self):
126+
assert True
127+
128+
def test_class_func_skip_inside(self):
129+
pytest.skip()
89130
"""
90131

91132

@@ -109,7 +150,7 @@ def test_pytest_atr_no_ddtrace_does_not_retry(self):
109150
self.testdir.makepyfile(test_pass_on_retries=_TEST_PASS_ON_RETRIES_CONTENT)
110151
self.testdir.makepyfile(test_skip=_TEST_SKIP_CONTENT)
111152
rec = self.inline_run()
112-
rec.assertoutcome(passed=2, failed=6, skipped=2)
153+
rec.assertoutcome(passed=3, failed=9, skipped=4)
113154
assert len(self.pop_spans()) == 0
114155

115156
def test_pytest_atr_env_var_disables_retrying(self):
@@ -121,7 +162,7 @@ def test_pytest_atr_env_var_disables_retrying(self):
121162

122163
with mock.patch("ddtrace.internal.ci_visibility.recorder.ddconfig", _get_default_civisibility_ddconfig()):
123164
rec = self.inline_run("--ddtrace", "-s", extra_env={"DD_CIVISIBILITY_FLAKY_RETRY_ENABLED": "0"})
124-
rec.assertoutcome(passed=2, failed=6, skipped=2)
165+
rec.assertoutcome(passed=3, failed=9, skipped=4)
125166
assert len(self.pop_spans()) > 0
126167

127168
def test_pytest_atr_env_var_does_not_override_api(self):
@@ -137,7 +178,7 @@ def test_pytest_atr_env_var_does_not_override_api(self):
137178
return_value=TestVisibilityAPISettings(flaky_test_retries_enabled=False),
138179
):
139180
rec = self.inline_run("--ddtrace", extra_env={"DD_CIVISIBILITY_FLAKY_RETRY_ENABLED": "1"})
140-
rec.assertoutcome(passed=2, failed=6, skipped=2)
181+
rec.assertoutcome(passed=3, failed=9, skipped=4)
141182
assert len(self.pop_spans()) > 0
142183

143184
def test_pytest_atr_spans(self):
@@ -178,6 +219,15 @@ def test_pytest_atr_spans(self):
178219
func_fail_retries += 1
179220
assert func_fail_retries == 5
180221

222+
class_func_fail_spans = _get_spans_from_list(spans, "test", "SomeTestCase::test_class_func_fail")
223+
assert len(class_func_fail_spans) == 6
224+
class_func_fail_retries = 0
225+
for class_func_fail_span in class_func_fail_spans:
226+
assert class_func_fail_span.get_tag("test.status") == "fail"
227+
if class_func_fail_span.get_tag("test.is_retry") == "true":
228+
class_func_fail_retries += 1
229+
assert class_func_fail_retries == 5
230+
181231
func_fail_skip_spans = _get_spans_from_list(spans, "test", "test_func_retries_skip")
182232
assert len(func_fail_skip_spans) == 6
183233
func_fail_skip_retries = 0
@@ -188,6 +238,18 @@ def test_pytest_atr_spans(self):
188238
func_fail_skip_retries += 1
189239
assert func_fail_skip_retries == 5
190240

241+
class_func_fail_skip_spans = _get_spans_from_list(spans, "test", "SomeTestCase::test_class_func_retries_skip")
242+
assert len(class_func_fail_skip_spans) == 6
243+
class_func_fail_skip_retries = 0
244+
for class_func_fail_skip_span in class_func_fail_skip_spans:
245+
class_func_fail_skip_is_retry = class_func_fail_skip_span.get_tag("test.is_retry") == "true"
246+
assert class_func_fail_skip_span.get_tag("test.status") == (
247+
"skip" if class_func_fail_skip_is_retry else "fail"
248+
)
249+
if class_func_fail_skip_is_retry:
250+
class_func_fail_skip_retries += 1
251+
assert class_func_fail_skip_retries == 5
252+
191253
func_pass_spans = _get_spans_from_list(spans, "test", "test_func_pass")
192254
assert len(func_pass_spans) == 1
193255
assert func_pass_spans[0].get_tag("test.status") == "pass"
@@ -205,7 +267,17 @@ def test_pytest_atr_spans(self):
205267
assert func_skip_inside_spans[0].get_tag("test.status") == "skip"
206268
assert func_skip_inside_spans[0].get_tag("test.is_retry") is None
207269

208-
assert len(spans) == 31
270+
class_func_skip_mark_spans = _get_spans_from_list(spans, "test", "SomeTestCase::test_class_func_skip_mark")
271+
assert len(class_func_skip_mark_spans) == 1
272+
assert class_func_skip_mark_spans[0].get_tag("test.status") == "skip"
273+
assert class_func_skip_mark_spans[0].get_tag("test.is_retry") is None
274+
275+
class_func_skip_inside_spans = _get_spans_from_list(spans, "test", "SomeTestCase::test_class_func_skip_inside")
276+
assert len(class_func_skip_inside_spans) == 1
277+
assert class_func_skip_inside_spans[0].get_tag("test.status") == "skip"
278+
assert class_func_skip_inside_spans[0].get_tag("test.is_retry") is None
279+
280+
assert len(spans) == 51
209281

210282
def test_pytest_atr_fails_session_when_test_fails(self):
211283
self.testdir.makepyfile(test_pass=_TEST_PASS_CONTENT)
@@ -216,7 +288,7 @@ def test_pytest_atr_fails_session_when_test_fails(self):
216288
rec = self.inline_run("--ddtrace")
217289
spans = self.pop_spans()
218290
assert rec.ret == 1
219-
assert len(spans) == 28
291+
assert len(spans) == 48
220292

221293
def test_pytest_atr_passes_session_when_test_pass(self):
222294
self.testdir.makepyfile(test_pass=_TEST_PASS_CONTENT)
@@ -226,9 +298,14 @@ def test_pytest_atr_passes_session_when_test_pass(self):
226298
rec = self.inline_run("--ddtrace")
227299
spans = self.pop_spans()
228300
assert rec.ret == 0
229-
assert len(spans) == 15
301+
assert len(spans) == 23
230302

231303
def test_pytest_atr_does_not_retry_failed_setup_or_teardown(self):
304+
# NOTE: This feature only works for regular pytest tests. For tests inside unittest classes, setup and teardown
305+
# happens at the 'call' phase, and we don't have a way to detect that the error happened during setup/teardown,
306+
# so tests will be retried as if they were failing tests.
307+
# See <https://docs.pytest.org/en/8.3.x/how-to/unittest.html#pdb-unittest-note>.
308+
232309
self.testdir.makepyfile(test_errors=_TEST_ERRORS_CONTENT)
233310
rec = self.inline_run("--ddtrace")
234311
spans = self.pop_spans()

0 commit comments

Comments
 (0)