From 4b59b1506887a19f8f922c818d4e3ac2fe6a52c2 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Wed, 10 Jun 2026 11:17:59 +0000 Subject: [PATCH 01/15] feat(ci_visibility): add test discovery mode for pytest Add DD_CI_TEST_DISCOVERY_MODE_ENABLED support to the ddtrace/testing pytest plugin, matching the format produced by datadog-ci-rb. When enabled, pytest collects tests and writes them as JSON Lines to DD_CI_TEST_DISCOVERY_OUTPUT_PATH (default: ddtest/test_discovery/tests.json), then exits without running any test. Each line contains name, suite, module, parameters, and suiteSourceFile fields. Tests decorated with pytest.mark.skip or pytest.mark.skipif with a truthy non-string condition are excluded from the output. CI Visibility initialisation is suppressed when discovery mode is active. Also moves _get_test_parameters_json and _encode_test_parameter from plugin.py to utils.py so they can be imported by _discovery.py without a circular dependency. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/testing/internal/pytest/_discovery.py | 97 ++++++++ ddtrace/testing/internal/pytest/plugin.py | 37 +-- ddtrace/testing/internal/pytest/utils.py | 35 +++ .../internal/pytest/test_pytest_discovery.py | 226 ++++++++++++++++++ 4 files changed, 365 insertions(+), 30 deletions(-) create mode 100644 ddtrace/testing/internal/pytest/_discovery.py create mode 100644 tests/testing/internal/pytest/test_pytest_discovery.py diff --git a/ddtrace/testing/internal/pytest/_discovery.py b/ddtrace/testing/internal/pytest/_discovery.py new file mode 100644 index 00000000000..a0f9051d2c3 --- /dev/null +++ b/ddtrace/testing/internal/pytest/_discovery.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +import typing as t + +import pytest + +from ddtrace.testing.internal.pytest.utils import _get_test_parameters_json +from ddtrace.testing.internal.pytest.utils import nodeid_to_names +from ddtrace.testing.internal.utils import asbool + + +log = logging.getLogger(__name__) + +_ENV_ENABLED = "DD_CI_TEST_DISCOVERY_MODE_ENABLED" +_ENV_OUTPUT_PATH = "DD_CI_TEST_DISCOVERY_OUTPUT_PATH" +_DEFAULT_OUTPUT_PATH = "ddtest/test_discovery/tests.json" +_FRAMEWORK = "pytest" + + +def is_discovery_mode_enabled() -> bool: + return asbool(os.environ.get(_ENV_ENABLED)) + + +def _get_output_path() -> Path: + return Path(os.environ.get(_ENV_OUTPUT_PATH, _DEFAULT_OUTPUT_PATH)) + + +def _is_item_skipped(item: pytest.Item) -> bool: + """Return True if the item will definitely be skipped at execution time. + + Handles pytest.mark.skip (unconditional) and pytest.mark.skipif with + non-string conditions. String conditions are not evaluated (would require + exec in the test module's namespace) so those tests are conservatively + included. + """ + if item.get_closest_marker("skip") is not None: + return True + for marker in item.iter_markers("skipif"): + condition = marker.args[0] if marker.args else marker.kwargs.get("condition") + if condition is None or isinstance(condition, str): + continue + if condition: + return True + return False + + +def _get_suite_source_file(item: pytest.Item, workspace_path: t.Optional[Path]) -> str: + item_path = Path(item.path if hasattr(item, "path") else getattr(item, "fspath", "")).absolute() + if workspace_path is not None: + try: + # TODO: use item_path.relative_to(workspace_path).as_posix() on Windows + return str(item_path.relative_to(workspace_path)) + except ValueError: + pass + return str(item_path) + + +@pytest.hookimpl(tryfirst=True) +def pytest_collection_finish(session: pytest.Session) -> None: + if not is_discovery_mode_enabled(): + return + + workspace_path: t.Optional[Path] = None + try: + from ddtrace.testing.internal.git import get_workspace_path + + workspace_path = get_workspace_path() + except Exception: + log.debug("Could not determine workspace path for test discovery", exc_info=True) + + output_path = _get_output_path() + output_path.parent.mkdir(parents=True, exist_ok=True) + + with open(output_path, "w") as f: + for item in session.items: + if _is_item_skipped(item): + continue + + _module, suite, name = nodeid_to_names(item.nodeid) + parameters = _get_test_parameters_json(item) + suite_source_file = _get_suite_source_file(item, workspace_path) + + test_info: dict[str, t.Any] = { + "name": name, + "suite": suite, + "module": _FRAMEWORK, + "parameters": parameters, + "suiteSourceFile": suite_source_file, + } + f.write(json.dumps(test_info) + "\n") + + log.info("Test discovery complete: wrote tests to %s", output_path) + pytest.exit("Test discovery complete", returncode=0) diff --git a/ddtrace/testing/internal/pytest/plugin.py b/ddtrace/testing/internal/pytest/plugin.py index 6f5d3901737..cd54990b40d 100644 --- a/ddtrace/testing/internal/pytest/plugin.py +++ b/ddtrace/testing/internal/pytest/plugin.py @@ -2,10 +2,8 @@ from collections import defaultdict from io import StringIO -import json import logging from pathlib import Path -import re import traceback import typing as t @@ -53,6 +51,10 @@ from ddtrace.testing.internal.tracer_api.coverage import install_coverage_percentage from ddtrace.testing.internal.tracer_api.coverage import uninstall_coverage_percentage import ddtrace.testing.internal.tracer_api.pytest_hooks +from ddtrace.testing.internal.pytest._discovery import is_discovery_mode_enabled +from ddtrace.testing.internal.pytest._discovery import pytest_collection_finish as pytest_collection_finish # noqa: F401 +from ddtrace.testing.internal.pytest.utils import _encode_test_parameter # noqa: F401 +from ddtrace.testing.internal.pytest.utils import _get_test_parameters_json from ddtrace.testing.internal.utils import TestContext from ddtrace.testing.internal.utils import asbool @@ -1074,6 +1076,9 @@ def _is_enabled_early(early_config: pytest.Config, args: list[str]) -> bool: if _is_test_optimization_disabled_by_kill_switch(): return False + if is_discovery_mode_enabled(): + return False + if _is_option_true("no-ddtrace", early_config, args): return False @@ -1243,38 +1248,10 @@ def _get_user_property(report: pytest.TestReport, user_property: str) -> t.Optio return None -def _get_test_parameters_json(item: pytest.Item) -> t.Optional[str]: - callspec: t.Optional[pytest.python.CallSpec2] = getattr(item, "callspec", None) - - if callspec is None: - return None - - parameters: dict[str, dict[str, str]] = {"arguments": {}, "metadata": {}} - for param_name, param_val in item.callspec.params.items(): - try: - parameters["arguments"][param_name] = _encode_test_parameter(param_val) - except Exception: - parameters["arguments"][param_name] = "Could not encode" - log.warning("Failed to encode %r", param_name, exc_info=True) - - try: - return json.dumps(parameters, sort_keys=True) - except TypeError: - log.warning("Failed to serialize parameters for test %s", item, exc_info=True) - return None - - def _get_test_original_name(item: pytest.Item) -> t.Optional[str]: return getattr(item, "originalname", None) -def _encode_test_parameter(parameter: t.Any) -> str: - param_repr = repr(parameter) - # if the representation includes an id() we'll remove it - # because it isn't constant across executions - return re.sub(r" at 0[xX][0-9a-fA-F]+", "", param_repr) - - def _get_skipif_condition(marker: pytest.Mark) -> t.Any: # DEV: pytest allows the condition to be a string to be evaluated. We currently don't support this. if marker.args: diff --git a/ddtrace/testing/internal/pytest/utils.py b/ddtrace/testing/internal/pytest/utils.py index b745360f3f6..05dbe5c1265 100644 --- a/ddtrace/testing/internal/pytest/utils.py +++ b/ddtrace/testing/internal/pytest/utils.py @@ -1,4 +1,9 @@ +from __future__ import annotations + +import json +import logging import re +import typing as t import pytest @@ -7,6 +12,8 @@ from ddtrace.testing.internal.test_data import TestRef +log = logging.getLogger(__name__) + _NODEID_REGEX = re.compile("^(((?P.*)/)?(?P[^/]*?))::(?P.*?)$") @@ -40,3 +47,31 @@ def item_to_test_ref(item: pytest.Item) -> TestRef: test_ref = TestRef(suite_ref, custom_test or default_test) return test_ref + + +def _encode_test_parameter(parameter: t.Any) -> str: + param_repr = repr(parameter) + # if the representation includes an id() we'll remove it + # because it isn't constant across executions + return re.sub(r" at 0[xX][0-9a-fA-F]+", "", param_repr) + + +def _get_test_parameters_json(item: pytest.Item) -> t.Optional[str]: + callspec: t.Optional[pytest.python.CallSpec2] = getattr(item, "callspec", None) + + if callspec is None: + return None + + parameters: dict[str, dict[str, str]] = {"arguments": {}, "metadata": {}} + for param_name, param_val in item.callspec.params.items(): + try: + parameters["arguments"][param_name] = _encode_test_parameter(param_val) + except Exception: + parameters["arguments"][param_name] = "Could not encode" + log.warning("Failed to encode %r", param_name, exc_info=True) + + try: + return json.dumps(parameters, sort_keys=True) + except TypeError: + log.warning("Failed to serialize parameters for test %s", item, exc_info=True) + return None diff --git a/tests/testing/internal/pytest/test_pytest_discovery.py b/tests/testing/internal/pytest/test_pytest_discovery.py new file mode 100644 index 00000000000..7a1bae23609 --- /dev/null +++ b/tests/testing/internal/pytest/test_pytest_discovery.py @@ -0,0 +1,226 @@ +"""Integration tests for the test discovery mode (DD_CI_TEST_DISCOVERY_MODE_ENABLED).""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from _pytest.pytester import Pytester +import pytest + + +class TestDiscoveryMode: + """Tests for pytest_collection_finish in discovery mode. + + Discovery mode: DD_CI_TEST_DISCOVERY_MODE_ENABLED=true causes pytest to write + collected tests as JSON Lines to a file and exit without running any tests. + """ + + @pytest.fixture(autouse=True) + def output_file(self, pytester: Pytester, tmp_path: Path) -> Path: + """Use a stable output path inside the pytester tmpdir for each test.""" + output = pytester.path / "discovery_output.json" + pytester.monkeypatch.setenv("DD_CI_TEST_DISCOVERY_MODE_ENABLED", "true") + pytester.monkeypatch.setenv("DD_CI_TEST_DISCOVERY_OUTPUT_PATH", str(output)) + return output + + def _read_discovered(self, output_file: Path) -> list[dict]: + return [json.loads(line) for line in output_file.read_text().splitlines() if line.strip()] + + # ------------------------------------------------------------------ + # Basic behaviour + # ------------------------------------------------------------------ + + def test_writes_json_lines_file(self, pytester: Pytester, output_file: Path) -> None: + """Discovery mode writes one JSON object per line for each collected test.""" + pytester.makepyfile( + test_foo=""" + def test_alpha(): + pass + + def test_beta(): + pass + """ + ) + + result = pytester.inline_run() + + assert result.ret == 0 + tests = self._read_discovered(output_file) + assert len(tests) == 2 + names = {t["name"] for t in tests} + assert names == {"test_alpha", "test_beta"} + + def test_does_not_run_tests(self, pytester: Pytester, output_file: Path) -> None: + """Discovery mode exits after collection — test bodies are never executed.""" + pytester.makepyfile( + test_boom=""" + def test_would_fail(): + raise RuntimeError("This should never run") + """ + ) + + result = pytester.inline_run() + + assert result.ret == 0 + + def test_json_fields_are_correct(self, pytester: Pytester, output_file: Path) -> None: + """Each JSON object has name, suite, module, parameters, suiteSourceFile.""" + pytester.makepyfile( + test_fields=""" + def test_something(): + pass + """ + ) + + pytester.inline_run() + + (entry,) = self._read_discovered(output_file) + assert entry["name"] == "test_something" + assert entry["suite"] == "test_fields.py" + assert entry["module"] == "pytest" + assert entry["parameters"] is None + assert entry["suiteSourceFile"].endswith("test_fields.py") + + def test_exit_code_zero(self, pytester: Pytester, output_file: Path) -> None: + """Discovery mode always exits with code 0.""" + pytester.makepyfile(test_x="def test_pass(): pass") + result = pytester.inline_run() + assert result.ret == 0 + + # ------------------------------------------------------------------ + # Parametrized tests + # ------------------------------------------------------------------ + + def test_parametrized_tests_include_parameters(self, pytester: Pytester, output_file: Path) -> None: + """Parametrized tests have their parameters encoded in the parameters field.""" + pytester.makepyfile( + test_params=""" + import pytest + + @pytest.mark.parametrize("x,y", [(1, 2), (3, 4)]) + def test_add(x, y): + pass + """ + ) + + pytester.inline_run() + + tests = self._read_discovered(output_file) + assert len(tests) == 2 + for entry in tests: + assert entry["parameters"] is not None + params = json.loads(entry["parameters"]) + assert "arguments" in params + assert "x" in params["arguments"] + assert "y" in params["arguments"] + + # ------------------------------------------------------------------ + # Skip filtering + # ------------------------------------------------------------------ + + def test_skip_marked_tests_are_excluded(self, pytester: Pytester, output_file: Path) -> None: + """Tests decorated with @pytest.mark.skip are excluded from the output.""" + pytester.makepyfile( + test_skip=""" + import pytest + + @pytest.mark.skip(reason="not ready") + def test_skipped(): + pass + + def test_included(): + pass + """ + ) + + pytester.inline_run() + + tests = self._read_discovered(output_file) + assert len(tests) == 1 + assert tests[0]["name"] == "test_included" + + def test_skipif_true_tests_are_excluded(self, pytester: Pytester, output_file: Path) -> None: + """Tests with @pytest.mark.skipif(True, ...) are excluded from the output.""" + pytester.makepyfile( + test_skipif=""" + import pytest + + @pytest.mark.skipif(True, reason="always skip") + def test_always_skipped(): + pass + + @pytest.mark.skipif(False, reason="never skip") + def test_never_skipped(): + pass + """ + ) + + pytester.inline_run() + + tests = self._read_discovered(output_file) + assert len(tests) == 1 + assert tests[0]["name"] == "test_never_skipped" + + def test_skipif_with_version_check(self, pytester: Pytester, output_file: Path) -> None: + """Tests with @pytest.mark.skipif(sys.version_info...) are handled correctly.""" + always_true = sys.version_info >= (2, 0) + always_false = sys.version_info >= (99, 0) + + pytester.makepyfile( + test_version=f""" + import sys + import pytest + + @pytest.mark.skipif(sys.version_info >= (2, 0), reason="skipped on all Pythons") + def test_skipped_on_all(): + pass + + @pytest.mark.skipif(sys.version_info >= (99, 0), reason="only on Python 99+") + def test_included_on_all(): + pass + """ + ) + + pytester.inline_run() + + tests = self._read_discovered(output_file) + names = {t["name"] for t in tests} + + if always_true: + assert "test_skipped_on_all" not in names + if not always_false: + assert "test_included_on_all" in names + + def test_skipif_string_condition_included(self, pytester: Pytester, output_file: Path) -> None: + """Tests with string-form skipif conditions are conservatively included.""" + pytester.makepyfile( + test_string_skip=""" + import pytest + + @pytest.mark.skipif("True", reason="string condition not evaluated") + def test_with_string_condition(): + pass + """ + ) + + pytester.inline_run() + + tests = self._read_discovered(output_file) + assert len(tests) == 1 + assert tests[0]["name"] == "test_with_string_condition" + + # ------------------------------------------------------------------ + # Interaction with --ddtrace + # ------------------------------------------------------------------ + + def test_discovery_mode_disables_ci_visibility(self, pytester: Pytester, output_file: Path) -> None: + """When discovery mode is active, CI visibility is not initialised even if --ddtrace is passed.""" + pytester.makepyfile(test_ci="def test_one(): pass") + + # Should exit cleanly with no API key / agent configured + result = pytester.inline_run("--ddtrace") + assert result.ret == 0 + tests = self._read_discovered(output_file) + assert len(tests) == 1 From 7f5262061520c1597f6ec24ccebca25ad519bd15 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Wed, 10 Jun 2026 11:26:16 +0000 Subject: [PATCH 02/15] fix(ci_visibility): use env.get() instead of os.environ.get in discovery module Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/testing/internal/pytest/_discovery.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ddtrace/testing/internal/pytest/_discovery.py b/ddtrace/testing/internal/pytest/_discovery.py index a0f9051d2c3..258acdc5797 100644 --- a/ddtrace/testing/internal/pytest/_discovery.py +++ b/ddtrace/testing/internal/pytest/_discovery.py @@ -2,12 +2,12 @@ import json import logging -import os from pathlib import Path import typing as t import pytest +from ddtrace.internal.settings import env from ddtrace.testing.internal.pytest.utils import _get_test_parameters_json from ddtrace.testing.internal.pytest.utils import nodeid_to_names from ddtrace.testing.internal.utils import asbool @@ -22,11 +22,11 @@ def is_discovery_mode_enabled() -> bool: - return asbool(os.environ.get(_ENV_ENABLED)) + return asbool(env.get(_ENV_ENABLED)) def _get_output_path() -> Path: - return Path(os.environ.get(_ENV_OUTPUT_PATH, _DEFAULT_OUTPUT_PATH)) + return Path(env.get(_ENV_OUTPUT_PATH, _DEFAULT_OUTPUT_PATH)) def _is_item_skipped(item: pytest.Item) -> bool: From 221015a7e672e215c050593841ff402a9e3a60ed Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Wed, 10 Jun 2026 11:28:43 +0000 Subject: [PATCH 03/15] fix(ci_visibility): adopt ddtest env var names for discovery mode Use DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED and DD_TEST_OPTIMIZATION_DISCOVERY_FILE (ddtest's canonical names) instead of the Ruby-aligned DD_CI_TEST_DISCOVERY_MODE_ENABLED / DD_CI_TEST_DISCOVERY_OUTPUT_PATH. ddtest owns the shared env var contract; individual language plugins should conform to it. Also update the default output path to match ddtest's .testoptimization/tests-discovery/tests.json. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/testing/internal/pytest/_discovery.py | 6 +++--- tests/testing/internal/pytest/test_pytest_discovery.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ddtrace/testing/internal/pytest/_discovery.py b/ddtrace/testing/internal/pytest/_discovery.py index 258acdc5797..618cb17bcee 100644 --- a/ddtrace/testing/internal/pytest/_discovery.py +++ b/ddtrace/testing/internal/pytest/_discovery.py @@ -15,9 +15,9 @@ log = logging.getLogger(__name__) -_ENV_ENABLED = "DD_CI_TEST_DISCOVERY_MODE_ENABLED" -_ENV_OUTPUT_PATH = "DD_CI_TEST_DISCOVERY_OUTPUT_PATH" -_DEFAULT_OUTPUT_PATH = "ddtest/test_discovery/tests.json" +_ENV_ENABLED = "DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED" +_ENV_OUTPUT_PATH = "DD_TEST_OPTIMIZATION_DISCOVERY_FILE" +_DEFAULT_OUTPUT_PATH = ".testoptimization/tests-discovery/tests.json" _FRAMEWORK = "pytest" diff --git a/tests/testing/internal/pytest/test_pytest_discovery.py b/tests/testing/internal/pytest/test_pytest_discovery.py index 7a1bae23609..e07776bf239 100644 --- a/tests/testing/internal/pytest/test_pytest_discovery.py +++ b/tests/testing/internal/pytest/test_pytest_discovery.py @@ -1,4 +1,4 @@ -"""Integration tests for the test discovery mode (DD_CI_TEST_DISCOVERY_MODE_ENABLED).""" +"""Integration tests for the test discovery mode (DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED).""" from __future__ import annotations @@ -13,7 +13,7 @@ class TestDiscoveryMode: """Tests for pytest_collection_finish in discovery mode. - Discovery mode: DD_CI_TEST_DISCOVERY_MODE_ENABLED=true causes pytest to write + Discovery mode: DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED=1 causes pytest to write collected tests as JSON Lines to a file and exit without running any tests. """ @@ -21,8 +21,8 @@ class TestDiscoveryMode: def output_file(self, pytester: Pytester, tmp_path: Path) -> Path: """Use a stable output path inside the pytester tmpdir for each test.""" output = pytester.path / "discovery_output.json" - pytester.monkeypatch.setenv("DD_CI_TEST_DISCOVERY_MODE_ENABLED", "true") - pytester.monkeypatch.setenv("DD_CI_TEST_DISCOVERY_OUTPUT_PATH", str(output)) + pytester.monkeypatch.setenv("DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED", "1") + pytester.monkeypatch.setenv("DD_TEST_OPTIMIZATION_DISCOVERY_FILE", str(output)) return output def _read_discovered(self, output_file: Path) -> list[dict]: From a4b13c31e51b635a3f7af170442c71b4265e4026 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Wed, 10 Jun 2026 11:35:42 +0000 Subject: [PATCH 04/15] fix(ci_visibility): use dotted directory path as module in discovery output The module field must match what the plugin reports at run time so the backend can correlate discovery results with test runs via FQN (module.suite.name). Using the static string "pytest" as module would collapse every test across all repos into the same namespace. nodeid_to_names already extracts the correct value (e.g. "tests.unit" for tests/unit/test_foo.py); we just needed to stop discarding it. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/testing/internal/pytest/_discovery.py | 5 ++-- .../internal/pytest/test_pytest_discovery.py | 29 ++++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/ddtrace/testing/internal/pytest/_discovery.py b/ddtrace/testing/internal/pytest/_discovery.py index 618cb17bcee..ad8a33a6b7d 100644 --- a/ddtrace/testing/internal/pytest/_discovery.py +++ b/ddtrace/testing/internal/pytest/_discovery.py @@ -18,7 +18,6 @@ _ENV_ENABLED = "DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED" _ENV_OUTPUT_PATH = "DD_TEST_OPTIMIZATION_DISCOVERY_FILE" _DEFAULT_OUTPUT_PATH = ".testoptimization/tests-discovery/tests.json" -_FRAMEWORK = "pytest" def is_discovery_mode_enabled() -> bool: @@ -80,14 +79,14 @@ def pytest_collection_finish(session: pytest.Session) -> None: if _is_item_skipped(item): continue - _module, suite, name = nodeid_to_names(item.nodeid) + module, suite, name = nodeid_to_names(item.nodeid) parameters = _get_test_parameters_json(item) suite_source_file = _get_suite_source_file(item, workspace_path) test_info: dict[str, t.Any] = { "name": name, "suite": suite, - "module": _FRAMEWORK, + "module": module, "parameters": parameters, "suiteSourceFile": suite_source_file, } diff --git a/tests/testing/internal/pytest/test_pytest_discovery.py b/tests/testing/internal/pytest/test_pytest_discovery.py index e07776bf239..6c5e6285509 100644 --- a/tests/testing/internal/pytest/test_pytest_discovery.py +++ b/tests/testing/internal/pytest/test_pytest_discovery.py @@ -66,22 +66,37 @@ def test_would_fail(): assert result.ret == 0 def test_json_fields_are_correct(self, pytester: Pytester, output_file: Path) -> None: - """Each JSON object has name, suite, module, parameters, suiteSourceFile.""" + """Each JSON object has name, suite, module, parameters, suiteSourceFile. + + module is the dotted directory path of the test file (matching what the + plugin reports at run time), not the framework name. A root-level file + has an empty module; a nested file carries the dotted path. + """ + # Root-level test: module should be "" pytester.makepyfile( test_fields=""" def test_something(): pass """ ) + # Nested test: module should reflect the subdirectory + pytester.mkdir("subpkg") + pytester.path.joinpath("subpkg", "test_nested.py").write_text("def test_inner(): pass") pytester.inline_run() - (entry,) = self._read_discovered(output_file) - assert entry["name"] == "test_something" - assert entry["suite"] == "test_fields.py" - assert entry["module"] == "pytest" - assert entry["parameters"] is None - assert entry["suiteSourceFile"].endswith("test_fields.py") + tests = {e["suite"]: e for e in self._read_discovered(output_file)} + + root_entry = tests["test_fields.py"] + assert root_entry["name"] == "test_something" + assert root_entry["module"] == "" + assert root_entry["parameters"] is None + assert root_entry["suiteSourceFile"].endswith("test_fields.py") + + nested_entry = tests["test_nested.py"] + assert nested_entry["name"] == "test_inner" + assert nested_entry["module"] == "subpkg" + assert nested_entry["suiteSourceFile"].endswith("test_nested.py") def test_exit_code_zero(self, pytester: Pytester, output_file: Path) -> None: """Discovery mode always exits with code 0.""" From dfe9abd960abda646ddf2d9f960c8daad142c87a Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Wed, 10 Jun 2026 11:46:51 +0000 Subject: [PATCH 05/15] refactor(ci_visibility): share item_to_test_ref code path in discovery mode Register TestOptHooks specs during discovery so item_to_test_ref can call the same custom hook chain used at run time. This means name/suite/ module resolution stays in sync with ITR matching if the logic ever changes, rather than being a separate parallel implementation. pytest-bdd is intentionally not wired up: BddTestOptPlugin is not registered in discovery mode, so BDD tests fall back to nodeid-based names (same as before). A TODO marks where to add that support. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/testing/internal/pytest/_discovery.py | 7 +++++-- ddtrace/testing/internal/pytest/plugin.py | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ddtrace/testing/internal/pytest/_discovery.py b/ddtrace/testing/internal/pytest/_discovery.py index ad8a33a6b7d..719b462e120 100644 --- a/ddtrace/testing/internal/pytest/_discovery.py +++ b/ddtrace/testing/internal/pytest/_discovery.py @@ -9,7 +9,7 @@ from ddtrace.internal.settings import env from ddtrace.testing.internal.pytest.utils import _get_test_parameters_json -from ddtrace.testing.internal.pytest.utils import nodeid_to_names +from ddtrace.testing.internal.pytest.utils import item_to_test_ref from ddtrace.testing.internal.utils import asbool @@ -79,7 +79,10 @@ def pytest_collection_finish(session: pytest.Session) -> None: if _is_item_skipped(item): continue - module, suite, name = nodeid_to_names(item.nodeid) + test_ref = item_to_test_ref(item) + module = test_ref.suite.module.name + suite = test_ref.suite.name + name = test_ref.name parameters = _get_test_parameters_json(item) suite_source_file = _get_suite_source_file(item, workspace_path) diff --git a/ddtrace/testing/internal/pytest/plugin.py b/ddtrace/testing/internal/pytest/plugin.py index cd54990b40d..2da2f92e789 100644 --- a/ddtrace/testing/internal/pytest/plugin.py +++ b/ddtrace/testing/internal/pytest/plugin.py @@ -1143,6 +1143,15 @@ def pytest_configure(config: pytest.Config) -> None: if _is_test_optimization_disabled_by_kill_switch(): return + if is_discovery_mode_enabled(): + # Register hook specs so item_to_test_ref can call the custom name hooks during + # discovery, giving the same module/suite/name resolution as a real test run. + # AIDEV-NOTE: BddTestOptPlugin is not registered here, so pytest-bdd tests will + # fall back to nodeid-based names rather than feature-file names during discovery. + # TODO: register BddTestOptPlugin in discovery mode to support pytest-bdd. + config.pluginmanager.add_hookspecs(TestOptHooks) + return + session_manager = config.stash.get(SESSION_MANAGER_STASH_KEY, None) if not session_manager: log.debug("Session manager not initialized (plugin was not enabled)") From 910db5d173e6d9370cbe9b100dfa9b94b58369ae Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Wed, 10 Jun 2026 11:52:46 +0000 Subject: [PATCH 06/15] chore(testing): register DD_TEST_OPTIMIZATION_DISCOVERY_* env vars Add DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED and DD_TEST_OPTIMIZATION_DISCOVERY_FILE to supported-configurations.json and regenerate _supported_configurations.py so the CI check passes. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/settings/_supported_configurations.py | 2 ++ supported-configurations.json | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/ddtrace/internal/settings/_supported_configurations.py b/ddtrace/internal/settings/_supported_configurations.py index 8912cb27a09..0f3f7d79109 100644 --- a/ddtrace/internal/settings/_supported_configurations.py +++ b/ddtrace/internal/settings/_supported_configurations.py @@ -459,6 +459,8 @@ "DD_TEST_DEBUG", "DD_TEST_MANAGEMENT_ATTEMPT_TO_FIX_RETRIES", "DD_TEST_MANAGEMENT_ENABLED", + "DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED", + "DD_TEST_OPTIMIZATION_DISCOVERY_FILE", "DD_TEST_OPTIMIZATION_ENV_DATA_FILE", "DD_TEST_OPTIMIZATION_MANIFEST_FILE", "DD_TEST_OPTIMIZATION_PAYLOADS_IN_FILES", diff --git a/supported-configurations.json b/supported-configurations.json index 001dbfffe7c..712465bb744 100644 --- a/supported-configurations.json +++ b/supported-configurations.json @@ -3514,6 +3514,20 @@ "default": null } ], + "DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "false" + } + ], + "DD_TEST_OPTIMIZATION_DISCOVERY_FILE": [ + { + "implementation": "A", + "type": "string", + "default": null + } + ], "DD_TEST_OPTIMIZATION_ENV_DATA_FILE": [ { "implementation": "A", From cbd52f3ad945c40d4b73aa2721c1d1726941eaf4 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Wed, 10 Jun 2026 12:21:37 +0000 Subject: [PATCH 07/15] fix(testing): fix import order and discovery hook registration in pytest plugin - Sort discovery imports alphabetically into the ddtrace.testing block - Drop the pytest_collection_finish module-level re-export (caused F811 clash with BddTestOptPlugin.pytest_collection_finish); register the _discovery module directly as a plugin in pytest_configure instead - Fix import order and remove spurious f-string prefix in test_pytest_discovery.py Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/testing/internal/pytest/plugin.py | 10 ++++++---- tests/testing/internal/pytest/test_pytest_discovery.py | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ddtrace/testing/internal/pytest/plugin.py b/ddtrace/testing/internal/pytest/plugin.py index 2da2f92e789..6829f1ac03b 100644 --- a/ddtrace/testing/internal/pytest/plugin.py +++ b/ddtrace/testing/internal/pytest/plugin.py @@ -25,11 +25,14 @@ from ddtrace.testing.internal.logging import catch_and_log_exceptions from ddtrace.testing.internal.logging import setup_logging from ddtrace.testing.internal.offline_mode import get_offline_mode +from ddtrace.testing.internal.pytest._discovery import is_discovery_mode_enabled from ddtrace.testing.internal.pytest.bdd import BddTestOptPlugin from ddtrace.testing.internal.pytest.benchmark import BenchmarkData from ddtrace.testing.internal.pytest.benchmark import get_benchmark_tags_and_metrics from ddtrace.testing.internal.pytest.hookspecs import TestOptHooks from ddtrace.testing.internal.pytest.report_links import print_test_report_links +from ddtrace.testing.internal.pytest.utils import _encode_test_parameter # noqa: F401 +from ddtrace.testing.internal.pytest.utils import _get_test_parameters_json from ddtrace.testing.internal.pytest.utils import item_to_test_ref from ddtrace.testing.internal.retry_handlers import RetryHandler from ddtrace.testing.internal.session_manager import SessionManager @@ -51,10 +54,6 @@ from ddtrace.testing.internal.tracer_api.coverage import install_coverage_percentage from ddtrace.testing.internal.tracer_api.coverage import uninstall_coverage_percentage import ddtrace.testing.internal.tracer_api.pytest_hooks -from ddtrace.testing.internal.pytest._discovery import is_discovery_mode_enabled -from ddtrace.testing.internal.pytest._discovery import pytest_collection_finish as pytest_collection_finish # noqa: F401 -from ddtrace.testing.internal.pytest.utils import _encode_test_parameter # noqa: F401 -from ddtrace.testing.internal.pytest.utils import _get_test_parameters_json from ddtrace.testing.internal.utils import TestContext from ddtrace.testing.internal.utils import asbool @@ -1149,7 +1148,10 @@ def pytest_configure(config: pytest.Config) -> None: # AIDEV-NOTE: BddTestOptPlugin is not registered here, so pytest-bdd tests will # fall back to nodeid-based names rather than feature-file names during discovery. # TODO: register BddTestOptPlugin in discovery mode to support pytest-bdd. + import ddtrace.testing.internal.pytest._discovery as _ddtrace_discovery + config.pluginmanager.add_hookspecs(TestOptHooks) + config.pluginmanager.register(_ddtrace_discovery, "_ddtrace_discovery") return session_manager = config.stash.get(SESSION_MANAGER_STASH_KEY, None) diff --git a/tests/testing/internal/pytest/test_pytest_discovery.py b/tests/testing/internal/pytest/test_pytest_discovery.py index 6c5e6285509..7bb4587046e 100644 --- a/tests/testing/internal/pytest/test_pytest_discovery.py +++ b/tests/testing/internal/pytest/test_pytest_discovery.py @@ -3,8 +3,8 @@ from __future__ import annotations import json -import sys from pathlib import Path +import sys from _pytest.pytester import Pytester import pytest @@ -184,7 +184,7 @@ def test_skipif_with_version_check(self, pytester: Pytester, output_file: Path) always_false = sys.version_info >= (99, 0) pytester.makepyfile( - test_version=f""" + test_version=""" import sys import pytest From 70b4b0a039ee66aa1dfcbc4f6283669e8a47d553 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Wed, 10 Jun 2026 13:24:49 +0000 Subject: [PATCH 08/15] fix(testing): suppress mypy misc error on pytest.hookimpl decorator Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/testing/internal/pytest/_discovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ddtrace/testing/internal/pytest/_discovery.py b/ddtrace/testing/internal/pytest/_discovery.py index 719b462e120..e12a263532b 100644 --- a/ddtrace/testing/internal/pytest/_discovery.py +++ b/ddtrace/testing/internal/pytest/_discovery.py @@ -58,7 +58,7 @@ def _get_suite_source_file(item: pytest.Item, workspace_path: t.Optional[Path]) return str(item_path) -@pytest.hookimpl(tryfirst=True) +@pytest.hookimpl(tryfirst=True) # type: ignore[misc] def pytest_collection_finish(session: pytest.Session) -> None: if not is_discovery_mode_enabled(): return From 05c99777bb5c669a8b4c1c3260043c762f3255df Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Wed, 10 Jun 2026 13:50:11 +0000 Subject: [PATCH 09/15] fix(testing): use monkeypatch fixture instead of pytester.monkeypatch pytester.monkeypatch was added in pytest 6.2; older envs (py3.9 CI) don't have it. Accept monkeypatch as a separate fixture parameter. Co-Authored-By: Claude Sonnet 4.6 --- tests/testing/internal/pytest/test_pytest_discovery.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/testing/internal/pytest/test_pytest_discovery.py b/tests/testing/internal/pytest/test_pytest_discovery.py index 7bb4587046e..9bbe054333d 100644 --- a/tests/testing/internal/pytest/test_pytest_discovery.py +++ b/tests/testing/internal/pytest/test_pytest_discovery.py @@ -18,11 +18,11 @@ class TestDiscoveryMode: """ @pytest.fixture(autouse=True) - def output_file(self, pytester: Pytester, tmp_path: Path) -> Path: + def output_file(self, pytester: Pytester, monkeypatch: pytest.MonkeyPatch) -> Path: """Use a stable output path inside the pytester tmpdir for each test.""" output = pytester.path / "discovery_output.json" - pytester.monkeypatch.setenv("DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED", "1") - pytester.monkeypatch.setenv("DD_TEST_OPTIMIZATION_DISCOVERY_FILE", str(output)) + monkeypatch.setenv("DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED", "1") + monkeypatch.setenv("DD_TEST_OPTIMIZATION_DISCOVERY_FILE", str(output)) return output def _read_discovered(self, output_file: Path) -> list[dict]: From c9050e9186cc423e44754fb927ef49d7258bda18 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Thu, 11 Jun 2026 15:15:11 +0000 Subject: [PATCH 10/15] fix(ci_visibility): treat bare skipif (no condition) as unconditional skip in discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @pytest.mark.skipif with no condition argument yields condition=None in _is_item_skipped. Previously this fell through to continue, so such tests were included in discovery output even though pytest treats missing conditions as unconditional skip (matching plugin.py's _get_skipif_condition). Align the None branch with plugin.py: condition is None → return True. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/testing/internal/pytest/_discovery.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ddtrace/testing/internal/pytest/_discovery.py b/ddtrace/testing/internal/pytest/_discovery.py index e12a263532b..da2aceb0b18 100644 --- a/ddtrace/testing/internal/pytest/_discovery.py +++ b/ddtrace/testing/internal/pytest/_discovery.py @@ -40,9 +40,11 @@ def _is_item_skipped(item: pytest.Item) -> bool: return True for marker in item.iter_markers("skipif"): condition = marker.args[0] if marker.args else marker.kwargs.get("condition") - if condition is None or isinstance(condition, str): + if isinstance(condition, str): continue - if condition: + # No condition arg at all (e.g. @pytest.mark.skipif(reason="...")) is + # treated as unconditional skip, matching plugin.py's _get_skipif_condition. + if condition is None or condition: return True return False From 2852eb14760c1ac362b877b8951d45babd7a62dc Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Thu, 11 Jun 2026 15:47:30 +0000 Subject: [PATCH 11/15] revert(ci_visibility): restore original condition=None handling in _is_item_skipped pytest >= 7 raises a collection error for @pytest.mark.skipif with no condition argument, so such items never reach _is_item_skipped. Treating condition=None as an unconditional skip would diverge from pytest's own behaviour and handle a case that can't occur in practice. Restore the original: condition=None conservatively includes the test, same as a string condition that cannot be evaluated at collection time. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/testing/internal/pytest/_discovery.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ddtrace/testing/internal/pytest/_discovery.py b/ddtrace/testing/internal/pytest/_discovery.py index da2aceb0b18..e12a263532b 100644 --- a/ddtrace/testing/internal/pytest/_discovery.py +++ b/ddtrace/testing/internal/pytest/_discovery.py @@ -40,11 +40,9 @@ def _is_item_skipped(item: pytest.Item) -> bool: return True for marker in item.iter_markers("skipif"): condition = marker.args[0] if marker.args else marker.kwargs.get("condition") - if isinstance(condition, str): + if condition is None or isinstance(condition, str): continue - # No condition arg at all (e.g. @pytest.mark.skipif(reason="...")) is - # treated as unconditional skip, matching plugin.py's _get_skipif_condition. - if condition is None or condition: + if condition: return True return False From 986172dcfb81779398dad388b55da6099ee7d713 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Thu, 11 Jun 2026 16:00:29 +0000 Subject: [PATCH 12/15] docs(ci_visibility): comment why condition=None/str are conservatively included Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/testing/internal/pytest/_discovery.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ddtrace/testing/internal/pytest/_discovery.py b/ddtrace/testing/internal/pytest/_discovery.py index e12a263532b..e036401a356 100644 --- a/ddtrace/testing/internal/pytest/_discovery.py +++ b/ddtrace/testing/internal/pytest/_discovery.py @@ -41,6 +41,10 @@ def _is_item_skipped(item: pytest.Item) -> bool: for marker in item.iter_markers("skipif"): condition = marker.args[0] if marker.args else marker.kwargs.get("condition") if condition is None or isinstance(condition, str): + # String conditions require eval in the test module's namespace, which + # we can't do safely at collection time. None conditions can't arise + # in practice (pytest >= 7 rejects bare skipif with no condition arg). + # Conservatively include in both cases rather than risk hiding a test. continue if condition: return True From d47bea6be9ce65ef2bb55162a2360d80b6e424e8 Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Thu, 11 Jun 2026 16:17:43 +0000 Subject: [PATCH 13/15] docs(ci_visibility): correct comment about bare skipif (no condition arg) pytest 9 accepts @pytest.mark.skipif(reason=...) without a condition and skips the test at runtime, but condition is still None at collection time. The conservative-include behaviour is correct; update the comment to reflect the actual pytest 9 behaviour rather than claiming it can't arise. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/testing/internal/pytest/_discovery.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ddtrace/testing/internal/pytest/_discovery.py b/ddtrace/testing/internal/pytest/_discovery.py index e036401a356..49c38141255 100644 --- a/ddtrace/testing/internal/pytest/_discovery.py +++ b/ddtrace/testing/internal/pytest/_discovery.py @@ -42,8 +42,10 @@ def _is_item_skipped(item: pytest.Item) -> bool: condition = marker.args[0] if marker.args else marker.kwargs.get("condition") if condition is None or isinstance(condition, str): # String conditions require eval in the test module's namespace, which - # we can't do safely at collection time. None conditions can't arise - # in practice (pytest >= 7 rejects bare skipif with no condition arg). + # we can't do safely at collection time. None conditions arise when + # @pytest.mark.skipif is used with only a reason= kwarg and no positional + # condition — pytest 9 accepts this and skips the test at runtime, but + # does not signal it during collection. # Conservatively include in both cases rather than risk hiding a test. continue if condition: From c12c6409efd73421d238c1c60ac0df2d1c59bb1f Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Thu, 11 Jun 2026 17:26:08 +0000 Subject: [PATCH 14/15] chore(ci_visibility): address PR review comments on discovery mode - Move DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED/FILE constants to ddtrace.testing.internal.constants alongside other DD_TEST_OPTIMIZATION_* constants to centralize config loading - Move get_workspace_path import to module level (no circular dependency) - Fix Windows path bug: use .as_posix() for consistent forward slashes - Open output file with explicit encoding="utf-8" - Remove unused _encode_test_parameter re-export from plugin.py; update test_plugin.py to import directly from utils.py - Add test for empty collection (output file created but empty) Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/testing/internal/constants.py | 4 ++++ ddtrace/testing/internal/pytest/_discovery.py | 16 +++++++--------- ddtrace/testing/internal/pytest/plugin.py | 1 - tests/testing/internal/pytest/test_plugin.py | 2 +- .../internal/pytest/test_pytest_discovery.py | 10 ++++++++++ 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/ddtrace/testing/internal/constants.py b/ddtrace/testing/internal/constants.py index 1f8a40c393c..56d0bac71e8 100644 --- a/ddtrace/testing/internal/constants.py +++ b/ddtrace/testing/internal/constants.py @@ -25,5 +25,9 @@ class ITRSkippingLevel(Enum): DD_TEST_OPTIMIZATION_ENV_DATA_FILE = "DD_TEST_OPTIMIZATION_ENV_DATA_FILE" TEST_UNDECLARED_OUTPUTS_DIR = "TEST_UNDECLARED_OUTPUTS_DIR" +# Test discovery environment variables +DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED = "DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED" +DD_TEST_OPTIMIZATION_DISCOVERY_FILE = "DD_TEST_OPTIMIZATION_DISCOVERY_FILE" + # The only supported .testoptimization manifest version SUPPORTED_MANIFEST_VERSION = 1 diff --git a/ddtrace/testing/internal/pytest/_discovery.py b/ddtrace/testing/internal/pytest/_discovery.py index 49c38141255..1a42de9fc2d 100644 --- a/ddtrace/testing/internal/pytest/_discovery.py +++ b/ddtrace/testing/internal/pytest/_discovery.py @@ -8,6 +8,9 @@ import pytest from ddtrace.internal.settings import env +from ddtrace.testing.internal.constants import DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED +from ddtrace.testing.internal.constants import DD_TEST_OPTIMIZATION_DISCOVERY_FILE +from ddtrace.testing.internal.git import get_workspace_path from ddtrace.testing.internal.pytest.utils import _get_test_parameters_json from ddtrace.testing.internal.pytest.utils import item_to_test_ref from ddtrace.testing.internal.utils import asbool @@ -15,17 +18,15 @@ log = logging.getLogger(__name__) -_ENV_ENABLED = "DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED" -_ENV_OUTPUT_PATH = "DD_TEST_OPTIMIZATION_DISCOVERY_FILE" _DEFAULT_OUTPUT_PATH = ".testoptimization/tests-discovery/tests.json" def is_discovery_mode_enabled() -> bool: - return asbool(env.get(_ENV_ENABLED)) + return asbool(env.get(DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED)) def _get_output_path() -> Path: - return Path(env.get(_ENV_OUTPUT_PATH, _DEFAULT_OUTPUT_PATH)) + return Path(env.get(DD_TEST_OPTIMIZATION_DISCOVERY_FILE, _DEFAULT_OUTPUT_PATH)) def _is_item_skipped(item: pytest.Item) -> bool: @@ -57,8 +58,7 @@ def _get_suite_source_file(item: pytest.Item, workspace_path: t.Optional[Path]) item_path = Path(item.path if hasattr(item, "path") else getattr(item, "fspath", "")).absolute() if workspace_path is not None: try: - # TODO: use item_path.relative_to(workspace_path).as_posix() on Windows - return str(item_path.relative_to(workspace_path)) + return item_path.relative_to(workspace_path).as_posix() except ValueError: pass return str(item_path) @@ -71,8 +71,6 @@ def pytest_collection_finish(session: pytest.Session) -> None: workspace_path: t.Optional[Path] = None try: - from ddtrace.testing.internal.git import get_workspace_path - workspace_path = get_workspace_path() except Exception: log.debug("Could not determine workspace path for test discovery", exc_info=True) @@ -80,7 +78,7 @@ def pytest_collection_finish(session: pytest.Session) -> None: output_path = _get_output_path() output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, "w") as f: + with open(output_path, "w", encoding="utf-8") as f: for item in session.items: if _is_item_skipped(item): continue diff --git a/ddtrace/testing/internal/pytest/plugin.py b/ddtrace/testing/internal/pytest/plugin.py index 6829f1ac03b..d09e9c6b21e 100644 --- a/ddtrace/testing/internal/pytest/plugin.py +++ b/ddtrace/testing/internal/pytest/plugin.py @@ -31,7 +31,6 @@ from ddtrace.testing.internal.pytest.benchmark import get_benchmark_tags_and_metrics from ddtrace.testing.internal.pytest.hookspecs import TestOptHooks from ddtrace.testing.internal.pytest.report_links import print_test_report_links -from ddtrace.testing.internal.pytest.utils import _encode_test_parameter # noqa: F401 from ddtrace.testing.internal.pytest.utils import _get_test_parameters_json from ddtrace.testing.internal.pytest.utils import item_to_test_ref from ddtrace.testing.internal.retry_handlers import RetryHandler diff --git a/tests/testing/internal/pytest/test_plugin.py b/tests/testing/internal/pytest/test_plugin.py index 05f046e5bdf..4cbdca449ce 100644 --- a/tests/testing/internal/pytest/test_plugin.py +++ b/tests/testing/internal/pytest/test_plugin.py @@ -16,7 +16,7 @@ from ddtrace.testing.internal.pytest.plugin import SKIPPED_BY_ITR_REASON from ddtrace.testing.internal.pytest.plugin import TestOptPlugin from ddtrace.testing.internal.pytest.plugin import XdistTestOptPlugin -from ddtrace.testing.internal.pytest.plugin import _encode_test_parameter +from ddtrace.testing.internal.pytest.utils import _encode_test_parameter from ddtrace.testing.internal.pytest.plugin import _get_exception_tags from ddtrace.testing.internal.pytest.plugin import _get_module_path_from_item from ddtrace.testing.internal.pytest.plugin import _get_source_lines diff --git a/tests/testing/internal/pytest/test_pytest_discovery.py b/tests/testing/internal/pytest/test_pytest_discovery.py index 9bbe054333d..77c00bcd520 100644 --- a/tests/testing/internal/pytest/test_pytest_discovery.py +++ b/tests/testing/internal/pytest/test_pytest_discovery.py @@ -52,6 +52,16 @@ def test_beta(): names = {t["name"] for t in tests} assert names == {"test_alpha", "test_beta"} + def test_no_items_collected_writes_empty_file(self, pytester: Pytester, output_file: Path) -> None: + """When no tests are collected the output file is created but empty.""" + pytester.makepyfile(test_empty="") + + result = pytester.inline_run() + + assert result.ret == 0 + assert output_file.exists() + assert output_file.read_text(encoding="utf-8") == "" + def test_does_not_run_tests(self, pytester: Pytester, output_file: Path) -> None: """Discovery mode exits after collection — test bodies are never executed.""" pytester.makepyfile( From b2b56f3f72db33cc64b5e5526c73a08d4392b29c Mon Sep 17 00:00:00 2001 From: Federico Mon Date: Thu, 11 Jun 2026 18:23:42 +0000 Subject: [PATCH 15/15] chore(ci_visibility): fix import sort order in test_plugin.py Co-Authored-By: Claude Sonnet 4.6 --- tests/testing/internal/pytest/test_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testing/internal/pytest/test_plugin.py b/tests/testing/internal/pytest/test_plugin.py index 4cbdca449ce..003bec39842 100644 --- a/tests/testing/internal/pytest/test_plugin.py +++ b/tests/testing/internal/pytest/test_plugin.py @@ -16,7 +16,6 @@ from ddtrace.testing.internal.pytest.plugin import SKIPPED_BY_ITR_REASON from ddtrace.testing.internal.pytest.plugin import TestOptPlugin from ddtrace.testing.internal.pytest.plugin import XdistTestOptPlugin -from ddtrace.testing.internal.pytest.utils import _encode_test_parameter from ddtrace.testing.internal.pytest.plugin import _get_exception_tags from ddtrace.testing.internal.pytest.plugin import _get_module_path_from_item from ddtrace.testing.internal.pytest.plugin import _get_source_lines @@ -25,6 +24,7 @@ from ddtrace.testing.internal.pytest.plugin import _get_test_original_name from ddtrace.testing.internal.pytest.plugin import _get_test_parameters_json from ddtrace.testing.internal.pytest.plugin import _get_user_property +from ddtrace.testing.internal.pytest.utils import _encode_test_parameter from ddtrace.testing.internal.pytest.utils import nodeid_to_names from ddtrace.testing.internal.test_data import TestStatus from ddtrace.testing.internal.test_data import TestTag