Skip to content

Commit 48085d5

Browse files
committed
refactor(helpers): create builders for data and types to simplify writing test
1 parent d1996a6 commit 48085d5

File tree

4 files changed

+110
-47
lines changed

4 files changed

+110
-47
lines changed

tests/e2e/metrics/conftest.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
1+
from pathlib import Path
2+
13
import pytest
4+
from _pytest import fixtures
25

36
from tests.e2e.metrics.infrastructure import MetricsStack
47

58

69
@pytest.fixture(autouse=True)
7-
def infrastructure() -> MetricsStack:
8-
# Use request fixture to remove hardcode handler dir
10+
def infrastructure(request: fixtures.SubRequest) -> MetricsStack:
11+
"""Setup and teardown logic for E2E test infrastructure
12+
13+
Parameters
14+
----------
15+
request : fixtures.SubRequest
16+
test fixture containing metadata about test execution
17+
18+
Returns
19+
-------
20+
MetricsStack
21+
Metrics Stack to deploy infrastructure
22+
23+
Yields
24+
------
25+
Iterator[MetricsStack]
26+
Deployed Infrastructure
27+
"""
928
try:
10-
stack = MetricsStack()
29+
stack = MetricsStack(handlers_dir=Path(f"{request.fspath.dirname}/handlers"))
1130
stack.deploy()
1231
yield stack
1332
finally:

tests/e2e/metrics/infrastructure.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414

1515

1616
class MetricsStack:
17-
def __init__(self) -> None:
17+
def __init__(self, handlers_dir: Path) -> None:
1818
self.stack_name = f"test-metrics-{uuid4()}"
19-
self.handlers_dir = "tests/e2e/metrics/handlers" # hardcoded as we're not using a fixture yet
19+
self.handlers_dir = handlers_dir # hardcoded as we're not using a fixture yet
2020
self.app = App()
2121
self.stack = Stack(self.app, self.stack_name)
2222
self.session = boto3.Session()
@@ -27,8 +27,8 @@ def __init__(self) -> None:
2727
self.stack_outputs: Dict[str, str] = {}
2828

2929
def create_functions(self):
30-
handlers = list(Path(self.handlers_dir).rglob("*.py"))
31-
source = Code.from_asset(self.handlers_dir)
30+
handlers = list(self.handlers_dir.rglob("*.py"))
31+
source = Code.from_asset(f"{self.handlers_dir}")
3232
for fn in handlers:
3333
fn_name = fn.stem
3434
function_python = Function(
@@ -54,7 +54,10 @@ def create_functions(self):
5454
retention=aws_logs.RetentionDays.ONE_DAY,
5555
removal_policy=RemovalPolicy.DESTROY,
5656
)
57-
CfnOutput(self.stack, f"{fn_name}_arn", value=function_python.function_arn)
57+
58+
# CFN Outputs only support hyphen
59+
fn_name_camel_case = fn_name.title().replace("_", "") # basic_handler -> BasicHandler
60+
CfnOutput(self.stack, f"{fn_name_camel_case}Arn", value=function_python.function_arn)
5861

5962
def deploy(self) -> Dict[str, str]:
6063
"""Creates CloudFormation Stack and return stack outputs as dict

tests/e2e/metrics/test_metrics.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,37 @@
11
import datetime
22
import json
3-
from typing import Dict, Type
43

54
import pytest
6-
from e2e.utils import helpers
75

86
from tests.e2e.metrics.infrastructure import MetricsStack
7+
from tests.e2e.utils import helpers
8+
9+
METRIC_NAMESPACE = "powertools-e2e-metric"
910

1011

1112
@pytest.fixture
12-
def infra_outputs(infrastructure: Type[MetricsStack]):
13+
def infra_outputs(infrastructure: MetricsStack):
1314
return infrastructure.get_stack_outputs()
1415

1516

16-
def test_basic_lambda_metric_is_visible(infra_outputs: Dict[str, str]):
17-
# GIVEN
18-
metric_name = "test-two"
19-
service = "test-metric-is-visible"
20-
namespace = "powertools-e2e-metric"
21-
event = json.dumps({"metric_name": metric_name, "service": service, "namespace": namespace})
17+
@pytest.fixture
18+
def basic_handler_fn(infra_outputs: dict) -> str:
19+
return infra_outputs.get("BasicHandlerArn", "")
2220

23-
# NOTE: Need to try creating a dynamic enum/dataclass w/ Literal types to make discovery easier
24-
# it might not be possible
21+
22+
def test_basic_lambda_metric_is_visible(basic_handler_fn):
23+
# GIVEN
24+
metric_name = helpers.build_metric_name()
25+
service = helpers.build_service_name()
26+
event = json.dumps({"metric_name": metric_name, "service": service, "namespace": METRIC_NAMESPACE})
2527

2628
# WHEN
27-
execution_time = datetime.datetime.utcnow()
28-
ret = helpers.trigger_lambda(lambda_arn=infra_outputs.get("basichandlerarn"), payload=event)
29+
ret, execution_time = helpers.trigger_lambda(lambda_arn=basic_handler_fn, payload=event)
2930

3031
metrics = helpers.get_metrics(
3132
start_date=execution_time,
3233
end_date=execution_time + datetime.timedelta(minutes=2),
33-
namespace=namespace,
34+
namespace=METRIC_NAMESPACE,
3435
service_name=service,
3536
metric_name=metric_name,
3637
)
@@ -48,8 +49,6 @@ def test_basic_lambda_metric_is_visible(infra_outputs: Dict[str, str]):
4849

4950
# helpers: adjust retries and wait to be much smaller
5051
# helpers: make retry config adjustable
51-
# Infra: Create dynamic Enum/DataClass to reduce guessing on outputs
52-
# Infra: Fix outputs
5352
# Infra: Add temporary Powertools Layer
5453
# Powertools: should have a method to set namespace at runtime
5554
# Create separate Infra class so they can live side by side

tests/e2e/utils/helpers.py

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import json
2+
import secrets
23
from datetime import datetime
34
from functools import lru_cache
4-
from typing import TYPE_CHECKING, Dict, List, Optional, Union
5-
6-
if TYPE_CHECKING:
7-
from mypy_boto3_cloudwatch import MetricDataResultTypeDef
8-
from mypy_boto3_cloudwatch.client import CloudWatchClient
9-
from mypy_boto3_lambda.client import LambdaClient
10-
from mypy_boto3_xray.client import XRayClient
5+
from typing import Dict, List, Optional, Tuple, Union
116

127
import boto3
8+
from mypy_boto3_cloudwatch.client import CloudWatchClient
9+
from mypy_boto3_cloudwatch.type_defs import DimensionTypeDef, MetricDataQueryTypeDef, MetricDataResultTypeDef
10+
from mypy_boto3_lambda.client import LambdaClient
11+
from mypy_boto3_lambda.type_defs import InvocationResponseTypeDef
12+
from mypy_boto3_xray.client import XRayClient
1313
from pydantic import BaseModel
1414
from retry import retry
1515

@@ -36,14 +36,17 @@ class TraceSegment(BaseModel):
3636
annotations: Dict = {}
3737

3838

39-
def trigger_lambda(lambda_arn: str, payload: str, client: Optional["LambdaClient"] = None):
39+
def trigger_lambda(
40+
lambda_arn: str, payload: str, client: Optional[LambdaClient] = None
41+
) -> Tuple[InvocationResponseTypeDef, datetime]:
4042
client = client or boto3.client("lambda")
41-
return client.invoke(FunctionName=lambda_arn, InvocationType="RequestResponse", Payload=payload)
43+
execution_time = datetime.utcnow()
44+
return client.invoke(FunctionName=lambda_arn, InvocationType="RequestResponse", Payload=payload), execution_time
4245

4346

4447
@lru_cache(maxsize=10, typed=False)
4548
@retry(ValueError, delay=1, jitter=1, tries=20)
46-
def get_logs(lambda_function_name: str, log_client: "CloudWatchClient", start_time: int, **kwargs: dict) -> List[Log]:
49+
def get_logs(lambda_function_name: str, log_client: CloudWatchClient, start_time: int, **kwargs: dict) -> List[Log]:
4750
response = log_client.filter_log_events(logGroupName=f"/aws/lambda/{lambda_function_name}", startTime=start_time)
4851
if not response["events"]:
4952
raise ValueError("Empty response from Cloudwatch Logs. Repeating...")
@@ -58,26 +61,24 @@ def get_logs(lambda_function_name: str, log_client: "CloudWatchClient", start_ti
5861
return filtered_logs
5962

6063

61-
@lru_cache(maxsize=10, typed=False)
62-
@retry(ValueError, delay=1, jitter=1, tries=5)
64+
@retry(ValueError, delay=1, jitter=1, tries=10)
6365
def get_metrics(
6466
namespace: str,
6567
start_date: datetime,
6668
metric_name: str,
6769
service_name: str,
68-
cw_client: Optional["CloudWatchClient"] = None,
70+
cw_client: Optional[CloudWatchClient] = None,
6971
end_date: Optional[datetime] = None,
70-
) -> "MetricDataResultTypeDef":
72+
period: int = 60,
73+
stat: str = "Sum",
74+
) -> MetricDataResultTypeDef:
7175
cw_client = cw_client or boto3.client("cloudwatch")
72-
metric_query = {
73-
"Id": "m1",
74-
"Expression": f'SELECT MAX("{metric_name}") from SCHEMA("{namespace}",service) \
75-
where service=\'{service_name}\'',
76-
"ReturnData": True,
77-
"Period": 600,
78-
}
76+
metric_query = build_metric_query_data(
77+
namespace=namespace, metric_name=metric_name, service_name=service_name, period=period, stat=stat
78+
)
79+
7980
response = cw_client.get_metric_data(
80-
MetricDataQueries=[metric_query],
81+
MetricDataQueries=metric_query,
8182
StartTime=start_date,
8283
EndTime=end_date or datetime.utcnow(),
8384
)
@@ -88,7 +89,10 @@ def get_metrics(
8889

8990

9091
@retry(ValueError, delay=1, jitter=1, tries=10)
91-
def get_traces(filter_expression: str, xray_client: "XRayClient", start_date: datetime, end_date: datetime) -> Dict:
92+
def get_traces(
93+
filter_expression: str, start_date: datetime, end_date: datetime, xray_client: Optional[XRayClient] = None
94+
) -> Dict:
95+
xray_client = xray_client or boto3.client("xray")
9296
paginator = xray_client.get_paginator("get_trace_summaries")
9397
response_iterator = paginator.paginate(
9498
StartTime=start_date,
@@ -132,3 +136,41 @@ def find_meta(segment: dict, result: List):
132136
)
133137
if x_subsegment.get("subsegments"):
134138
find_meta(segment=x_subsegment, result=result)
139+
140+
141+
# Maintenance: Build a separate module for builders
142+
def build_metric_name() -> str:
143+
return f"test_metric{build_random_value()}"
144+
145+
146+
def build_service_name() -> str:
147+
return f"test_service{build_random_value()}"
148+
149+
150+
def build_random_value(nbytes: int = 10) -> str:
151+
return secrets.token_urlsafe(nbytes).replace("-", "")
152+
153+
154+
def build_metric_query_data(
155+
namespace: str,
156+
metric_name: str,
157+
service_name: str,
158+
period: int = 60,
159+
stat: str = "Sum",
160+
dimensions: Optional[DimensionTypeDef] = None,
161+
) -> List[MetricDataQueryTypeDef]:
162+
metric_dimensions: List[DimensionTypeDef] = [{"Name": "service", "Value": service_name}]
163+
if dimensions is not None:
164+
metric_dimensions.append(dimensions)
165+
166+
return [
167+
{
168+
"Id": metric_name,
169+
"MetricStat": {
170+
"Metric": {"Namespace": namespace, "MetricName": metric_name, "Dimensions": metric_dimensions},
171+
"Period": period,
172+
"Stat": stat,
173+
},
174+
"ReturnData": True,
175+
}
176+
]

0 commit comments

Comments
 (0)