Skip to content

Commit 78002f1

Browse files
fix(ci_visibility): itr skipped counts suites or tests [backport 3.13] (#14697)
Backport e239ca4 from #14681 to 3.13. CI Visibility: Fixes some bad behavior in test skipping count, introduced in #14257 Co-authored-by: Federico Mon <[email protected]>
1 parent f774a51 commit 78002f1

10 files changed

+234
-51
lines changed

ddtrace/contrib/internal/pytest/_plugin_v2.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import os
22
from pathlib import Path
3-
import re
43
import typing as t
54

65
from _pytest.runner import runtestprotocol
@@ -101,11 +100,12 @@
101100
log = get_logger(__name__)
102101

103102

104-
_NODEID_REGEX = re.compile("^((?P<module>.*)/(?P<suite>[^/]*?))::(?P<name>.*?)$")
105103
OUTCOME_QUARANTINED = "quarantined"
106104
DISABLED_BY_TEST_MANAGEMENT_REASON = "Flaky test is disabled by Datadog"
107105
INCOMPATIBLE_PLUGINS = ("flaky", "rerunfailures")
108106

107+
skipped_suites = set()
108+
109109
skip_pytest_runtest_protocol = False
110110

111111
# Module-level variable to store the current test's coverage collector
@@ -151,13 +151,27 @@ def _handle_itr_should_skip(item, test_id) -> bool:
151151
if hasattr(item.config, "workeroutput"):
152152
if "itr_skipped_count" not in item.config.workeroutput:
153153
item.config.workeroutput["itr_skipped_count"] = 0
154-
item.config.workeroutput["itr_skipped_count"] += 1
154+
if not is_suite_skipping_mode:
155+
item.config.workeroutput["itr_skipped_count"] += 1
155156

156157
return True
157158

158159
return False
159160

160161

162+
def _handle_itr_xdist_skipped_suite(item, suite_id) -> bool:
163+
if suite_id in skipped_suites:
164+
log.debug("Suite is already skipped")
165+
return False
166+
167+
if hasattr(item.config, "workeroutput"):
168+
if "itr_skipped_count" not in item.config.workeroutput:
169+
item.config.workeroutput["itr_skipped_count"] = 0
170+
item.config.workeroutput["itr_skipped_count"] += 1
171+
skipped_suites.add(suite_id)
172+
return True
173+
174+
161175
def _handle_test_management(item, test_id):
162176
"""Add a user property to identify quarantined tests, and mark them for skipping if quarantine is enabled in
163177
skipping mode.
@@ -605,6 +619,7 @@ def _pytest_runtest_protocol_post_yield(item, nextitem, coverage_collector):
605619
if next_test_id is None or next_test_id.parent_id != suite_id:
606620
if InternalTestSuite.is_itr_skippable(suite_id) and not InternalTestSuite.was_itr_forced_run(suite_id):
607621
InternalTestSuite.mark_itr_skipped(suite_id)
622+
_handle_itr_xdist_skipped_suite(item, suite_id)
608623
else:
609624
_handle_coverage_dependencies(suite_id)
610625
InternalTestSuite.finish(suite_id)

ddtrace/internal/ci_visibility/api/_suite.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ def finish_itr_skipped(self) -> None:
6969
)
7070
return
7171

72+
# Only count for suite-level skipping mode, not test-level
73+
if self._session_settings.itr_test_skipping_level == ITR_SKIPPING_LEVEL.SUITE:
74+
self.count_itr_skipped()
7275
self.mark_itr_skipped()
7376
self.finish()
7477

ddtrace/internal/ci_visibility/api/_test.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,8 @@ def count_itr_skipped(self) -> None:
260260

261261
def finish_itr_skipped(self) -> None:
262262
log.debug("Finishing Test Visibility test %s with ITR skipped", self)
263-
self.count_itr_skipped()
263+
if self._session_settings.itr_test_skipping_level == ITR_SKIPPING_LEVEL.TEST:
264+
self.count_itr_skipped()
264265
self.mark_itr_skipped()
265266
self.finish_test(TestStatus.SKIP)
266267

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: This fix solves an issue where the ITR skip count metric was aggregating skipped tests even when skipping level was set to suite. It will now count appropriately (skipped suites or skipped tests) depending on ITR skip level.

tests/ci_visibility/api/fake_runner_mix_fail_itr_suite_level.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
1717
Comment lines in the test start/finish lines are there for visual distinction.
1818
"""
19+
1920
import json
2021
from multiprocessing import freeze_support
2122
from pathlib import Path
@@ -427,8 +428,19 @@ def main():
427428
if __name__ == "__main__":
428429
freeze_support()
429430
# NOTE: this is only safe because these tests are run in a subprocess
430-
import os
431431

432-
os.environ["_DD_CIVISIBILITY_ITR_SUITE_MODE"] = "1"
433-
with mock.patch("ddtrace.internal.ci_visibility.CIVisibility.is_itr_enabled", return_value=True):
434-
main()
432+
from ddtrace.internal.ci_visibility._api_client import TestVisibilityAPISettings
433+
434+
itr_settings = TestVisibilityAPISettings(
435+
coverage_enabled=False,
436+
skipping_enabled=True,
437+
require_git=False,
438+
itr_enabled=True,
439+
flaky_test_retries_enabled=False,
440+
)
441+
442+
mock.patch(
443+
"ddtrace.internal.ci_visibility.recorder.CIVisibility._check_enabled_features", return_value=itr_settings
444+
).start()
445+
446+
main()

tests/ci_visibility/api/test_api_fake_runners.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -272,12 +272,8 @@ def test_manual_api_fake_runner_mix_fail_itr_suite_level(self):
272272
mock_ci_env=True,
273273
),
274274
replace_os_env=True,
275-
), mock.patch(
276-
"ddtrace.internal.ci_visibility._api_client._TestVisibilityAPIClientBase.fetch_settings",
277-
return_value=TestVisibilityAPISettings(False, False, False, False, False),
278-
), mock.patch(
279-
"ddtrace.internal.ci_visibility.recorder.ddconfig", _get_default_civisibility_ddconfig()
280275
):
276+
# The fake runner handles its own ITR mocking internally
281277
subprocess.run(["python", "fake_runner_mix_fail_itr_suite_level.py"])
282278

283279
@snapshot(ignores=SNAPSHOT_IGNORES)

tests/contrib/pytest/test_pytest.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2583,15 +2583,15 @@ def test_inner_2():
25832583
for skipped_test_span in skipped_test_spans:
25842584
assert skipped_test_span.get_tag("test.skipped_by_itr") == "true"
25852585

2586-
def test_pytest_suite_level_skipping_counts_tests_not_suites(self):
2586+
def test_pytest_suite_level_skipping_counts_suites(self):
25872587
"""
25882588
Regression test for suite level skipping count bug.
25892589
25902590
When ITR is enabled at suite level and suites are skipped, the `itr.tests_skipping.count` tag
2591-
should count the number of tests that were skipped (contained within those suites).
2591+
should count the number of suites that were skipped (instead of the number of tests).
25922592
25932593
This test creates 2 suites with multiple tests each (4 tests total), expects all suites to be
2594-
skipped, and verifies that the count reflects the number of tests (4), not suites (2).
2594+
skipped, and verifies that the count reflects the number of suites (2), not tests (4).
25952595
"""
25962596
package_outer_dir = self.testdir.mkpydir("test_outer_package")
25972597
os.chdir(str(package_outer_dir))
@@ -2651,12 +2651,12 @@ def test_inner_2():
26512651
assert session_span.get_tag("_dd.ci.itr.tests_skipped") == "true"
26522652
assert session_span.get_tag("test.itr.tests_skipping.type") == "suite"
26532653

2654-
# This is the regression test: should count tests (4), not suites (2)
2655-
expected_test_count = 4 # 4 individual tests were skipped
2654+
# This is the regression test: should count suites (2), not tests (4)
2655+
expected_suite_count = 2 # 4 individual tests were skipped
26562656
actual_count = session_span.get_metric("test.itr.tests_skipping.count")
26572657
assert (
2658-
actual_count == expected_test_count
2659-
), f"Expected {expected_test_count} tests skipped but got {actual_count}"
2658+
actual_count == expected_suite_count
2659+
), f"Expected {expected_suite_count} suites skipped but got {actual_count}"
26602660

26612661
# Verify all test spans were skipped by ITR
26622662
skipped_test_spans = [x for x in spans if x.get_tag("test.status") == "skip" and x.get_tag("type") == "test"]

tests/contrib/pytest/test_pytest_xdist_itr.py

Lines changed: 158 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ def test_pytest_xdist_itr_skips_tests_at_test_level_by_pytest_addopts_env_var(se
118118
return_value=itr_settings
119119
).start()
120120
121+
# Mock fetch_skippable_items to return our test data
122+
mock.patch(
123+
"ddtrace.internal.ci_visibility._api_client._TestVisibilityAPIClientBase.fetch_skippable_items",
124+
return_value=itr_data
125+
).start()
126+
121127
# Set ITR data when CIVisibility is enabled
122128
import ddtrace.internal.ci_visibility.recorder
123129
CIVisibility = ddtrace.internal.ci_visibility.recorder.CIVisibility
@@ -173,6 +179,129 @@ def patched_enable(cls, *args, **kwargs):
173179
# Verify number of skipped tests in session
174180
assert session_span.get_metric("test.itr.tests_skipping.count") == 2
175181

182+
def test_xdist_suite_mode_skipped_suites(self):
183+
"""Test that suite-level ITR skipping works correctly in xdist and counts suites, not individual tests."""
184+
185+
itr_skipping_sitecustomize = """
186+
# sitecustomize.py - ITR setup for xdist worker nodes
187+
from unittest import mock
188+
189+
# Import required modules
190+
from ddtrace.internal.ci_visibility._api_client import TestVisibilityAPISettings
191+
from ddtrace.internal.ci_visibility._api_client import EarlyFlakeDetectionSettings
192+
from ddtrace.internal.ci_visibility._api_client import TestManagementSettings
193+
from ddtrace.internal.ci_visibility._api_client import ITRData
194+
from ddtrace.ext.test_visibility._test_visibility_base import TestSuiteId, TestModuleId, TestId
195+
196+
# Create ITR settings and data
197+
itr_settings = TestVisibilityAPISettings(
198+
coverage_enabled=False, skipping_enabled=True, require_git=False, itr_enabled=True,
199+
flaky_test_retries_enabled=False, known_tests_enabled=False,
200+
early_flake_detection=EarlyFlakeDetectionSettings(), test_management=TestManagementSettings()
201+
)
202+
203+
# Create skippable suites for suite-level skipping
204+
skippable_suites = {
205+
TestSuiteId(TestModuleId(""), "test_scope1.py"),
206+
TestSuiteId(TestModuleId(""), "test_scope2.py")
207+
}
208+
itr_data = ITRData(correlation_id="12345678-1234-1234-1234-123456789012", skippable_items=skippable_suites)
209+
210+
# Mock API calls to return our settings
211+
mock.patch(
212+
"ddtrace.internal.ci_visibility._api_client._TestVisibilityAPIClientBase.fetch_settings",
213+
return_value=itr_settings
214+
).start()
215+
216+
mock.patch(
217+
"ddtrace.internal.ci_visibility.recorder.CIVisibility._check_enabled_features",
218+
return_value=itr_settings
219+
).start()
220+
221+
# Mock fetch_skippable_items to return our test data
222+
mock.patch(
223+
"ddtrace.internal.ci_visibility._api_client._TestVisibilityAPIClientBase.fetch_skippable_items",
224+
return_value=itr_data
225+
).start()
226+
227+
# Set ITR data when CIVisibility is enabled
228+
import ddtrace.internal.ci_visibility.recorder
229+
CIVisibility = ddtrace.internal.ci_visibility.recorder.CIVisibility
230+
original_enable = CIVisibility.enable
231+
232+
def patched_enable(cls, *args, **kwargs):
233+
result = original_enable(*args, **kwargs)
234+
if cls._instance:
235+
cls._instance._itr_data = itr_data
236+
return result
237+
238+
CIVisibility.enable = classmethod(patched_enable)
239+
"""
240+
241+
# Create test files
242+
self.testdir.makepyfile(sitecustomize=itr_skipping_sitecustomize)
243+
self.testdir.makepyfile(
244+
test_scope1="""
245+
import pytest
246+
247+
class TestScope1:
248+
def test_scope1_method1(self):
249+
assert True
250+
251+
def test_scope1_method2(self):
252+
assert True
253+
""",
254+
test_scope2="""
255+
import pytest
256+
257+
class TestScope2:
258+
def test_scope2_method1(self):
259+
assert True
260+
""",
261+
)
262+
self.testdir.chdir()
263+
264+
itr_settings = TestVisibilityAPISettings(
265+
coverage_enabled=False,
266+
skipping_enabled=True,
267+
require_git=False,
268+
itr_enabled=True,
269+
flaky_test_retries_enabled=False,
270+
known_tests_enabled=False,
271+
early_flake_detection=EarlyFlakeDetectionSettings(),
272+
test_management=TestManagementSettings(),
273+
)
274+
275+
with mock.patch(
276+
"ddtrace.internal.ci_visibility.recorder.CIVisibility._check_enabled_features", return_value=itr_settings
277+
), mock.patch(
278+
"ddtrace.internal.ci_visibility.recorder.CIVisibility.test_skipping_enabled",
279+
return_value=True,
280+
):
281+
# Run with xdist using loadscope mode (suite-level skipping)
282+
rec = self.inline_run(
283+
"--ddtrace",
284+
"-n",
285+
"2",
286+
"--dist=loadscope",
287+
"-s",
288+
"-vvv",
289+
extra_env={
290+
"DD_CIVISIBILITY_AGENTLESS_ENABLED": "1",
291+
"DD_API_KEY": "foobar.baz",
292+
"DD_INSTRUMENTATION_TELEMETRY_ENABLED": "0",
293+
},
294+
)
295+
assert rec.ret == 0 # All tests skipped, so exit code is 0
296+
297+
# Assert on session span metrics - key assertion for suite-level skipping
298+
spans = self.pop_spans()
299+
session_span = [span for span in spans if span.get_tag("type") == "test_session_end"][0]
300+
assert session_span.get_tag("test.itr.tests_skipping.enabled") == "true"
301+
assert session_span.get_tag("test.itr.tests_skipping.type") == "suite" # loadscope uses suite-level skipping
302+
# Verify number of skipped SUITES in session (should be 2 suites, not 3 tests)
303+
assert session_span.get_metric("test.itr.tests_skipping.count") == 2
304+
176305
def test_pytest_xdist_itr_skips_tests_at_test_level_without_loadscope(self):
177306
"""Test that ITR tags are correctly aggregated from xdist workers."""
178307
# Create a simplified sitecustomize with just the essential ITR setup
@@ -210,6 +339,12 @@ def test_pytest_xdist_itr_skips_tests_at_test_level_without_loadscope(self):
210339
return_value=itr_settings
211340
).start()
212341
342+
# Mock fetch_skippable_items to return our test data
343+
mock.patch(
344+
"ddtrace.internal.ci_visibility._api_client._TestVisibilityAPIClientBase.fetch_skippable_items",
345+
return_value=itr_data
346+
).start()
347+
213348
# Set ITR data when CIVisibility is enabled
214349
import ddtrace.internal.ci_visibility.recorder
215350
CIVisibility = ddtrace.internal.ci_visibility.recorder.CIVisibility
@@ -300,6 +435,12 @@ def test_pytest_xdist_itr_skips_tests_at_suite_level_with_loadscope(self):
300435
return_value=itr_settings
301436
).start()
302437
438+
# Mock fetch_skippable_items to return our test data
439+
mock.patch(
440+
"ddtrace.internal.ci_visibility._api_client._TestVisibilityAPIClientBase.fetch_skippable_items",
441+
return_value=itr_data
442+
).start()
443+
303444
# Set ITR data when CIVisibility is enabled
304445
import ddtrace.internal.ci_visibility.recorder
305446
CIVisibility = ddtrace.internal.ci_visibility.recorder.CIVisibility
@@ -460,16 +601,16 @@ def test_handle_itr_should_skip_counts_skipped_tests_in_worker(self):
460601
test_id = TestId(TestSuiteId(TestModuleId("test_module"), "test_suite"), "test_name")
461602

462603
mock_service = mock.MagicMock()
463-
mock_service._suite_skipping_mode = True
604+
mock_service._suite_skipping_mode = False # Use test-level skipping for worker count tests
464605

465606
with mock.patch(
466607
"ddtrace.internal.test_visibility.api.InternalTestSession.is_test_skipping_enabled", return_value=True
467608
), mock.patch(
468-
"ddtrace.internal.test_visibility.api.InternalTestSuite.is_itr_unskippable", return_value=False
609+
"ddtrace.internal.test_visibility.api.InternalTest.is_itr_unskippable", return_value=False
469610
), mock.patch(
470611
"ddtrace.internal.test_visibility.api.InternalTest.is_attempt_to_fix", return_value=False
471612
), mock.patch(
472-
"ddtrace.internal.test_visibility.api.InternalTestSuite.is_itr_skippable", return_value=True
613+
"ddtrace.internal.test_visibility.api.InternalTest.is_itr_skippable", return_value=True
473614
), mock.patch(
474615
"ddtrace.internal.test_visibility.api.InternalTest.mark_itr_skipped"
475616
), mock.patch(
@@ -491,16 +632,16 @@ def test_handle_itr_should_skip_increments_existing_worker_count(self):
491632
test_id = TestId(TestSuiteId(TestModuleId("test_module"), "test_suite"), "test_name")
492633

493634
mock_service = mock.MagicMock()
494-
mock_service._suite_skipping_mode = True
635+
mock_service._suite_skipping_mode = False # Use test-level skipping for worker count tests
495636

496637
with mock.patch(
497638
"ddtrace.internal.test_visibility.api.InternalTestSession.is_test_skipping_enabled", return_value=True
498639
), mock.patch(
499-
"ddtrace.internal.test_visibility.api.InternalTestSuite.is_itr_unskippable", return_value=False
640+
"ddtrace.internal.test_visibility.api.InternalTest.is_itr_unskippable", return_value=False
500641
), mock.patch(
501642
"ddtrace.internal.test_visibility.api.InternalTest.is_attempt_to_fix", return_value=False
502643
), mock.patch(
503-
"ddtrace.internal.test_visibility.api.InternalTestSuite.is_itr_skippable", return_value=True
644+
"ddtrace.internal.test_visibility.api.InternalTest.is_itr_skippable", return_value=True
504645
), mock.patch(
505646
"ddtrace.internal.test_visibility.api.InternalTest.mark_itr_skipped"
506647
), mock.patch(
@@ -1690,6 +1831,11 @@ def test_func2():
16901831

16911832
# The ITR skipping type should be suite due to explicit env var override
16921833
assert session_span.get_tag("test.itr.tests_skipping.type") == "suite"
1834+
expected_suite_count = 0 # No suites skipped
1835+
actual_count = session_span.get_metric("test.itr.tests_skipping.count")
1836+
assert (
1837+
actual_count == expected_suite_count
1838+
), f"Expected {expected_suite_count} suites skipped but got {actual_count}"
16931839

16941840
def test_explicit_env_var_overrides_xdist_test_mode(self):
16951841
"""Test that explicit _DD_CIVISIBILITY_ITR_SUITE_MODE=False overrides xdist suite-level detection."""
@@ -1742,6 +1888,12 @@ def patched_enable(cls, *args, **kwargs):
17421888
return_value=itr_settings
17431889
).start()
17441890
1891+
# Mock fetch_skippable_items to return our test data
1892+
mock.patch(
1893+
"ddtrace.internal.ci_visibility._api_client._TestVisibilityAPIClientBase.fetch_skippable_items",
1894+
return_value=itr_data
1895+
).start()
1896+
17451897
CIVisibility.enable = classmethod(patched_enable)
17461898
17471899
"""

0 commit comments

Comments
 (0)