From b5bfdf52446897fd6da9e22a34bcd78637ace600 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 5 Aug 2025 09:29:37 +0000 Subject: [PATCH] Adding additional api gateway mock local pytest --- python-test-samples/README.md | 1 + .../apigw-mock-local/README.md | 389 +++++++++++++ .../apigw-mock-local/events/test-event.json | 7 + .../apigw-mock-local/img/apigateway-mock.png | Bin 0 -> 59925 bytes .../apigw-mock-local/lambda_mock_src/app.py | 8 + .../apigw-mock-local/template.yaml | 25 + .../apigw-mock-local/tests/requirements.txt | 14 + .../tests/unit/src/test_apigateway_local.py | 539 ++++++++++++++++++ 8 files changed, 983 insertions(+) create mode 100644 python-test-samples/apigw-mock-local/README.md create mode 100644 python-test-samples/apigw-mock-local/events/test-event.json create mode 100644 python-test-samples/apigw-mock-local/img/apigateway-mock.png create mode 100644 python-test-samples/apigw-mock-local/lambda_mock_src/app.py create mode 100755 python-test-samples/apigw-mock-local/template.yaml create mode 100644 python-test-samples/apigw-mock-local/tests/requirements.txt create mode 100644 python-test-samples/apigw-mock-local/tests/unit/src/test_apigateway_local.py diff --git a/python-test-samples/README.md b/python-test-samples/README.md index e25b76f2..38649bec 100644 --- a/python-test-samples/README.md +++ b/python-test-samples/README.md @@ -12,6 +12,7 @@ This portion of the repository contains code samples for testing serverless appl |[Lambda local testing with Mocks](./lambda-mock)|This project contains unit tests for Lambda using mocks.| |[Lambda Layers with Mocks](./apigw-lambda-layer)|This project contains unit tests for Lambda layers using mocks.| |[API Gateway with Lambda and DynamoDB](./apigw-lambda-dynamodb)|This project contains unit and integration tests for a pattern using API Gateway, AWS Lambda and Amazon DynamoDB.| +|[API Gateway mock local](./apigw-mock-local)|This project contains unit tests for a pattern running API Gateway locally using pytests and mocks.| |[Schema and Contract Testing](./schema-and-contract-testing)|This project contains sample schema and contract tests for an event driven architecture.| |[Kinesis with Lambda and DynamoDB](./kinesis-lambda-dynamodb)|This project contains a example of testing an application with an Amazon Kinesis Data Stream.| |[SQS with Lambda](./apigw-sqs-lambda-sqs)|This project demonstrates testing SQS as a source and destination in an integration test| diff --git a/python-test-samples/apigw-mock-local/README.md b/python-test-samples/apigw-mock-local/README.md new file mode 100644 index 00000000..9063f7c3 --- /dev/null +++ b/python-test-samples/apigw-mock-local/README.md @@ -0,0 +1,389 @@ +[![python: 3.10](https://img.shields.io/badge/Python-3.10-green)](https://img.shields.io/badge/Python-3.10-green) +[![AWS: API Gateway](https://img.shields.io/badge/AWS-API%20Gateway-blue)](https://img.shields.io/badge/AWS-API%20Gateway-blue) +[![AWS: Lambda](https://img.shields.io/badge/AWS-Lambda-orange)](https://img.shields.io/badge/AWS-Lambda-orange) +[![test: local](https://img.shields.io/badge/Test-Local-red)](https://img.shields.io/badge/Test-Local-red) + +# Local: Amazon API Gateway Mock Testing + +## Introduction + +This project demonstrates how to test AWS API Gateway endpoints locally using SAM CLI. It showcases a simple mock implementation using Python 3.10 that returns predefined responses without requiring AWS credentials or external dependencies, making it ideal for rapid development and testing cycles. + +--- + +## Contents + +- [Local: Amazon API Gateway Mock Testing](#local-amazon-api-gateway-mock-testing) + - [Introduction](#introduction) + - [Contents](#contents) + - [Architecture Overview](#architecture-overview) + - [Project Structure](#project-structure) + - [Prerequisites](#prerequisites) + - [Test Scenarios](#test-scenarios) + - [About the Test Process](#about-the-test-process) + - [Testing Workflows](#testing-workflows) + - [API Documentation](#api-documentation) + - [Additional Resources](#additional-resources) + +--- + +## Architecture Overview + +

+ API Gateway Mock Testing +

+ +Components: + +- API Gateway Local emulator via SAM CLI +- Python Lambda function returning JSON mock responses +- Testcontainers for container management +- PyTest for automated testing + +--- + +## Project Structure +``` +├── events _# folder containing json events files_ +├── img/apigw-mock-local.png _# visual architecture diagram_ +├── lambda_mock_src/app.py _# Python Lambda function source code_ +├── tests/ +│ ├── unit/src/test_apigateway_local.py _# python PyTest test definition_ +│ └── requirements.txt _# pip requirements dependencies file_ +├── template.yaml _# SAM template defining API Gateway and Lambda resources_ +└── README.md _# instructions file_ +``` +--- + +## Prerequisites + +- Docker +- AWS SAM CLI +- Python 3.10 or newer +- curl (for API testing) +- Basic understanding of SAM, API Gateway and Lambda + +--- + +## Test Scenarios + +### 1. Mock Response Validation + +- Tests the basic mock endpoint functionality +- Verifies that the API Gateway correctly routes requests to the Lambda function +- Validates the JSON response format and content +- Ensures proper HTTP status codes are returned + +### 2. Local API Gateway Behavior + +- Tests the local emulation of API Gateway routing +- Verifies that the `/MOCK` endpoint is accessible via GET method +- Validates that the Lambda integration works correctly in local environment + +### 3. PyTest Integration Tests (end to end python test) + +- **Basic API Gateway Test**: Validates API Gateway routing and Lambda integration +- **Response Format Validation**: Tests proper JSON response structure +- **Error Handling Test**: Validates behavior with invalid requests and methods +- **Performance Metrics**: Measures API response times and consistency +- **Concurrent Request Test**: Tests API behavior under concurrent load +- **Input Validation**: Tests API with various input scenarios + +--- + +## About the Test Process + +The test process leverages SAM CLI to provide local emulation of AWS services: + +1. **SAM Local Setup**: SAM CLI starts a local API Gateway emulator that listens on port 3000 by default. + +2. **Lambda Function Loading**: The local emulator loads the Python Lambda function code from `lambda_mock_src/app.py` and creates a containerized runtime environment using Python 3.10. + +3. **API Route Mapping**: Based on the `template.yaml` configuration, SAM maps the `/MOCK` path with GET method to the Lambda function. + +4. **Request Processing**: When a request is made to `http://127.0.0.1:3000/MOCK`, the local API Gateway: + - Receives the HTTP request + - Routes it to the appropriate Lambda function + - Executes the Python function in a Docker container + - Returns the response to the client + +5. **Response Validation**: Tests verify that the mock response is correctly formatted and contains the expected content. + +--- + +## Testing Workflows + +### Setup Docker Environment + +> Make sure Docker engine is running before running the tests. + +```shell +apigw-mock-local$ docker version +Client: Docker Engine - Community + Version: 24.0.6 + API version: 1.43 +(...) +``` + +### Run the Unit Test - End to end python test + +> Start the API Gateway emulator in a separate terminal: + +```shell +apigw-mock-local$ +sam local start-api --port 3000 --docker-network host & +``` + +> Set up the python environment: + +```shell +apigw-mock-local$ cd tests +python3 -m venv venv +source venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +``` + +#### Run the Unit Tests + +```shell +apigw-mock-local/tests$ +python3 -m pytest -s unit/src/test_apigateway_local.py +``` + +Expected output: + +```shell +========================================================== test session starts========================================================== +platform linux -- Python 3.10.12, pytest-8.4.1, pluggy-1.6.0 +rootdir: /home/ubuntu/environment/python-test-samples/apigw-mock-local/tests +plugins: timeout-2.4.0, xdist-3.8.0 +collected 9 items +unit/src/test_apigateway_local.py SAM Local API Gateway is running on port 3000 +API Gateway endpoint is responding correctly +API Gateway response: {'StatusCode': 200, 'Response': 'This is mock response', 'ResponseTime': 496ms} +.API Gateway response format validation passed - all headers and format requirements met +.Error handling test passed: Invalid endpoint returned status 403 +Error handling test passed: Wrong HTTP method returned status 403 +Error handling test passed: Unsupported HTTP method PUT returned status 403 +Error handling test passed: Unsupported HTTP method DELETE returned status 403 +API Gateway error handling test passed - all error scenarios handled appropriately +.Performance metrics: + Average: 490ms + Min: 484ms + Max: 496ms + Consistency: All responses within acceptable range +Performance test completed: avg=490ms, min=484ms, max=496ms +.Concurrent requests test passed +Results: Success_Rate=100.0%, Avg_Response_Time=1642ms, Successful=5/5 +.Input validation test 1 passed: Basic request +Input validation test 2 passed: Request with query parameters +Input validation test 3 passed: Request with custom headers +Input validation test 4 passed: Request with Accept header +Input validation test 5 passed: Combined request with params and headers +Input validation test passed - 5 scenarios handled correctly +.Server header present: Werkzeug/3.0.1 Python/3.11.3 +Response headers validation passed - all required headers present and properly formatted +.Timeout test passed: Normal timeout - 478ms +Timeout test passed: Standard timeout - 505ms +Timeout test passed: Short timeout - 484ms +Timeout handling test passed - all timeout scenarios handled correctly +.Connection resilience test passed +Results: Success_Rate=100.0%, Avg_Response_Time=499ms, Successful=10/10 +. + +========================================================== 9 passed in 18.17s ========================================================== +``` + +#### Clean up section + +> clean pyenv environment + +```sh +apigw-mock-local/tests$ +deactivate +rm -rf venv/ +``` + +> stopping SAM local process: + +```sh +apigw-mock-local$ +ps -axuf | grep '[s]am local start-api' | awk '{print $2}' | xargs -r kill +``` + +#### Debug - PyTest Debugging + +For more detailed debugging in pytest: + +```sh +# Run with verbose output +python -m pytest -s -v unit/src/test_apigateway_local.py + +# Run with debug logging +python -m pytest -s -v unit/src/test_apigateway_local.py --log-cli-level=DEBUG + +# List available individual tests +python3 -m pytest unit/src/test_apigateway_local.py --collect-only + +# Run a specific pytest test +python3 -m pytest -s unit/src/test_apigateway_local.py::test_api_basic_mock_response -v +``` + +--- + +### Run the Local API Testing + +> Start the API Gateway emulator: + +```shell +apigw-mock-local$ +sam local start-api --port 3000 --docker-network host & +``` + +Expected output: +```shell +apigw-mock-local$ +Mounting LambdaMockFunction at http://127.0.0.1:3000/MOCK [GET] + +You can now browse to the above endpoints to invoke your functions. You do not need to restart/reload SAM CLI while working on your functions, changes will be reflected instantly/automatically. You only need to restart SAM CLI if you update your AWS SAM template +2024-08-05 10:30:15 * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit) +``` + +#### Test the Mock Endpoint + +```shell +apigw-mock-local$ +curl -X GET http://127.0.0.1:3000/MOCK +``` + +Expected response: +```json +"This is mock response" +``` + +#### Test with Verbose Output + +```shell +apigw-mock-local$ +curl -v -X GET http://127.0.0.1:3000/MOCK +``` + +Expected verbose output: +```shell +* Trying 127.0.0.1:3000... +* Connected to 127.0.0.1 (127.0.0.1) port 3000 (#0) +> GET /MOCK HTTP/1.1 +> Host: 127.0.0.1:3000 +> User-Agent: curl/8.0.1 +> Accept: */* +> +< HTTP/1.1 200 OK +< Content-Type: application/json +< Content-Length: 22 +< Server: Werkzeug/2.3.6 Python/3.9.18 +< Date: Mon, 05 Aug 2024 14:30:15 GMT +< +"This is mock response" +``` + +#### Test Invalid Endpoints + +```shell +# Test non-existent endpoint +apigw-mock-local$ +curl -X GET http://127.0.0.1:3000/INVALID + +# Test wrong HTTP method +curl -X POST http://127.0.0.1:3000/MOCK +``` + +#### Clean up section + +> Stop SAM local process: + +```sh +apigw-mock-local$ +ps -axuf | grep '[s]am local start-api' | awk '{print $2}' | xargs -r kill +``` + +--- + +### Fast local development for API Gateway + +#### Manual Lambda Function Testing + +You can test the Python Lambda function directly without API Gateway: + +#### Test Lambda function directly + +```sh +# Test Python Lambda function locally +apigw-mock-local$ +sam local invoke LambdaMockFunction --event events/test-event.json +``` + +#### Debug API Gateway Routes + +```sh +# List all available endpoints +apigw-mock-local$ +sam local start-api --port 3000 --docker-network host --debug & + +# Check template syntax +apigw-mock-local$ +sam validate --lint + +# Generate sample events for testing +apigw-mock-local$ +sam local generate-event apigateway aws-proxy > events/sample-event.json + +# Build the application (Python dependencies) +apigw-mock-local$ +sam build +``` + +--- + +## API Documentation + +### Endpoints + +| Endpoint | Method | Response Type | Status Code | Description | +|----------|---------|---------------|-------------|-------------| +| `/MOCK` | GET | JSON String | 200 | Returns a simple mock response message | + +### Response Examples + +**Successful Response (200):** +```json +"This is mock response" +``` + +**Invalid Endpoint (403):** +```json +{ + "message": "Missing Authentication Token" +} +``` + +### Request/Response Flow + +1. **Request**: `GET /MOCK` +2. **API Gateway Processing**: Routes request to Python Lambda function +3. **Lambda Execution**: Python function returns mock response with 200 status code +4. **Response**: JSON string containing mock message + +--- + +## Additional Resources + +- [AWS SAM CLI Installation Guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) +- [SAM Local API Testing Guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-using-start-api.html) +- [AWS API Gateway Developer Guide](https://docs.aws.amazon.com/apigateway/latest/developerguide/welcome.html) +- [AWS Lambda Python Developer Guide](https://docs.aws.amazon.com/lambda/latest/dg/lambda-python.html) +- [Python 3.10 Runtime Guide](https://docs.aws.amazon.com/lambda/latest/dg/python-runtime.html) +- [SAM Template Specification](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-specification.html) +- [Local Testing with SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-using-invoke.html) + +[Top](#contents) \ No newline at end of file diff --git a/python-test-samples/apigw-mock-local/events/test-event.json b/python-test-samples/apigw-mock-local/events/test-event.json new file mode 100644 index 00000000..b7a88f21 --- /dev/null +++ b/python-test-samples/apigw-mock-local/events/test-event.json @@ -0,0 +1,7 @@ +{ + "httpMethod": "GET", + "path": "/MOCK", + "headers": { + "Accept": "application/json" + } +} diff --git a/python-test-samples/apigw-mock-local/img/apigateway-mock.png b/python-test-samples/apigw-mock-local/img/apigateway-mock.png new file mode 100644 index 0000000000000000000000000000000000000000..d88090d41f8dbe0c167cbe66f8929d31d87fac3f GIT binary patch literal 59925 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 X{UR@}*KhM|7=Xaj)z4*}Q$iB})pk)x literal 0 HcmV?d00001 diff --git a/python-test-samples/apigw-mock-local/lambda_mock_src/app.py b/python-test-samples/apigw-mock-local/lambda_mock_src/app.py new file mode 100644 index 00000000..3fc2df22 --- /dev/null +++ b/python-test-samples/apigw-mock-local/lambda_mock_src/app.py @@ -0,0 +1,8 @@ +import json + +def lambda_handler(event, context): + response = { + 'statusCode': 200, + 'body': json.dumps('This is mock response') + } + return response \ No newline at end of file diff --git a/python-test-samples/apigw-mock-local/template.yaml b/python-test-samples/apigw-mock-local/template.yaml new file mode 100755 index 00000000..724d4b77 --- /dev/null +++ b/python-test-samples/apigw-mock-local/template.yaml @@ -0,0 +1,25 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM Template for API Gateway with Mock and Lambda integration using Python + +Resources: + # API Gateway with MOCK integration + APIGatewayMock: + Type: AWS::Serverless::Api + Properties: + StageName: Prod + + # Lambda function to run the MOCK + LambdaMockFunction: + Type: AWS::Serverless::Function + Properties: + Handler: app.lambda_handler + Runtime: python3.10 + CodeUri: lambda_mock_src/ + Events: + EventMock: + Type: Api + Properties: + Path: /MOCK + Method: get + RestApiId: !Ref APIGatewayMock \ No newline at end of file diff --git a/python-test-samples/apigw-mock-local/tests/requirements.txt b/python-test-samples/apigw-mock-local/tests/requirements.txt new file mode 100644 index 00000000..b3017b62 --- /dev/null +++ b/python-test-samples/apigw-mock-local/tests/requirements.txt @@ -0,0 +1,14 @@ +# Testing framework +pytest>=8.0.0 +pytest-xdist>=3.5.0 +pytest-timeout>=2.3.0 + +# HTTP client for API testing +requests>=2.31.0 + +# AWS SDK (for potential future extensions) +boto3>=1.34.0 +botocore>=1.34.0 + +# Additional testing utilities +urllib3>=2.0.0 \ No newline at end of file diff --git a/python-test-samples/apigw-mock-local/tests/unit/src/test_apigateway_local.py b/python-test-samples/apigw-mock-local/tests/unit/src/test_apigateway_local.py new file mode 100644 index 00000000..82c9d18d --- /dev/null +++ b/python-test-samples/apigw-mock-local/tests/unit/src/test_apigateway_local.py @@ -0,0 +1,539 @@ +import pytest +import requests +import json +import time +import socket +import threading +import queue +from datetime import datetime +from requests.exceptions import RequestException, ConnectionError + + +@pytest.fixture(scope="session") +def api_container(): + """ + Fixture to verify SAM Local API Gateway emulator is running. + This fixture assumes the emulator is already started externally. + """ + # Check if API Gateway emulator is running on port 3000 + def is_port_open(host, port): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(5) + result = s.connect_ex((host, port)) + return result == 0 + except: + return False + + if not is_port_open("127.0.0.1", 3000): + pytest.skip("SAM Local API Gateway emulator is not running on port 3000. Please start with 'sam local start-api --port 3000'") + + print("SAM Local API Gateway is running on port 3000") + yield "http://127.0.0.1:3000" + + +@pytest.fixture(scope="session") +def api_client(): + """ + Fixture to create a base URL for API testing. + """ + return "http://127.0.0.1:3000" + + +@pytest.fixture(scope="session") +def health_check(api_container, api_client): + """ + Fixture to perform initial health check of the API Gateway endpoint. + """ + try: + response = requests.get(f"{api_client}/MOCK", timeout=10) + + if response.status_code == 200: + print("API Gateway endpoint is responding correctly") + return True + else: + pytest.fail(f"API Gateway health check failed with status: {response.status_code}") + + except Exception as e: + pytest.fail(f"API Gateway health check failed: {str(e)}") + + +def test_api_basic_mock_response(api_client, health_check): + """ + Test the basic API Gateway mock endpoint. + Validates the default mock response functionality. + """ + # Make GET request to mock endpoint + start_time = time.time() + response = requests.get(f"{api_client}/MOCK", timeout=10) + end_time = time.time() + + response_time = int((end_time - start_time) * 1000) + + # Validate HTTP response + assert response.status_code == 200, f"API request failed with status: {response.status_code}" + + # Validate response headers + assert 'content-type' in response.headers, "Response should contain Content-Type header" + content_type = response.headers.get('content-type', '').lower() + assert 'json' in content_type, f"Expected JSON content type, got: {content_type}" + + # Validate response content + try: + response_data = response.json() + except ValueError: + pytest.fail(f"Response is not valid JSON: {response.text}") + + # Validate mock response content + expected_response = "This is mock response" + assert response_data == expected_response, f"Expected '{expected_response}', got '{response_data}'" + + # Validate response time is reasonable + assert response_time < 5000, f"Response time too slow: {response_time}ms" + + print(f"API Gateway response: {{'StatusCode': {response.status_code}, 'Response': '{response_data}', 'ResponseTime': {response_time}ms}}") + + +def test_api_response_format_validation(api_client, health_check): + """ + Test that the API Gateway response format is correct. + Validates headers, content type, and JSON structure. + """ + # Make request to mock endpoint + response = requests.get(f"{api_client}/MOCK", timeout=10) + + # Validate HTTP status code + assert response.status_code == 200, f"API request failed with status: {response.status_code}" + + # Validate response headers + required_headers = ['content-type', 'content-length'] + for header in required_headers: + assert header in response.headers, f"Response missing required header: {header}" + + # Validate content type + content_type = response.headers.get('content-type', '') + assert 'application/json' in content_type or 'json' in content_type, \ + f"Expected JSON content type, got: {content_type}" + + # Validate content length + content_length = int(response.headers.get('content-length', 0)) + assert content_length > 0, "Content-Length should be greater than 0" + + # Validate response body + response_text = response.text + assert len(response_text) > 0, "Response body should not be empty" + + # Validate JSON parsing + try: + response_data = response.json() + assert response_data is not None, "Parsed JSON should not be None" + except ValueError as e: + pytest.fail(f"Response body is not valid JSON: {e}") + + # Validate response encoding + assert response.encoding is not None, "Response should have encoding information" + + print("API Gateway response format validation passed - all headers and format requirements met") + + +def test_api_error_handling(api_client, health_check): + """ + Test API Gateway error handling with invalid requests. + Validates proper error responses for various edge cases. + """ + # Test scenarios with expected error responses + test_scenarios = [ + # Invalid endpoint + { + "url": f"{api_client}/INVALID", + "method": "GET", + "expected_status": [403, 404], + "description": "Invalid endpoint" + }, + # Wrong HTTP method + { + "url": f"{api_client}/MOCK", + "method": "POST", + "expected_status": [403, 405], + "description": "Wrong HTTP method" + }, + # Wrong HTTP method - PUT + { + "url": f"{api_client}/MOCK", + "method": "PUT", + "expected_status": [403, 405], + "description": "Unsupported HTTP method PUT" + }, + # Wrong HTTP method - DELETE + { + "url": f"{api_client}/MOCK", + "method": "DELETE", + "expected_status": [403, 405], + "description": "Unsupported HTTP method DELETE" + } + ] + + for scenario in test_scenarios: + try: + if scenario["method"] == "GET": + response = requests.get(scenario["url"], timeout=10) + elif scenario["method"] == "POST": + response = requests.post(scenario["url"], timeout=10) + elif scenario["method"] == "PUT": + response = requests.put(scenario["url"], timeout=10) + elif scenario["method"] == "DELETE": + response = requests.delete(scenario["url"], timeout=10) + + # Validate error status codes + assert response.status_code in scenario["expected_status"], \ + f"{scenario['description']}: Expected status {scenario['expected_status']}, got {response.status_code}" + + # Validate that error responses are still properly formatted + assert 'content-type' in response.headers, f"{scenario['description']}: Error response should have content-type" + + print(f"Error handling test passed: {scenario['description']} returned status {response.status_code}") + + except requests.RequestException as e: + # Network errors are acceptable for invalid requests + print(f"Network error for {scenario['description']}: {str(e)} (acceptable)") + + print("API Gateway error handling test passed - all error scenarios handled appropriately") + + +def test_api_performance_metrics(api_client, health_check): + """ + Test API Gateway performance and measure response metrics. + """ + # Perform multiple requests to measure performance consistency + response_times = [] + responses = [] + + for i in range(5): + start_time = time.time() + + response = requests.get(f"{api_client}/MOCK", timeout=10) + + end_time = time.time() + response_time = int((end_time - start_time) * 1000) # Convert to milliseconds + response_times.append(response_time) + + # Validate each response + assert response.status_code == 200, f"Request {i+1} failed with status: {response.status_code}" + + response_data = response.json() + responses.append(response_data) + + # Small delay between requests + if i < 4: + time.sleep(0.2) + + # Analyze performance metrics + avg_response_time = sum(response_times) / len(response_times) + min_response_time = min(response_times) + max_response_time = max(response_times) + + # Performance assertions (reasonable for API Gateway + Lambda) + assert avg_response_time < 10000, f"Average response time too slow: {avg_response_time}ms" + assert min_response_time < 5000, f"Minimum response time too slow: {min_response_time}ms" + + # Validate response consistency + expected_response = "This is mock response" + for i, response_data in enumerate(responses): + assert response_data == expected_response, f"Response {i+1} inconsistent: {response_data}" + + # Check for performance consistency (no response should be significantly slower) + max_acceptable_time = avg_response_time * 3 + for i, response_time in enumerate(response_times): + assert response_time < max_acceptable_time, \ + f"Request {i+1} response time ({response_time}ms) significantly slower than average ({avg_response_time}ms)" + + print(f"Performance metrics:") + print(f" Average: {int(avg_response_time)}ms") + print(f" Min: {min_response_time}ms") + print(f" Max: {max_response_time}ms") + print(f" Consistency: All responses within acceptable range") + + print(f"Performance test completed: avg={int(avg_response_time)}ms, min={min_response_time}ms, max={max_response_time}ms") + + +def test_api_concurrent_requests(api_client, health_check): + """ + Test concurrent API requests to validate thread safety and load handling. + """ + results = queue.Queue() + num_threads = 5 + + def make_api_request(thread_id): + """Helper function for concurrent API requests""" + try: + start_time = time.time() + + response = requests.get(f"{api_client}/MOCK", timeout=15) + + end_time = time.time() + response_time = int((end_time - start_time) * 1000) + + # Parse response + response_data = response.json() + + results.put({ + 'thread_id': thread_id, + 'success': response.status_code == 200, + 'response_time': response_time, + 'response_data': response_data, + 'status_code': response.status_code + }) + + except Exception as e: + results.put({ + 'thread_id': thread_id, + 'success': False, + 'error': str(e), + 'response_time': 0 + }) + + # Start concurrent threads + threads = [] + for i in range(num_threads): + thread = threading.Thread(target=make_api_request, args=(i,)) + threads.append(thread) + thread.start() + + # Wait for all threads to complete + for thread in threads: + thread.join(timeout=30) + + # Analyze results + successful_requests = 0 + total_response_time = 0 + expected_response = "This is mock response" + + while not results.empty(): + result = results.get() + if result['success']: + successful_requests += 1 + total_response_time += result['response_time'] + + # Validate response consistency + assert result['response_data'] == expected_response, \ + f"Thread {result['thread_id']} returned inconsistent response: {result['response_data']}" + else: + print(f"Thread {result['thread_id']} failed: {result.get('error', 'Unknown error')}") + + success_rate = successful_requests / num_threads * 100 + avg_response_time = total_response_time / successful_requests if successful_requests > 0 else 0 + + # Validate concurrent performance + assert success_rate >= 90, f"Concurrent request success rate too low: {success_rate}%" + assert successful_requests >= num_threads - 1, f"Too many failed concurrent requests" + assert avg_response_time < 15000, f"Average concurrent response time too slow: {avg_response_time}ms" + + print(f"Concurrent requests test passed") + print(f"Results: Success_Rate={success_rate}%, Avg_Response_Time={int(avg_response_time)}ms, Successful={successful_requests}/{num_threads}") + + +def test_api_input_validation(api_client, health_check): + """ + Test API Gateway with various input scenarios and query parameters. + """ + # Test scenarios with different request variations + test_scenarios = [ + # Basic request + { + "url": f"{api_client}/MOCK", + "params": None, + "headers": None, + "description": "Basic request" + }, + # Request with query parameters (should still work) + { + "url": f"{api_client}/MOCK", + "params": {"param1": "value1", "param2": "value2"}, + "headers": None, + "description": "Request with query parameters" + }, + # Request with custom headers + { + "url": f"{api_client}/MOCK", + "params": None, + "headers": {"User-Agent": "pytest-test", "X-Custom-Header": "test-value"}, + "description": "Request with custom headers" + }, + # Request with Accept header + { + "url": f"{api_client}/MOCK", + "params": None, + "headers": {"Accept": "application/json"}, + "description": "Request with Accept header" + }, + # Combined request + { + "url": f"{api_client}/MOCK", + "params": {"test": "combined"}, + "headers": {"Accept": "application/json", "X-Test": "true"}, + "description": "Combined request with params and headers" + } + ] + + expected_response = "This is mock response" + + for i, scenario in enumerate(test_scenarios): + try: + response = requests.get( + scenario["url"], + params=scenario["params"], + headers=scenario["headers"], + timeout=10 + ) + + # Validate basic response + assert response.status_code == 200, \ + f"{scenario['description']}: Expected status 200, got {response.status_code}" + + # Validate response content consistency + response_data = response.json() + assert response_data == expected_response, \ + f"{scenario['description']}: Response should be consistent regardless of input" + + # Validate response format + assert 'content-type' in response.headers, \ + f"{scenario['description']}: Response should have content-type header" + + print(f"Input validation test {i+1} passed: {scenario['description']}") + + except requests.RequestException as e: + pytest.fail(f"Input validation test failed for {scenario['description']}: {str(e)}") + + print(f"Input validation test passed - {len(test_scenarios)} scenarios handled correctly") + + +def test_api_response_headers_validation(api_client, health_check): + """ + Test that API Gateway returns appropriate response headers. + """ + response = requests.get(f"{api_client}/MOCK", timeout=10) + + # Validate basic response + assert response.status_code == 200, f"API request failed with status: {response.status_code}" + + # Check for essential headers + essential_headers = ['content-type', 'content-length'] + for header in essential_headers: + assert header in response.headers, f"Missing essential header: {header}" + + # Validate content-type header + content_type = response.headers.get('content-type', '').lower() + valid_content_types = ['application/json', 'text/json', 'json'] + assert any(ct in content_type for ct in valid_content_types), \ + f"Invalid content-type for JSON response: {content_type}" + + # Validate content-length header + content_length = response.headers.get('content-length') + if content_length: + assert int(content_length) > 0, "Content-Length should be greater than 0" + assert int(content_length) == len(response.content), \ + "Content-Length should match actual content length" + + # Check for server header (optional but informative) + server_header = response.headers.get('server', '') + if server_header: + print(f"Server header present: {server_header}") + + # Validate that headers are properly formatted + for header_name, header_value in response.headers.items(): + assert isinstance(header_name, str), f"Header name should be string: {header_name}" + assert isinstance(header_value, str), f"Header value should be string: {header_value}" + assert len(header_name) > 0, f"Header name should not be empty" + + print("Response headers validation passed - all required headers present and properly formatted") + + +def test_api_timeout_handling(api_client, health_check): + """ + Test API Gateway timeout handling and response time limits. + """ + # Test with various timeout scenarios + timeout_scenarios = [ + {"timeout": 30, "description": "Normal timeout"}, + {"timeout": 10, "description": "Standard timeout"}, + {"timeout": 5, "description": "Short timeout"} + ] + + for scenario in timeout_scenarios: + try: + start_time = time.time() + response = requests.get(f"{api_client}/MOCK", timeout=scenario["timeout"]) + end_time = time.time() + + response_time = int((end_time - start_time) * 1000) + + # Validate response + assert response.status_code == 200, \ + f"{scenario['description']}: Request failed with status {response.status_code}" + + # Validate response time is within timeout + timeout_ms = scenario["timeout"] * 1000 + assert response_time < timeout_ms, \ + f"{scenario['description']}: Response time ({response_time}ms) exceeded timeout ({timeout_ms}ms)" + + # Validate response content + response_data = response.json() + expected_response = "This is mock response" + assert response_data == expected_response, \ + f"{scenario['description']}: Response content should be consistent" + + print(f"Timeout test passed: {scenario['description']} - {response_time}ms") + + except requests.Timeout: + pytest.fail(f"Request timed out for {scenario['description']} - this shouldn't happen for a mock endpoint") + except requests.RequestException as e: + pytest.fail(f"Request failed for {scenario['description']}: {str(e)}") + + print("Timeout handling test passed - all timeout scenarios handled correctly") + + +def test_api_connection_resilience(api_client, health_check): + """ + Test API Gateway connection resilience with rapid sequential requests. + """ + # Make rapid sequential requests to test connection handling + num_requests = 10 + successful_requests = 0 + response_times = [] + + for i in range(num_requests): + try: + start_time = time.time() + response = requests.get(f"{api_client}/MOCK", timeout=10) + end_time = time.time() + + response_time = int((end_time - start_time) * 1000) + response_times.append(response_time) + + if response.status_code == 200: + successful_requests += 1 + + # Validate response content + response_data = response.json() + expected_response = "This is mock response" + assert response_data == expected_response, \ + f"Request {i+1}: Response content inconsistent" + + # Very small delay to make rapid requests + time.sleep(0.05) + + except requests.RequestException as e: + print(f"Request {i+1} failed: {str(e)}") + + success_rate = successful_requests / num_requests * 100 + avg_response_time = sum(response_times) / len(response_times) if response_times else 0 + + # Validate connection resilience + assert success_rate >= 90, f"Connection resilience test failed: success rate {success_rate}%" + assert successful_requests >= num_requests - 2, f"Too many failed requests: {successful_requests}/{num_requests}" + + if response_times: + assert avg_response_time < 8000, f"Average response time too slow under load: {avg_response_time}ms" + + print(f"Connection resilience test passed") + print(f"Results: Success_Rate={success_rate}%, Avg_Response_Time={int(avg_response_time)}ms, Successful={successful_requests}/{num_requests}") \ No newline at end of file