Skip to content

Commit 8fbe2b9

Browse files
authored
feat[pytest] Added test parameters tagging into pytest plugin (#2574)
* Added test parameters tagging into pytest plugin. * Added comment, updated changelog * Reverted unrelated test.py changes * Updated spelling wordlist * Re-added removed unused constant * Added support for Python non-builtin object encoding into span tag. * Added hypothesis tests for _json_encode helper. * Changed default object representation from str() to repr() * Added exception/side effect handling when reading __dict__ and __repr__ * Formatting * Make tests compatible for py2 and py3
1 parent 0dee2fc commit 8fbe2b9

File tree

6 files changed

+142
-5
lines changed

6 files changed

+142
-5
lines changed

.dd-ci/ci-app-spec.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"runtime.version",
2929
"test.framework",
3030
"test.name",
31+
"test.parameters",
3132
"test.skip_reason",
3233
"test.status",
3334
"test.suite",

ddtrace/contrib/pytest/plugin.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import json
2+
from typing import Any
3+
from typing import Dict
4+
15
import pytest
26

37
import ddtrace
@@ -28,6 +32,20 @@ def _store_span(item, span):
2832
setattr(item, "_datadog_span", span)
2933

3034

35+
def _json_encode(params):
36+
# type: (Dict[str, Any]) -> str
37+
"""JSON encode parameters. If complex object show inner values, otherwise default to string representation."""
38+
39+
def inner_encode(obj):
40+
try:
41+
obj_dict = getattr(obj, "__dict__", None)
42+
return obj_dict if obj_dict else repr(obj)
43+
except Exception as e:
44+
return repr(e)
45+
46+
return json.dumps(params, default=inner_encode)
47+
48+
3149
PATCH_ALL_HELP_MSG = "Call ddtrace.patch_all before running tests."
3250

3351

@@ -102,6 +120,12 @@ def pytest_runtest_protocol(item, nextitem):
102120
span.set_tag(test.SUITE, item.module.__name__)
103121
span.set_tag(test.TYPE, SpanTypes.TEST.value)
104122

123+
# Parameterized test cases will have a `callspec` attribute attached to the pytest Item object.
124+
# Pytest docs: https://docs.pytest.org/en/6.2.x/reference.html#pytest.Function
125+
if getattr(item, "callspec", None):
126+
params = {"arguments": item.callspec.params, "metadata": {}}
127+
span.set_tag(test.PARAMETERS, _json_encode(params))
128+
105129
markers = [marker.kwargs for marker in item.iter_markers(name="dd_tags")]
106130
for tags in markers:
107131
span.set_tags(tags)

ddtrace/ext/test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
# Test Name
1515
NAME = TEST_NAME = "test.name"
1616

17+
# Test Parameters
18+
PARAMETERS = "test.parameters"
19+
1720
# Pytest Result (XFail, XPass)
1821
RESULT = TEST_RESULT = "pytest.result"
1922

docs/spelling_wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ mysqldb
8585
namespace
8686
opentracer
8787
opentracing
88+
parameterized
8889
pid
8990
plugin
9091
posix
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
features:
3+
- |
4+
The pytest plugin now includes support for automatically tagging spans with parameters in parameterized tests.

tests/contrib/pytest/test_pytest.py

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import json
12
import os
23
import sys
34

5+
from hypothesis import given
6+
from hypothesis import strategies as st
47
import pytest
58

69
from ddtrace import Pin
10+
from ddtrace.contrib.pytest.plugin import _json_encode
711
from ddtrace.ext import test
812
from tests.utils import TracerTestCase
913

@@ -107,18 +111,53 @@ def test_parameterize_case(self):
107111
"""
108112
import pytest
109113
110-
@pytest.mark.parametrize('abc', [1, 2, 3, 4, pytest.param(5, marks=pytest.mark.skip)])
114+
class A:
115+
def __init__(self, name, value):
116+
self.name = name
117+
self.value = value
118+
119+
def item_param():
120+
return 42
121+
122+
@pytest.mark.parametrize(
123+
'item',
124+
[
125+
1,
126+
2,
127+
3,
128+
4,
129+
pytest.param(A("test_name", "value"), marks=pytest.mark.skip),
130+
pytest.param(A("test_name", A("inner_name", "value")), marks=pytest.mark.skip),
131+
pytest.param({"a": A("test_name", "value"), "b": [1, 2, 3]}, marks=pytest.mark.skip),
132+
pytest.param([1, 2, 3], marks=pytest.mark.skip),
133+
pytest.param(item_param, marks=pytest.mark.skip)
134+
]
135+
)
111136
class Test1(object):
112-
def test_1(self, abc):
113-
assert abc in {1, 2, 3}
137+
def test_1(self, item):
138+
assert item in {1, 2, 3}
114139
"""
115140
)
116141
file_name = os.path.basename(py_file.strpath)
117142
rec = self.inline_run("--ddtrace", file_name)
118-
rec.assertoutcome(passed=3, failed=1, skipped=1)
143+
rec.assertoutcome(passed=3, failed=1, skipped=5)
119144
spans = self.pop_spans()
120145

121-
assert len(spans) == 5
146+
expected_params = [
147+
1,
148+
2,
149+
3,
150+
4,
151+
{"name": "test_name", "value": "value"},
152+
{"name": "test_name", "value": {"name": "inner_name", "value": "value"}},
153+
{"a": {"name": "test_name", "value": "value"}, "b": [1, 2, 3]},
154+
[1, 2, 3],
155+
]
156+
assert len(spans) == 9
157+
for i in range(len(spans) - 1):
158+
extracted_params = json.loads(spans[i].meta[test.PARAMETERS])
159+
assert extracted_params == {"arguments": {"item": expected_params[i]}, "metadata": {}}
160+
assert "<function item_param at 0x" in json.loads(spans[8].meta[test.PARAMETERS])["arguments"]["item"]
122161

123162
def test_skip(self):
124163
"""Test parametrize case."""
@@ -290,3 +329,68 @@ def test_service(ddspan):
290329
file_name = os.path.basename(py_file.strpath)
291330
rec = self.subprocess_run("--ddtrace", file_name)
292331
assert 0 == rec.ret
332+
333+
334+
class A(object):
335+
def __init__(self, name, value):
336+
self.name = name
337+
self.value = value
338+
339+
340+
simple_types = [st.none(), st.booleans(), st.text(), st.integers(), st.floats(allow_infinity=False, allow_nan=False)]
341+
complex_types = [st.functions(), st.dates(), st.decimals(), st.builds(A, name=st.text(), value=st.integers())]
342+
343+
344+
@given(
345+
st.dictionaries(
346+
st.text(),
347+
st.one_of(
348+
st.lists(st.one_of(*simple_types)), st.dictionaries(st.text(), st.one_of(*simple_types)), *simple_types
349+
),
350+
)
351+
)
352+
def test_custom_json_encoding_simple_types(obj):
353+
"""Ensures the _json.encode helper encodes simple objects."""
354+
encoded = _json_encode(obj)
355+
decoded = json.loads(encoded)
356+
assert obj == decoded
357+
358+
359+
@given(
360+
st.dictionaries(
361+
st.text(),
362+
st.one_of(
363+
st.lists(st.one_of(*complex_types)), st.dictionaries(st.text(), st.one_of(*complex_types)), *complex_types
364+
),
365+
)
366+
)
367+
def test_custom_json_encoding_python_objects(obj):
368+
"""Ensures the _json_encode helper encodes complex objects into dicts of inner values or a string representation."""
369+
encoded = _json_encode(obj)
370+
obj = json.loads(
371+
json.dumps(obj, default=lambda x: getattr(x, "__dict__", None) if getattr(x, "__dict__", None) else repr(x))
372+
)
373+
decoded = json.loads(encoded)
374+
assert obj == decoded
375+
376+
377+
def test_custom_json_encoding_side_effects():
378+
"""Ensures the _json_encode helper encodes objects with side effects (getattr, repr) without raising exceptions."""
379+
dict_side_effect = Exception("side effect __dict__")
380+
repr_side_effect = Exception("side effect __repr__")
381+
382+
class B(object):
383+
def __getattribute__(self, item):
384+
if item == "__dict__":
385+
raise dict_side_effect
386+
raise AttributeError()
387+
388+
class C(object):
389+
def __repr__(self):
390+
raise repr_side_effect
391+
392+
obj = {"b": B(), "c": C()}
393+
encoded = _json_encode(obj)
394+
decoded = json.loads(encoded)
395+
assert decoded["b"] == repr(dict_side_effect)
396+
assert decoded["c"] == repr(repr_side_effect)

0 commit comments

Comments
 (0)