Skip to content

fix(ci_visibility): handle PYTEST_ADDOPTS for enabling code coverage collection [backport 3.9] #13828

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ddtrace/contrib/internal/pytest/_plugin_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ def _get_test_class_hierarchy(item):


def pytest_load_initial_conftests(early_config, parser, args):
if _is_enabled_early(early_config):
if _is_enabled_early(early_config, args):
# Enables experimental use of ModuleCodeCollector for coverage collection.
from ddtrace.internal.ci_visibility.coverage import USE_DD_COVERAGE
from ddtrace.internal.logger import get_logger
Expand Down
2 changes: 1 addition & 1 deletion ddtrace/contrib/internal/pytest/_plugin_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ def _pytest_load_initial_conftests_pre_yield(early_config, parser, args):
ModuleCodeCollector has a tangible impact on the time it takes to load modules, so it should only be installed if
coverage collection is requested by the backend.
"""
if not _is_enabled_early(early_config):
if not _is_enabled_early(early_config, args):
return

try:
Expand Down
15 changes: 7 additions & 8 deletions ddtrace/contrib/internal/pytest/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ def _extract_span(item):
return getattr(item, "_datadog_span", None)


def _is_enabled_early(early_config):
def _is_enabled_early(early_config, args):
"""Checks if the ddtrace plugin is enabled before the config is fully populated.

This is necessary because the module watchdog for coverage collection needs to be enabled as early as possible.
Expand All @@ -222,15 +222,14 @@ def _is_enabled_early(early_config):
if not _pytest_version_supports_itr():
return False

if (
"--no-ddtrace" in early_config.invocation_params.args
or early_config.getini("no-ddtrace")
or "ddtrace" in early_config.inicfg
and early_config.getini("ddtrace") is False
):
if _is_option_true("no-ddtrace", early_config, args):
return False

return "--ddtrace" in early_config.invocation_params.args or early_config.getini("ddtrace")
return _is_option_true("ddtrace", early_config, args)


def _is_option_true(option, early_config, args):
return early_config.getoption(option) or early_config.getini(option) or f"--{option}" in args


class _TestOutcome(t.NamedTuple):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
fixes:
- |
This fix resolves an issue where code coverage would not be enabled if ddtrace was enabled via the
``PYTEST_ADDOPTS`` environment variable.
35 changes: 35 additions & 0 deletions tests/contrib/pytest/test_pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,41 @@ def test_ini(ddspan):

assert len(spans) == 4

def test_pytest_addopts_env_var(self):
"""Test enabling ddtrace via the PYTEST_ADDOPTS environment variable."""
py_file = self.testdir.makepyfile(
"""
import pytest

def test_trace(ddspan):
assert ddspan is not None
"""
)
file_name = os.path.basename(py_file.strpath)
rec = self.inline_run(file_name, extra_env={"PYTEST_ADDOPTS": "--ddtrace"})
rec.assertoutcome(passed=1)
spans = self.pop_spans()

assert len(spans) == 4

def test_pytest_addopts_ini(self):
"""Test enabling ddtrace via the `addopts` option in the ini file."""
self.testdir.makefile(".ini", pytest="[pytest]\naddopts = --ddtrace\n")
py_file = self.testdir.makepyfile(
"""
import pytest

def test_ini(ddspan):
assert ddspan is not None
"""
)
file_name = os.path.basename(py_file.strpath)
rec = self.inline_run(file_name)
rec.assertoutcome(passed=1)
spans = self.pop_spans()

assert len(spans) == 4

def test_pytest_command(self):
"""Test that the pytest run command is stored on a test span."""
py_file = self.testdir.makepyfile(
Expand Down
80 changes: 80 additions & 0 deletions tests/contrib/pytest/test_pytest_early_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from unittest import mock

import pytest

from ddtrace.contrib.internal.pytest._utils import _pytest_version_supports_itr
from ddtrace.internal.ci_visibility._api_client import TestVisibilityAPISettings
from ddtrace.internal.ci_visibility.constants import COVERAGE_TAG_NAME
from tests.contrib.pytest.test_pytest import PytestTestCaseBase
from tests.contrib.pytest.test_pytest import _get_spans_from_list


_TEST_PASS_CONTENT = """
def test_func_pass():
assert True
"""

pytestmark = pytest.mark.skipif(not _pytest_version_supports_itr(), reason="pytest version does not support coverage")


class PytestEarlyConfigTestCase(PytestTestCaseBase):
"""
Test that code coverage is enabled in the `pytest_load_initial_conftests` hook, regardless of the method used to
enable ddtrace (command line, env var, or ini file).
"""

@pytest.fixture(autouse=True, scope="function")
def set_up_features(self):
with mock.patch(
"ddtrace.internal.ci_visibility.recorder.CIVisibility._check_enabled_features",
return_value=TestVisibilityAPISettings(coverage_enabled=True),
):
yield

def test_coverage_not_enabled(self):
self.testdir.makepyfile(test_pass=_TEST_PASS_CONTENT)
self.inline_run()
spans = self.pop_spans()
assert not spans

def test_coverage_enabled_via_command_line_option(self):
self.testdir.makepyfile(test_pass=_TEST_PASS_CONTENT)
self.inline_run("--ddtrace")
spans = self.pop_spans()
[suite_span] = _get_spans_from_list(spans, "suite")
[test_span] = _get_spans_from_list(spans, "test")
assert (
suite_span.get_struct_tag(COVERAGE_TAG_NAME) is not None or test_span.get_tag(COVERAGE_TAG_NAME) is not None
)

def test_coverage_enabled_via_pytest_addopts_env_var(self):
self.testdir.makepyfile(test_pass=_TEST_PASS_CONTENT)
self.inline_run(extra_env={"PYTEST_ADDOPTS": "--ddtrace"})
spans = self.pop_spans()
[suite_span] = _get_spans_from_list(spans, "suite")
[test_span] = _get_spans_from_list(spans, "test")
assert (
suite_span.get_struct_tag(COVERAGE_TAG_NAME) is not None or test_span.get_tag(COVERAGE_TAG_NAME) is not None
)

def test_coverage_enabled_via_addopts_ini_file_option(self):
self.testdir.makepyfile(test_pass=_TEST_PASS_CONTENT)
self.testdir.makefile(".ini", pytest="[pytest]\naddopts = --ddtrace\n")
self.inline_run()
spans = self.pop_spans()
[suite_span] = _get_spans_from_list(spans, "suite")
[test_span] = _get_spans_from_list(spans, "test")
assert (
suite_span.get_struct_tag(COVERAGE_TAG_NAME) is not None or test_span.get_tag(COVERAGE_TAG_NAME) is not None
)

def test_coverage_enabled_via_ddtrace_ini_file_option(self):
self.testdir.makepyfile(test_pass=_TEST_PASS_CONTENT)
self.testdir.makefile(".ini", pytest="[pytest]\nddtrace = 1\n")
self.inline_run()
spans = self.pop_spans()
[suite_span] = _get_spans_from_list(spans, "suite")
[test_span] = _get_spans_from_list(spans, "test")
assert (
suite_span.get_struct_tag(COVERAGE_TAG_NAME) is not None or test_span.get_tag(COVERAGE_TAG_NAME) is not None
)
Loading