diff --git a/ddtrace/contrib/internal/pytest/_plugin_v1.py b/ddtrace/contrib/internal/pytest/_plugin_v1.py index ffc291afb80..c121bbddf6b 100644 --- a/ddtrace/contrib/internal/pytest/_plugin_v1.py +++ b/ddtrace/contrib/internal/pytest/_plugin_v1.py @@ -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 diff --git a/ddtrace/contrib/internal/pytest/_plugin_v2.py b/ddtrace/contrib/internal/pytest/_plugin_v2.py index 1dd15619a81..d58b76e4069 100644 --- a/ddtrace/contrib/internal/pytest/_plugin_v2.py +++ b/ddtrace/contrib/internal/pytest/_plugin_v2.py @@ -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: diff --git a/ddtrace/contrib/internal/pytest/_utils.py b/ddtrace/contrib/internal/pytest/_utils.py index e83548e2d58..4ab62bf614d 100644 --- a/ddtrace/contrib/internal/pytest/_utils.py +++ b/ddtrace/contrib/internal/pytest/_utils.py @@ -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. @@ -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): diff --git a/releasenotes/notes/ci_visibility-fix-pytest-addopts-early-init-74fb4e207dee18e4.yaml b/releasenotes/notes/ci_visibility-fix-pytest-addopts-early-init-74fb4e207dee18e4.yaml new file mode 100644 index 00000000000..d0ef7d15690 --- /dev/null +++ b/releasenotes/notes/ci_visibility-fix-pytest-addopts-early-init-74fb4e207dee18e4.yaml @@ -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. diff --git a/tests/contrib/pytest/test_pytest.py b/tests/contrib/pytest/test_pytest.py index 2e7836e97d4..5667be4f5a8 100644 --- a/tests/contrib/pytest/test_pytest.py +++ b/tests/contrib/pytest/test_pytest.py @@ -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( diff --git a/tests/contrib/pytest/test_pytest_early_config.py b/tests/contrib/pytest/test_pytest_early_config.py new file mode 100644 index 00000000000..b2c21a62ad5 --- /dev/null +++ b/tests/contrib/pytest/test_pytest_early_config.py @@ -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 + )