Skip to content

Commit df5bed3

Browse files
fix(pytest): ensure pytest module always exist, adjust suite path accordingly [backport 1.17] (#6416)
Backport ae7bab3 from #6368 to 1.17. CI Visibility: Pytest module should always be reported, although it can be empty if, for example, tests files are located at the root of the project. Taking into account that the Pytest Package may not exist, we need to use Pytest Module to report the test module span (as with the test suite). Also, the test suite path is reported as relative to the test module. Example of data reported with Flask tests (where there is no Pytest Package): Before: ``` { "command": "pytest --ddtrace tests", "fingerprint": "c3acffec7c31fbcd", "framework": "pytest", "framework_version": "6.2.4", "full_name": "tests/test_testing.py.test_cli_custom_obj", "has_parameters": false, "name": "test_cli_custom_obj", "service": "flask", "status": "pass", "suite": "tests/test_testing.py", "type": "test" } ``` After: ``` { "bundle": "tests", "command": "pytest --ddtrace tests", "fingerprint": "406b79a08cd4b768", "framework": "pytest", "framework_version": "6.2.4", "full_name": "tests.test_testing.py.test_cli_custom_obj", "has_parameters": false, "module": "tests", "module_path": "tests", "name": "test_cli_custom_obj", "service": "flask", "status": "pass", "suite": "test_testing.py", "type": "test" } ``` ## Checklist - [x] Change(s) are motivated and described in the PR description. - [x] Testing strategy is described if automated tests are not included in the PR. - [x] Risk is outlined (performance impact, potential for breakage, maintainability, etc). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) are followed. If no release note is required, add label `changelog/no-changelog`. - [x] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)). - [x] Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Title is accurate. - [x] No unnecessary changes are introduced. - [x] Description motivates each change. - [x] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [x] Testing strategy adequately addresses listed risk(s). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] Release note makes sense to a user of the library. - [x] Reviewer has explicitly acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment. - [x] Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) Co-authored-by: Federico Mon <[email protected]>
1 parent c1bdd8d commit df5bed3

File tree

6 files changed

+232
-94
lines changed

6 files changed

+232
-94
lines changed

ddtrace/contrib/pytest/plugin.py

Lines changed: 86 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"""
1414
from doctest import DocTest
1515
import json
16+
import os
1617
import re
1718
from typing import Dict
1819

@@ -69,6 +70,16 @@ def _store_span(item, span):
6970
setattr(item, "_datadog_span", span)
7071

7172

73+
def _extract_module_span(item):
74+
"""Extract span from `pytest.Item` instance."""
75+
return getattr(item, "_datadog_span_module", None)
76+
77+
78+
def _store_module_span(item, span):
79+
"""Store span at `pytest.Item` instance."""
80+
setattr(item, "_datadog_span_module", span)
81+
82+
7283
def _mark_failed(item):
7384
"""Store test failed status at `pytest.Item` instance."""
7485
if item.parent:
@@ -128,26 +139,43 @@ def _get_pytest_command(config):
128139

129140
def _get_module_path(item):
130141
"""Extract module path from a `pytest.Item` instance."""
131-
if not isinstance(item, pytest.Package):
142+
if not isinstance(item, (pytest.Package, pytest.Module)):
132143
return None
133144
return item.nodeid.rpartition("/")[0]
134145

135146

136-
def _get_module_name(item):
147+
def _get_module_name(item, is_package=True):
137148
"""Extract module name (fully qualified) from a `pytest.Item` instance."""
138-
return item.module.__name__
149+
if is_package:
150+
return item.module.__name__
151+
return item.nodeid.rpartition("/")[0].replace("/", ".")
139152

140153

141-
def _get_suite_name(item):
142-
"""Extract suite name from a `pytest.Item` instance."""
143-
return item.nodeid.rpartition("/")[-1]
154+
def _get_suite_name(item, test_module_path=None):
155+
"""
156+
Extract suite name from a `pytest.Item` instance.
157+
If the module path doesn't exist, the suite path will be reported in full.
158+
"""
159+
if test_module_path:
160+
if not item.nodeid.startswith(test_module_path):
161+
log.warning("Suite path is not under module path: '%s' '%s'", item.nodeid, test_module_path)
162+
suite_path = os.path.relpath(item.nodeid, start=test_module_path)
163+
return suite_path
164+
return item.nodeid
144165

145166

146-
def _start_test_module_span(item):
167+
def _start_test_module_span(pytest_package_item=None, pytest_module_item=None):
147168
"""
148169
Starts a test module span at the start of a new pytest test package.
149170
Note that ``item`` is a ``pytest.Package`` object referencing the test module being run.
150171
"""
172+
is_package = True
173+
item = pytest_package_item
174+
175+
if pytest_package_item is None and pytest_module_item is not None:
176+
item = pytest_module_item
177+
is_package = False
178+
151179
test_session_span = _extract_span(item.session)
152180
test_module_span = _CIVisibility._instance.tracer._start_span(
153181
"pytest.test_module",
@@ -164,23 +192,27 @@ def _start_test_module_span(item):
164192
test_module_span.set_tag_str(_EVENT_TYPE, _MODULE_TYPE)
165193
test_module_span.set_tag_str(_SESSION_ID, str(test_session_span.span_id))
166194
test_module_span.set_tag_str(_MODULE_ID, str(test_module_span.span_id))
167-
test_module_span.set_tag_str(test.MODULE, _get_module_name(item))
195+
test_module_span.set_tag_str(test.MODULE, _get_module_name(item, is_package))
168196
test_module_span.set_tag_str(test.MODULE_PATH, _get_module_path(item))
169-
_store_span(item, test_module_span)
170-
return test_module_span
197+
if is_package:
198+
_store_span(item, test_module_span)
199+
else:
200+
_store_module_span(item, test_module_span)
201+
return test_module_span, is_package
171202

172203

173-
def _start_test_suite_span(item):
204+
def _start_test_suite_span(item, test_module_span):
174205
"""
175206
Starts a test suite span at the start of a new pytest test module.
176207
Note that ``item`` is a ``pytest.Module`` object referencing the test file being run.
177208
"""
178209
test_session_span = _extract_span(item.session)
179-
parent_span = test_session_span
180-
test_module_span = None
181-
if isinstance(item.parent, pytest.Package):
210+
if test_module_span is None and isinstance(item.parent, pytest.Package):
182211
test_module_span = _extract_span(item.parent)
183-
parent_span = test_module_span
212+
213+
parent_span = test_module_span
214+
if parent_span is None:
215+
parent_span = test_session_span
184216

185217
test_suite_span = _CIVisibility._instance.tracer._start_span(
186218
"pytest.test_suite",
@@ -197,11 +229,13 @@ def _start_test_suite_span(item):
197229
test_suite_span.set_tag_str(_EVENT_TYPE, _SUITE_TYPE)
198230
test_suite_span.set_tag_str(_SESSION_ID, str(test_session_span.span_id))
199231
test_suite_span.set_tag_str(_SUITE_ID, str(test_suite_span.span_id))
232+
test_module_path = None
200233
if test_module_span is not None:
201234
test_suite_span.set_tag_str(_MODULE_ID, str(test_module_span.span_id))
202235
test_suite_span.set_tag_str(test.MODULE, test_module_span.get_tag(test.MODULE))
203-
test_suite_span.set_tag_str(test.MODULE_PATH, test_module_span.get_tag(test.MODULE_PATH))
204-
test_suite_span.set_tag_str(test.SUITE, _get_suite_name(item))
236+
test_module_path = test_module_span.get_tag(test.MODULE_PATH)
237+
test_suite_span.set_tag_str(test.MODULE_PATH, test_module_path)
238+
test_suite_span.set_tag_str(test.SUITE, _get_suite_name(item, test_module_path))
205239
_store_span(item, test_suite_span)
206240
return test_suite_span
207241

@@ -338,14 +372,20 @@ def pytest_runtest_protocol(item, nextitem):
338372
pytest_module_item = _find_pytest_item(item, pytest.Module)
339373
pytest_package_item = _find_pytest_item(pytest_module_item, pytest.Package)
340374

375+
module_is_package = True
376+
341377
test_module_span = _extract_span(pytest_package_item)
342-
if pytest_package_item is not None and test_module_span is None:
343-
if test_module_span is None:
344-
test_module_span = _start_test_module_span(pytest_package_item)
378+
if not test_module_span:
379+
test_module_span = _extract_module_span(pytest_module_item)
380+
if test_module_span:
381+
module_is_package = False
382+
383+
if test_module_span is None:
384+
test_module_span, module_is_package = _start_test_module_span(pytest_package_item, pytest_module_item)
345385

346386
test_suite_span = _extract_span(pytest_module_item)
347387
if pytest_module_item is not None and test_suite_span is None:
348-
test_suite_span = _start_test_suite_span(pytest_module_item)
388+
test_suite_span = _start_test_suite_span(pytest_module_item, test_module_span)
349389
# Start coverage for the test suite if coverage is enabled
350390
if _CIVisibility._instance._collect_coverage_enabled:
351391
_initialize(str(item.config.rootdir))
@@ -366,20 +406,18 @@ def pytest_runtest_protocol(item, nextitem):
366406
span.set_tag_str(test.COMMAND, _get_pytest_command(item.config))
367407
span.set_tag_str(_SESSION_ID, str(test_session_span.span_id))
368408

369-
if test_module_span is not None:
370-
span.set_tag_str(_MODULE_ID, str(test_module_span.span_id))
371-
span.set_tag_str(test.MODULE, test_module_span.get_tag(test.MODULE))
372-
span.set_tag_str(test.MODULE_PATH, test_module_span.get_tag(test.MODULE_PATH))
373-
374-
if test_suite_span is not None:
375-
span.set_tag_str(_SUITE_ID, str(test_suite_span.span_id))
376-
test_class_hierarchy = _get_test_class_hierarchy(item)
377-
if test_class_hierarchy:
378-
span.set_tag_str(test.CLASS_HIERARCHY, test_class_hierarchy)
379-
if hasattr(item, "dtest") and isinstance(item.dtest, DocTest):
380-
span.set_tag_str(test.SUITE, "{}.py".format(item.dtest.globs["__name__"]))
381-
else:
382-
span.set_tag_str(test.SUITE, test_suite_span.get_tag(test.SUITE))
409+
span.set_tag_str(_MODULE_ID, str(test_module_span.span_id))
410+
span.set_tag_str(test.MODULE, test_module_span.get_tag(test.MODULE))
411+
span.set_tag_str(test.MODULE_PATH, test_module_span.get_tag(test.MODULE_PATH))
412+
413+
span.set_tag_str(_SUITE_ID, str(test_suite_span.span_id))
414+
test_class_hierarchy = _get_test_class_hierarchy(item)
415+
if test_class_hierarchy:
416+
span.set_tag_str(test.CLASS_HIERARCHY, test_class_hierarchy)
417+
if hasattr(item, "dtest") and isinstance(item.dtest, DocTest):
418+
span.set_tag_str(test.SUITE, "{}.py".format(item.dtest.globs["__name__"]))
419+
else:
420+
span.set_tag_str(test.SUITE, test_suite_span.get_tag(test.SUITE))
383421

384422
span.set_tag_str(test.TYPE, SpanTypes.TEST)
385423
span.set_tag_str(test.FRAMEWORK_VERSION, pytest.__version__)
@@ -412,21 +450,25 @@ def pytest_runtest_protocol(item, nextitem):
412450
yield
413451

414452
nextitem_pytest_module_item = _find_pytest_item(nextitem, pytest.Module)
415-
if test_suite_span is not None and (
416-
nextitem is None or nextitem_pytest_module_item != pytest_module_item and not test_suite_span.finished
417-
):
453+
if nextitem is None or nextitem_pytest_module_item != pytest_module_item and not test_suite_span.finished:
418454
_mark_test_status(pytest_module_item, test_suite_span)
419455
# Finish coverage for the test suite if coverage is enabled
420456
if _CIVisibility._instance._collect_coverage_enabled:
421457
_coverage_end(test_suite_span)
422458
test_suite_span.finish()
423459

424-
nextitem_pytest_package_item = _find_pytest_item(nextitem, pytest.Package)
425-
if test_module_span is not None and (
426-
nextitem is None or nextitem_pytest_package_item != pytest_package_item and not test_module_span.finished
427-
):
428-
_mark_test_status(pytest_package_item, test_module_span)
429-
test_module_span.finish()
460+
if not module_is_package:
461+
test_module_span.set_tag_str(test.STATUS, test_suite_span.get_tag(test.STATUS))
462+
test_module_span.finish()
463+
else:
464+
nextitem_pytest_package_item = _find_pytest_item(nextitem, pytest.Package)
465+
if (
466+
nextitem is None
467+
or nextitem_pytest_package_item != pytest_package_item
468+
and not test_module_span.finished
469+
):
470+
_mark_test_status(pytest_package_item, test_module_span)
471+
test_module_span.finish()
430472

431473

432474
@pytest.hookimpl(hookwrapper=True)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
prelude: >
3+
**Breaking change** for CI Visibility: `test.suite` and `test.full_name` are changed, so any visualization or monitor that uses these fields is potentially affected
4+
fixes:
5+
- |
6+
pytest: This fix resolves an issue where test modules could be non-existent, causing errors in the CI Visibility product.

tests/ci_visibility/test_encoder.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ def test_ok():
230230
expected_meta.update({b"_dd.origin": b"ciapp-test"})
231231
expected_meta.pop(b"test_session_id")
232232
expected_meta.pop(b"test_suite_id")
233+
expected_meta.pop(b"test_module_id")
233234
expected_metrics = {
234235
"{}".format(key).encode("utf-8"): value for key, value in sorted(given_test_span._metrics.items())
235236
}
@@ -245,6 +246,7 @@ def test_ok():
245246
b"service": given_test_span.service.encode("utf-8"),
246247
b"span_id": given_test_span.span_id,
247248
b"start": given_test_span.start_ns,
249+
b"test_module_id": int(given_test_span.get_tag("test_module_id")),
248250
b"test_session_id": int(given_test_span.get_tag("test_session_id")),
249251
b"test_suite_id": int(given_test_span.get_tag("test_suite_id")),
250252
b"trace_id": given_test_span.trace_id,
@@ -275,15 +277,17 @@ def test_ok():
275277
ci_agentless_encoder.put(spans)
276278
event_payload = ci_agentless_encoder.encode()
277279
decoded_event_payload = self.tracer.encoder._decode(event_payload)
278-
given_test_suite_span = spans[2]
279-
given_test_suite_event = decoded_event_payload[b"events"][2]
280+
given_test_suite_span = spans[3]
281+
assert given_test_suite_span.get_tag("type") == "test_suite_end"
282+
given_test_suite_event = decoded_event_payload[b"events"][3]
280283
expected_meta = {
281284
"{}".format(key).encode("utf-8"): "{}".format(value).encode("utf-8")
282285
for key, value in sorted(given_test_suite_span._meta.items())
283286
}
284287
expected_meta.update({b"_dd.origin": b"ciapp-test"})
285288
expected_meta.pop(b"test_session_id")
286289
expected_meta.pop(b"test_suite_id")
290+
expected_meta.pop(b"test_module_id")
287291
expected_metrics = {
288292
"{}".format(key).encode("utf-8"): value for key, value in sorted(given_test_suite_span._metrics.items())
289293
}
@@ -297,6 +301,7 @@ def test_ok():
297301
b"resource": given_test_suite_span.resource.encode("utf-8"),
298302
b"service": given_test_suite_span.service.encode("utf-8"),
299303
b"start": given_test_suite_span.start_ns,
304+
b"test_module_id": int(given_test_suite_span.get_tag("test_module_id")),
300305
b"test_session_id": int(given_test_suite_span.get_tag("test_session_id")),
301306
b"test_suite_id": int(given_test_suite_span.get_tag("test_suite_id")),
302307
b"type": given_test_suite_span.get_tag("type").encode("utf-8"),

tests/contrib/asynctest/test_asynctest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,6 @@ def test_asynctest():
5757
rec.assertoutcome(passed=1)
5858
spans = self.pop_spans()
5959

60-
assert len(spans) == 3
60+
assert len(spans) == 4
6161
test_span = spans[0]
6262
assert test_span.get_tag(test.STATUS) == test.Status.PASS.value

0 commit comments

Comments
 (0)