Skip to content

Commit 51d68d8

Browse files
authored
Add snapshot test setup code and basic tests (#207)
1 parent 2facd37 commit 51d68d8

File tree

7 files changed

+353
-0
lines changed

7 files changed

+353
-0
lines changed

dev-constraints.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mypy-extensions==0.4.3
55
mypy==0.982
66
pylint==2.8.3
77
Sphinx==4.3.1
8+
syrupy==3.0.4
89
types-protobuf==3.20.4.2
910
types-requests==2.28.11.2
1011
types-setuptools==65.5.0.2
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"/google.monitoring.v3.MetricService/CreateMetricDescriptor": [
3+
{
4+
"metricDescriptor": {
5+
"description": "foo",
6+
"displayName": "mycounter",
7+
"labels": [
8+
{
9+
"key": "string"
10+
},
11+
{
12+
"key": "int"
13+
},
14+
{
15+
"key": "float"
16+
}
17+
],
18+
"metricKind": "CUMULATIVE",
19+
"type": "workload.googleapis.com/mycounter",
20+
"valueType": "INT64"
21+
},
22+
"name": "projects/fakeproject"
23+
}
24+
],
25+
"/google.monitoring.v3.MetricService/CreateTimeSeries": [
26+
{
27+
"name": "projects/fakeproject",
28+
"timeSeries": [
29+
{
30+
"metric": {
31+
"labels": {
32+
"float": "123.4",
33+
"int": "123",
34+
"string": "string"
35+
},
36+
"type": "workload.googleapis.com/mycounter"
37+
},
38+
"metricKind": "CUMULATIVE",
39+
"points": [
40+
{
41+
"interval": {
42+
"endTime": "str",
43+
"startTime": "str"
44+
},
45+
"value": {
46+
"int64Value": "123"
47+
}
48+
}
49+
],
50+
"resource": {
51+
"labels": {
52+
"location": "global",
53+
"namespace": "",
54+
"node_id": ""
55+
},
56+
"type": "generic_node"
57+
}
58+
}
59+
]
60+
}
61+
]
62+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright 2022 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# pylint: disable=unused-import
16+
17+
# import fixtures to be made available to other tests
18+
from fixtures.gcmfake import fixture_gcmfake
19+
from fixtures.snapshot_gcmcalls import fixture_snapshot_gcmcalls
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Copyright 2022 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from collections import defaultdict
16+
from concurrent.futures import ThreadPoolExecutor
17+
from dataclasses import dataclass
18+
from typing import Callable, Iterable, List, Mapping, Tuple
19+
20+
import grpc
21+
import pytest
22+
from google.api.metric_pb2 import MetricDescriptor
23+
from google.cloud.monitoring_v3 import (
24+
CreateMetricDescriptorRequest,
25+
CreateTimeSeriesRequest,
26+
MetricServiceClient,
27+
)
28+
from google.cloud.monitoring_v3.services.metric_service.transports import (
29+
MetricServiceGrpcTransport,
30+
)
31+
32+
# pylint: disable=no-name-in-module
33+
from google.protobuf.empty_pb2 import Empty
34+
from google.protobuf.message import Message
35+
from grpc import (
36+
GenericRpcHandler,
37+
RpcContext,
38+
RpcMethodHandler,
39+
insecure_channel,
40+
method_handlers_generic_handler,
41+
unary_unary_rpc_method_handler,
42+
)
43+
44+
# Mapping of fully qualified GCM API method names to list of requests received
45+
GcmCalls = Mapping[str, List[Message]]
46+
47+
48+
class FakeHandler(GenericRpcHandler):
49+
"""gRPC handler partially implementing the GCM API and capturing the requests.
50+
51+
Captures the request protos made to each method.
52+
"""
53+
54+
_service = "google.monitoring.v3.MetricService"
55+
56+
def __init__(self):
57+
# pylint: disable=no-member
58+
super().__init__()
59+
self._calls: GcmCalls = defaultdict(list)
60+
61+
self._wrapped = method_handlers_generic_handler(
62+
self._service,
63+
dict(
64+
[
65+
self._make_impl(
66+
"CreateTimeSeries",
67+
lambda req, ctx: Empty(),
68+
CreateTimeSeriesRequest.deserialize,
69+
Empty.SerializeToString,
70+
),
71+
self._make_impl(
72+
"CreateMetricDescriptor",
73+
# return the metric descriptor back
74+
lambda req, ctx: req.metric_descriptor,
75+
CreateMetricDescriptorRequest.deserialize,
76+
MetricDescriptor.SerializeToString,
77+
),
78+
]
79+
),
80+
)
81+
82+
def _make_impl(
83+
self,
84+
method: str,
85+
behavior: Callable[[Message, RpcContext], Message],
86+
deserializer,
87+
serializer,
88+
) -> Tuple[str, RpcMethodHandler]:
89+
def impl(req: Message, context: RpcContext) -> Message:
90+
self._calls[f"/{self._service}/{method}"].append(req)
91+
return behavior(req, context)
92+
93+
return method, unary_unary_rpc_method_handler(
94+
impl,
95+
request_deserializer=deserializer,
96+
response_serializer=serializer,
97+
)
98+
99+
def service(self, handler_call_details):
100+
res = self._wrapped.service(handler_call_details)
101+
return res
102+
103+
def get_calls(self) -> GcmCalls:
104+
"""Returns calls made to each GCM API method"""
105+
return self._calls
106+
107+
108+
@dataclass
109+
class GcmFake:
110+
client: MetricServiceClient
111+
get_calls: Callable[[], GcmCalls]
112+
113+
114+
@pytest.fixture(name="gcmfake")
115+
def fixture_gcmfake() -> Iterable[GcmFake]:
116+
"""Fixture providing faked GCM api with captured requests"""
117+
118+
handler = FakeHandler()
119+
server = None
120+
121+
try:
122+
# Run in a single thread to serialize requests
123+
with ThreadPoolExecutor(1) as executor:
124+
server = grpc.server(executor, handlers=[handler])
125+
port = server.add_insecure_port("localhost:0")
126+
server.start()
127+
with insecure_channel(f"localhost:{port}") as channel:
128+
yield GcmFake(
129+
client=MetricServiceClient(
130+
transport=MetricServiceGrpcTransport(channel=channel),
131+
),
132+
get_calls=handler.get_calls,
133+
)
134+
finally:
135+
if server:
136+
server.stop(None)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Copyright 2022 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from typing import Optional, cast
16+
17+
import google.protobuf.message
18+
import proto
19+
import pytest
20+
from fixtures.gcmfake import GcmCalls
21+
from google.protobuf import json_format
22+
from syrupy.extensions.json import JSONSnapshotExtension
23+
from syrupy.matchers import path_type
24+
from syrupy.types import (
25+
PropertyFilter,
26+
PropertyMatcher,
27+
SerializableData,
28+
SerializedData,
29+
)
30+
31+
32+
# pylint: disable=too-many-ancestors
33+
class GcmCallsSnapshotExtension(JSONSnapshotExtension):
34+
"""syrupy extension to serialize GcmCalls.
35+
36+
Serializes the protobufs for each method call into JSON for storing as a snapshot.
37+
"""
38+
39+
def serialize(
40+
self,
41+
data: SerializableData,
42+
*,
43+
exclude: Optional[PropertyFilter] = None,
44+
matcher: Optional[PropertyMatcher] = None,
45+
) -> SerializedData:
46+
calls = cast(GcmCalls, data)
47+
json = {}
48+
for method, requests in calls.items():
49+
dict_requests = []
50+
for request in requests:
51+
if isinstance(request, proto.message.Message):
52+
request = type(request).pb(request)
53+
elif isinstance(request, google.protobuf.message.Message):
54+
pass
55+
else:
56+
raise ValueError(
57+
f"Excepted a proto-plus or protobuf message, got {type(request)}"
58+
)
59+
dict_requests.append(json_format.MessageToDict(request))
60+
json[method] = dict_requests
61+
62+
return super().serialize(json, exclude=exclude, matcher=matcher)
63+
64+
65+
@pytest.fixture(name="snapshot_gcmcalls")
66+
def fixture_snapshot_gcmcalls(snapshot):
67+
"""Fixture for snapshot testing of GcmCalls
68+
69+
TimeInterval.start_time and TimeInterval.end_time timestamps are "redacted" since they are
70+
dynamic depending on when the test is run.
71+
"""
72+
return snapshot.use_extension(GcmCallsSnapshotExtension)(
73+
matcher=path_type(
74+
{r".*\.interval\.(start|end)Time": (str,)},
75+
regex=True,
76+
)
77+
)

opentelemetry-exporter-gcp-monitoring/tests/test_cloud_monitoring.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,71 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16+
"""
17+
Some tests in this file use [syrupy](https://github.com/tophat/syrupy) for snapshot testing aka
18+
golden testing. The GCM API calls are captured with a gRPC fake and compared to the existing
19+
snapshot file in the __snapshots__ directory.
20+
21+
If an expected behavior change is made to the exporter causing these tests to fail, regenerate
22+
the snapshots by running tox to pass the --snapshot-update flag to pytest:
23+
24+
```sh
25+
tox -e py310-ci-test-cloudmonitoring -- --snapshot-update
26+
```
27+
28+
Be sure to review the changes.
29+
"""
30+
31+
from typing import Iterable
32+
33+
import pytest
34+
from fixtures.gcmfake import GcmFake
1635
from google.auth.credentials import AnonymousCredentials
1736
from google.cloud.monitoring_v3 import MetricServiceClient
1837
from opentelemetry.exporter.cloud_monitoring import (
1938
CloudMonitoringMetricsExporter,
2039
)
40+
from opentelemetry.sdk.metrics import MeterProvider
41+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
42+
from opentelemetry.util.types import Attributes
2143

2244
PROJECT_ID = "fakeproject"
45+
LABELS: Attributes = {
46+
"string": "string",
47+
"int": 123,
48+
"float": 123.4,
49+
}
50+
51+
52+
@pytest.fixture(name="meter_provider")
53+
def fixture_meter_provider(gcmfake: GcmFake) -> Iterable[MeterProvider]:
54+
mp = MeterProvider(
55+
metric_readers=[
56+
PeriodicExportingMetricReader(
57+
CloudMonitoringMetricsExporter(
58+
project_id=PROJECT_ID, client=gcmfake.client
59+
)
60+
)
61+
],
62+
shutdown_on_exit=False,
63+
)
64+
yield mp
65+
mp.shutdown()
2366

2467

2568
def test_create_monitoring_exporter() -> None:
2669
client = MetricServiceClient(credentials=AnonymousCredentials())
2770
CloudMonitoringMetricsExporter(project_id=PROJECT_ID, client=client)
71+
72+
73+
def test_counter(
74+
meter_provider: MeterProvider,
75+
gcmfake: GcmFake,
76+
snapshot_gcmcalls,
77+
) -> None:
78+
counter = meter_provider.get_meter(__name__).create_counter(
79+
"mycounter", description="foo", unit="{myunit}"
80+
)
81+
counter.add(123, LABELS)
82+
meter_provider.force_flush()
83+
assert gcmfake.get_calls() == snapshot_gcmcalls

tox.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dev_deps =
3030
mypy
3131
pylint
3232
pytest
33+
syrupy
3334
types-protobuf
3435
types-requests
3536
types-setuptools
@@ -50,6 +51,7 @@ setenv =
5051
deps =
5152
test: {[constants]base_deps}
5253
test: pytest
54+
test: syrupy
5355
passenv = SKIP_GET_MOCK_SERVER
5456
changedir = {env:PACKAGE_NAME}
5557

0 commit comments

Comments
 (0)