From c585d1c221aaa8758b1518422b81ee8298a87b3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 03:42:34 +0000 Subject: [PATCH 1/2] Initial plan From 1c08aa097c6b6d537951bcce3ef64860f4c040d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 03:54:58 +0000 Subject: [PATCH 2/2] feat: complete framework implementation - add missing modules, tests, docs Co-authored-by: ErickFlores13 <112999558+ErickFlores13@users.noreply.github.com> --- README.md | 119 +++-- conftest.py | 27 + core/api/services/auth/strategies/oauth2.py | 68 ++- docs/API_TESTING.md | 543 ++++++++++++++++++++ docs/DATABASE_TESTING.md | 340 ++++++++++++ docs/UI_TESTING.md | 482 +++++++++++++++++ services/__init__.py | 6 + services/base_pages/__init__.py | 3 + services/base_pages/login_page.py | 77 +++ tests/__init__.py | 1 + tests/test_api_examples.py | 386 ++++++++++++++ tests/test_crud_example.py | 284 ++++++++++ tests/test_database_examples.py | 242 +++++++++ tests/test_ui_examples.py | 275 ++++++++++ utils/config.py | 21 - 15 files changed, 2797 insertions(+), 77 deletions(-) create mode 100644 docs/API_TESTING.md create mode 100644 docs/DATABASE_TESTING.md create mode 100644 docs/UI_TESTING.md create mode 100644 services/__init__.py create mode 100644 services/base_pages/__init__.py create mode 100644 services/base_pages/login_page.py create mode 100644 tests/__init__.py create mode 100644 tests/test_api_examples.py create mode 100644 tests/test_crud_example.py create mode 100644 tests/test_database_examples.py create mode 100644 tests/test_ui_examples.py diff --git a/README.md b/README.md index c16105d..11d94e0 100644 --- a/README.md +++ b/README.md @@ -151,47 +151,96 @@ Pre-commit hooks run automatically on `git commit` and ensure consistent code qu ## 🏗️ Project Structure ``` -playwright-python-async-template/ -├── pages/ # Page Object Model -│ ├── base_page.py # Core browser interactions -│ ├── standard_web_page.py # Common UI patterns (CRUD, filters, etc.) -│ ├── login_page.py # Authentication -│ └── examples/ # Example page objects +playwright-python-async-framework/ +├── core/ # Framework internals +│ ├── api/ # API testing layer +│ │ ├── base_client.py # BaseAPIClient (HTTP methods + auth) +│ │ ├── http_client.py # Low-level HTTP with retries +│ │ ├── config.py # HTTPConfig presets +│ │ ├── models.py # API exception models +│ │ ├── services/ +│ │ │ ├── auth/ # Authentication strategies +│ │ │ │ └── strategies/ # Bearer, APIKey, Basic, OAuth2, etc. +│ │ │ ├── response/ # APIResponseWrapper +│ │ │ ├── retry.py # Retry with exponential backoff +│ │ │ ├── interceptor.py # Request/response interceptors +│ │ │ └── validation.py # Schema + status code validation +│ │ ├── README.md # API usage guide (user-facing) +│ │ └── DEVELOPER_GUIDE.md # How to extend the API layer +│ │ +│ ├── ui/ # UI testing layer +│ │ ├── base_page.py # BasePage with lazy-loaded services +│ │ ├── ai/ # AI-powered selector healing +│ │ │ ├── locator_healer.py # Main healing orchestrator +│ │ │ ├── cache_manager.py # Persistent healing cache +│ │ │ ├── metrics_tracker.py # Healing metrics & reports +│ │ │ └── extraction/ # DOM extraction strategies +│ │ ├── browser/ +│ │ │ ├── browser_manager.py # Browser lifecycle management +│ │ │ └── strategies/ # Local / CI / Debug strategies +│ │ ├── components/ # Reusable UI components +│ │ │ ├── button.py, checkbox.py, datepicker.py +│ │ │ ├── input.py, modal.py, radio.py +│ │ │ ├── select.py, select2.py, table.py +│ │ │ └── file.py +│ │ ├── services/ # Page interaction services +│ │ │ ├── attribute.py # DOM attribute manipulation +│ │ │ ├── screenshot.py # Evidence capture +│ │ │ ├── storage.py # LocalStorage / Cookies +│ │ │ ├── tab_window.py # Multi-tab management +│ │ │ ├── validation.py # Assertion helpers +│ │ │ ├── wait.py # Page-load waiting +│ │ │ └── form/ # Form-filling strategies +│ │ ├── wrappers/ # Locator wrappers +│ │ │ ├── retry_locator.py # Retry failed operations +│ │ │ └── smart_locator.py # AI healing + retry wrapper +│ │ ├── README.md # UI usage guide (user-facing) +│ │ └── DEVELOPER_GUIDE.md # How to extend the UI layer +│ │ +│ ├── reporting/ +│ │ └── pytest_hooks.py # Auto-screenshot on failure + Allure +│ │ +│ └── utils/ +│ ├── exceptions.py # Custom exception types +│ ├── logger_config.py # Centralized logging setup +│ └── playwright_utils.py # Locator resolution helpers │ -├── helpers/ # Helper modules -│ ├── api_client.py # API testing client -│ ├── database.py # Database client (PostgreSQL, MySQL, etc.) -│ └── redis_client.py # Redis client +├── services/ # Project-level page objects +│ └── base_pages/ +│ └── login_page.py # Generic login page (used by conftest) │ -├── utils/ # Utilities -│ ├── config.py # Configuration management -│ ├── consts.py # Constants and enums -│ ├── exceptions.py # Custom exceptions -│ └── test_helpers.py # Test utilities +├── helpers/ # Infrastructure clients +│ ├── database.py # DatabaseClient (PostgreSQL, MySQL, …) +│ └── redis_client.py # RedisClient │ -├── tests/ # Test suites -│ ├── test_ui_examples.py # UI testing examples -│ ├── test_api_examples.py # API testing examples -│ ├── test_database_examples.py # Database testing examples -│ └── test_crud_example.py # Complete CRUD example +├── utils/ # Project-level utilities +│ ├── config.py # Centralized configuration (env vars) +│ ├── consts.py # Enumerations (FilterType, etc.) +│ └── test_helpers.py # TestDataGenerator + TestHelpers │ -├── docs/ # Documentation -│ ├── UI_TESTING.md # UI testing guide -│ ├── API_TESTING.md # API testing guide -│ └── DATABASE_TESTING.md # Database testing guide +├── tests/ # Example test suites +│ ├── test_ui_examples.py # UI automation examples +│ ├── test_api_examples.py # REST API testing examples +│ ├── test_database_examples.py # Database & Redis testing examples +│ └── test_crud_example.py # Complete CRUD workflow example │ -├── ci/ # CI/CD configuration -│ └── Jenkinsfile # Jenkins pipeline +├── docs/ # User-facing documentation +│ ├── UI_TESTING.md # UI testing patterns & examples +│ ├── API_TESTING.md # API testing patterns & examples +│ └── DATABASE_TESTING.md # Database testing patterns & examples │ -├── conftest.py # Pytest configuration and fixtures -├── pytest.ini # Pytest settings -├── requirements.txt # Python dependencies -├── Dockerfile # Docker configuration -├── docker-compose.yml # Docker Compose orchestration -├── .pre-commit-config.yaml # Pre-commit hooks configuration -├── pyproject.toml # Python tooling configuration -├── .env.example # Environment template -└── README.md # This file +├── ci/ # CI/CD configuration +│ └── Jenkinsfile # Jenkins pipeline +│ +├── conftest.py # Pytest fixtures (browser, page, api_client, …) +├── pytest.ini # Pytest settings (markers, testpaths, …) +├── requirements.txt # Python dependencies +├── Dockerfile # Docker image for test runner +├── docker-compose.yml # Multi-service Docker Compose (DB + Redis) +├── .pre-commit-config.yaml # Pre-commit hooks (black, flake8, mypy, …) +├── pyproject.toml # Tool configuration (black, isort, mypy, …) +├── .env.example # Environment variable template +└── README.md # This file ``` --- diff --git a/conftest.py b/conftest.py index 0403201..93e91e7 100644 --- a/conftest.py +++ b/conftest.py @@ -16,6 +16,8 @@ from core.utils.logger_config import configure_logging from helpers.database import DatabaseClient +from core.api.base_client import BaseAPIClient + # Custom imports from helpers.redis_client import RedisClient from services.base_pages.login_page import LoginPage @@ -162,6 +164,31 @@ async def _connect_to_environment(username: str, password: str, url: str): return _connect_to_environment +# --- API client fixture ------------------------------------------------------- +@pytest_asyncio.fixture(scope="function") +async def api_client(context: BrowserContext) -> BaseAPIClient: + """ + Provides a ready-to-use BaseAPIClient for API testing. + + The client is pre-configured with the base URL from the + ``API_BASE_URL`` environment variable (falls back to ``BASE_URL``). + Authentication can be configured per-test via: + + - ``client.set_bearer_token("token")`` + - ``client.set_api_key("key")`` + - ``client.set_basic_auth("user", "pass")`` + + Example:: + + async def test_get_users(api_client): + api_client.set_bearer_token(Config.get_api_bearer_token()) + response = await api_client.get("/users") + assert response.is_success + assert isinstance(response.data, list) + """ + return BaseAPIClient(context.request, Config.get_api_base_url()) + + # --- Database fixtures -------------------------------------------------------- @pytest_asyncio.fixture async def db_client(): diff --git a/core/api/services/auth/strategies/oauth2.py b/core/api/services/auth/strategies/oauth2.py index 28c0fbb..2c2be7e 100644 --- a/core/api/services/auth/strategies/oauth2.py +++ b/core/api/services/auth/strategies/oauth2.py @@ -70,31 +70,57 @@ def _needs_refresh(self) -> bool: async def _fetch_token(self) -> None: """ - Fetch access token from token endpoint. + Fetch access token from token endpoint using client credentials flow. - Note: Requires HTTPClient to be injected or uses requests library. + Makes a POST request to the token URL with client credentials. + Uses the standard OAuth2 client_credentials grant type. + + Raises: + RuntimeError: If token request fails or response is invalid """ - # This would use HTTPClient in real implementation - # For now, showing the structure + import asyncio + import json + import urllib.error + import urllib.parse + import urllib.request + logger.info(f"Fetching OAuth2 token from {self.token_url}") - # In real implementation: - # response = await http_client.post( - # self.token_url, - # data={ - # 'grant_type': 'client_credentials', - # 'client_id': self.client_id, - # 'client_secret': self.client_secret, - # 'scope': self.scope - # } - # ) - # self._access_token = response.data['access_token'] - # self._expires_at = time.time() + response.data.get('expires_in', 3600) - - raise NotImplementedError( - "OAuth2 token fetching requires HTTPClient integration. " - "Use BearerTokenAuth with pre-fetched token for now." - ) + post_data: dict = { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + } + if self.scope: + post_data["scope"] = self.scope + + encoded_data = urllib.parse.urlencode(post_data).encode("utf-8") + + def _sync_fetch() -> dict: + req = urllib.request.Request( + self.token_url, + data=encoded_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode("utf-8")) + + try: + token_response = await asyncio.to_thread(_sync_fetch) + self._access_token = token_response["access_token"] + expires_in = token_response.get("expires_in", 3600) + self._expires_at = time.time() + int(expires_in) + logger.info("OAuth2 token fetched successfully") + except urllib.error.HTTPError as e: + error_body = e.read().decode("utf-8") + raise RuntimeError( + f"OAuth2 token request failed (HTTP {e.code}): {error_body}" + ) from e + except (KeyError, ValueError) as e: + raise RuntimeError(f"Invalid OAuth2 token response: {e}") from e + except Exception as e: + raise RuntimeError(f"Failed to fetch OAuth2 token: {e}") from e @classmethod def from_env( diff --git a/docs/API_TESTING.md b/docs/API_TESTING.md new file mode 100644 index 0000000..dc947f1 --- /dev/null +++ b/docs/API_TESTING.md @@ -0,0 +1,543 @@ +# API Testing Guide + +Complete guide for REST API testing using the Playwright Python Async Framework. + +> **Quick reference?** See [core/api/README.md](../core/api/README.md) +> **Extending the framework?** See [core/api/DEVELOPER_GUIDE.md](../core/api/DEVELOPER_GUIDE.md) + +--- + +## Table of Contents + +- [Overview](#overview) +- [Quick Start](#quick-start) +- [Service Client Pattern](#service-client-pattern) +- [HTTP Methods](#http-methods) +- [Authentication](#authentication) +- [Response Validation](#response-validation) +- [HTTP Configuration Presets](#http-configuration-presets) +- [conftest Fixtures](#conftest-fixtures) +- [Complete Examples](#complete-examples) + +--- + +## Overview + +The API testing layer wraps Playwright's `APIRequestContext` with a clean, +strategy-based client. The architecture has three layers: + +``` +Service Clients (your code) e.g. UserServiceClient, OrderClient + ↓ extends +BaseAPIClient (framework) get(), post(), put(), patch(), delete() + ↓ uses +HTTPClient (infrastructure) retry logic, interceptors, timeouts +``` + +You focus on **business logic**; the framework handles HTTP mechanics. + +--- + +## Quick Start + +### 1. Create a Service Client + +```python +from pydantic import BaseModel +from core.api.base_client import BaseAPIClient + +class User(BaseModel): + id: int + name: str + email: str + +class UserServiceClient(BaseAPIClient): + """Typed client for the User service.""" + + async def list_users(self) -> list[User]: + response = await self.get('/users') + return [User(**u) for u in response.data] + + async def get_user(self, user_id: int) -> User: + response = await self.get(f'/users/{user_id}') + return self.validation.validate_schema(response.data, User) + + async def create_user(self, name: str, email: str) -> User: + response = await self.post('/users', data={'name': name, 'email': email}) + return self.validation.validate_schema(response.data, User) + + async def delete_user(self, user_id: int) -> None: + await self.delete(f'/users/{user_id}') +``` + +### 2. Use the `api_client` Fixture + +A generic `api_client` fixture is available in every test via `conftest.py`: + +```python +async def test_api_health(api_client): + response = await api_client.get('/health') + assert response.is_success +``` + +### 3. Create a Typed Service Fixture + +For domain-specific clients, define a fixture in your test file or a +shared `conftest.py`: + +```python +import pytest_asyncio +from core.api.services.auth import BearerTokenAuth + +@pytest_asyncio.fixture +async def user_client(context): + auth = BearerTokenAuth(token='my-jwt-token') + return UserServiceClient( + context.request, + base_url='https://api.example.com', + auth_strategy=auth, + ) + +async def test_get_user(user_client): + user = await user_client.get_user(1) + assert user.id == 1 + assert user.name +``` + +--- + +## Service Client Pattern + +Define one `BaseAPIClient` subclass per service/domain: + +```python +class OrderServiceClient(BaseAPIClient): + """Client for the Orders microservice.""" + + async def create_order(self, items: list[dict]) -> dict: + response = await self.post('/orders', data={'items': items}, expected_status=201) + return response.data + + async def get_order(self, order_id: str) -> dict: + response = await self.get(f'/orders/{order_id}') + self.validation.validate_required_fields(response.data, ['id', 'status', 'items']) + return response.data + + async def cancel_order(self, order_id: str) -> None: + await self.delete(f'/orders/{order_id}', expected_status=[200, 204]) +``` + +--- + +## HTTP Methods + +All methods return an `APIResponseWrapper`: + +```python +response.data # Parsed JSON (dict or list), or plain text +response.status_code # HTTP status code (int) +response.headers # Response headers (dict) +response.elapsed_ms # Request duration in milliseconds +response.is_success # True for 2xx status codes +response.url # Request URL +response.method # HTTP method used +``` + +### GET + +```python +# Simple fetch +response = await client.get('/users/1') + +# With query parameters +response = await client.get('/users', params={'page': 1, 'per_page': 20, 'status': 'active'}) + +# Accept multiple status codes +response = await client.get('/users/99999', expected_status=[200, 404]) +``` + +### POST + +```python +response = await client.post('/users', data={ + 'name': 'Alice', + 'email': 'alice@example.com', + 'role': 'editor', +}) +# Default expected_status is 201 +``` + +### PUT (full update) + +```python +response = await client.put('/users/1', data={ + 'id': 1, + 'name': 'Alice Updated', + 'email': 'alice.new@example.com', +}) +``` + +### PATCH (partial update) + +```python +response = await client.patch('/users/1', data={'email': 'new@example.com'}) +``` + +### DELETE + +```python +response = await client.delete('/users/1') +# Default expected_status is 204 +``` + +### Custom Headers per Request + +```python +response = await client.get( + '/protected', + headers={'X-Request-ID': 'trace-abc-123', 'X-Tenant': 'tenant-1'}, +) +``` + +--- + +## Authentication + +### Bearer Token + +```python +from core.api.services.auth import BearerTokenAuth + +auth = BearerTokenAuth(token='your-jwt-token') + +# Or from environment (reads API_BEARER_TOKEN) +auth = BearerTokenAuth.from_env() + +# Or lazy fetch via login endpoint +auth = BearerTokenAuth( + playwright_context=context.request, + api_url='https://api.example.com', + auth_endpoint='/auth/token', + credentials={'username': 'user', 'password': 'pass'}, + token_field='access_token', +) +``` + +### API Key + +```python +from core.api.services.auth import APIKeyAuth + +auth = APIKeyAuth(api_key='sk_test_123456') # header: X-API-Key +auth = APIKeyAuth(api_key='abc123', header_name='Authorization') # custom header + +# From environment (reads API_KEY and API_KEY_HEADER_NAME) +auth = APIKeyAuth.from_env() +``` + +### Basic Auth + +```python +from core.api.services.auth import BasicAuth + +auth = BasicAuth(username='admin', password='password123') + +# From environment (reads API_USERNAME and API_PASSWORD) +auth = BasicAuth.from_env() +``` + +### OAuth2 Client Credentials + +```python +from core.api.services.auth import OAuth2ClientCredentialsAuth + +auth = OAuth2ClientCredentialsAuth( + client_id='your-client-id', + client_secret='your-secret', + token_url='https://auth.example.com/oauth/token', + scope='read write', # optional +) +# Token is fetched and cached automatically; refreshed when expired. + +# From environment (reads OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_SCOPE) +auth = OAuth2ClientCredentialsAuth.from_env( + token_url='https://auth.example.com/oauth/token' +) +``` + +### Refreshable Token + +```python +from core.api.services.auth import RefreshableTokenAuth +import time + +async def get_fresh_token(): + # Your refresh logic here + return 'new-access-token' + +auth = RefreshableTokenAuth( + initial_token='current-token', + refresh_callback=get_fresh_token, + expires_at=time.time() + 3600, +) +``` + +### Custom Headers + +```python +from core.api.services.auth import CustomHeaderAuth + +auth = CustomHeaderAuth(headers={ + 'X-API-Key': 'abc123', + 'X-Tenant-ID': 'tenant-456', + 'X-API-Version': 'v2', +}) +``` + +### Composite (Multiple Strategies) + +```python +from core.api.services.auth import CompositeAuth, BearerTokenAuth, CustomHeaderAuth + +auth = CompositeAuth([ + BearerTokenAuth(token='jwt-token'), + CustomHeaderAuth(headers={'X-Tenant-ID': 'tenant-1'}), +]) +``` + +### Switching Auth Mid-Test + +```python +from core.api.services.auth import BearerTokenAuth + +client.set_bearer_token('admin-token') +await client.delete('/users/42') + +client.set_bearer_token('regular-user-token') +response = await client.get('/users/me') +``` + +--- + +## Response Validation + +### Schema Validation (Pydantic) + +```python +from pydantic import BaseModel + +class User(BaseModel): + id: int + name: str + email: str + status: str + +user = self.validation.validate_schema(response.data, User) +print(user.name) # IDE autocomplete works, type-safe access +assert user.status == 'active' +``` + +### Status Code Validation + +```python +self.validation.validate_status_code(response, 200) +self.validation.validate_status_code(response, [200, 201]) # multiple acceptable +``` + +### Required Fields + +```python +self.validation.validate_required_fields( + response.data, + ['id', 'name', 'email', 'created_at'] +) +``` + +### JSON Path Validation + +```python +# Assert a field exists +self.validation.validate_json_path(response.data, 'user.address.city') + +# Assert a specific value +self.validation.validate_json_path(response.data, 'user.status', expected_value='active') + +# Nested array access: items[0].price +self.validation.validate_json_path(response.data, 'items[0].price', expected_value=9.99) +``` + +### Type and Length Validation + +```python +# Type check +self.validation.validate_response_type(response.data, list) +self.validation.validate_response_type(response.data, dict) + +# List length +self.validation.validate_list_length(response.data, min_length=1) +self.validation.validate_list_length(response.data, min_length=1, max_length=100) +self.validation.validate_list_length(response.data, exact_length=10) +``` + +--- + +## HTTP Configuration Presets + +Control retry and timeout behaviour with built-in presets: + +| Preset | Use Case | Retries | Timeout | +|--------|----------|---------|---------| +| `HTTPConfig.standard()` | Most API testing | 3 | 30 s | +| `HTTPConfig.external_api()` | Third-party APIs (Stripe, AWS) | 5 | 60 s | +| `HTTPConfig.local_api()` | Localhost / Docker | 1 | 10 s | +| `HTTPConfig.testing()` | Fast unit tests | 1 | 5 s | + +```python +from core.api.config import HTTPConfig + +client = BaseAPIClient(context.request, 'https://api.example.com', + http_config=HTTPConfig.external_api()) +``` + +Set the default preset via environment variable (applies to the `api_client` fixture): + +```bash +HTTP_CONFIG_MODE=local_api # Options: standard | external_api | local_api | testing +``` + +### Custom Configuration + +```python +from core.api.config import HTTPConfig, RetryConfig, TimeoutConfig + +config = HTTPConfig( + retry=RetryConfig(max_attempts=5, initial_delay=2.0, max_delay=30.0), + timeout=TimeoutConfig(request_timeout=45.0, connect_timeout=10.0), + base_headers={'User-Agent': 'MyTestFramework/1.0'}, +) +``` + +--- + +## conftest Fixtures + +### `api_client` (generic) + +Available in every test without any setup: + +```python +async def test_create_resource(api_client): + api_client.set_bearer_token('my-token') + response = await api_client.post('/resources', data={'name': 'test'}) + assert response.status_code == 201 +``` + +### `context` (Playwright browser context) + +Used to create service-specific clients: + +```python +@pytest_asyncio.fixture +async def order_client(context): + return OrderServiceClient( + context.request, + base_url='https://orders.example.com', + auth_strategy=BearerTokenAuth.from_env(), + ) +``` + +--- + +## Complete Examples + +### Example 1: Full CRUD Test + +```python +import pytest_asyncio +from pydantic import BaseModel +from core.api.base_client import BaseAPIClient +from core.api.services.auth import BearerTokenAuth + +class Product(BaseModel): + id: int + name: str + price: float + in_stock: bool + +class ProductClient(BaseAPIClient): + async def create(self, name, price) -> Product: + r = await self.post('/products', data={'name': name, 'price': price}) + return self.validation.validate_schema(r.data, Product) + + async def get(self, pid) -> Product: + r = await self.get(f'/products/{pid}') + return self.validation.validate_schema(r.data, Product) + + async def update(self, pid, **kw) -> Product: + r = await self.patch(f'/products/{pid}', data=kw) + return self.validation.validate_schema(r.data, Product) + + async def delete(self, pid) -> None: + await self.delete(f'/products/{pid}') + +@pytest_asyncio.fixture +async def products(context): + return ProductClient( + context.request, + base_url='https://api.example.com', + auth_strategy=BearerTokenAuth.from_env(), + ) + +async def test_product_crud(products): + # CREATE + p = await products.create('Laptop', 999.99) + assert p.id > 0 + + # READ + fetched = await products.get(p.id) + assert fetched.name == 'Laptop' + + # UPDATE + updated = await products.update(p.id, price=899.99) + assert updated.price == 899.99 + + # DELETE + await products.delete(p.id) +``` + +### Example 2: Pagination + +```python +async def get_all_users(client: BaseAPIClient) -> list[dict]: + """Fetch all pages of users.""" + all_users = [] + page = 1 + while True: + response = await client.get('/users', params={'page': page, 'per_page': 50}) + data = response.data + if not data: + break + all_users.extend(data) + if len(data) < 50: + break + page += 1 + return all_users + +async def test_all_users_are_active(api_client): + users = await get_all_users(api_client) + assert all(u['status'] == 'active' for u in users) +``` + +### Example 3: Performance Gate + +```python +async def test_api_response_time(api_client): + response = await api_client.get('/health') + assert response.elapsed_ms < 500, ( + f"Health check took {response.elapsed_ms}ms — SLA is 500ms" + ) +``` + +--- + +See [core/api/README.md](../core/api/README.md) for the full framework reference. diff --git a/docs/DATABASE_TESTING.md b/docs/DATABASE_TESTING.md new file mode 100644 index 0000000..36c82ae --- /dev/null +++ b/docs/DATABASE_TESTING.md @@ -0,0 +1,340 @@ +# Database Testing Guide + +Complete guide for database and cache testing using the Playwright Python Async Framework. + +--- + +## Table of Contents + +- [Overview](#overview) +- [Supported Databases](#supported-databases) +- [Quick Start](#quick-start) +- [DatabaseClient API](#databaseclient-api) +- [Redis Testing](#redis-testing) +- [Docker Setup](#docker-setup) +- [Configuration Reference](#configuration-reference) +- [Complete Examples](#complete-examples) + +--- + +## Overview + +The framework ships with two helper clients: + +| Client | Module | Purpose | +|--------|--------|---------| +| `DatabaseClient` | `helpers/database.py` | PostgreSQL, MySQL, SQL Server, Oracle | +| `RedisClient` | `helpers/redis_client.py` | Redis cache operations | + +Both clients are **async-first** and integrate with pytest via fixtures defined +in `conftest.py`. + +--- + +## Supported Databases + +The `DatabaseClient` uses **SQLAlchemy async** drivers: + +| Database | `DB_TYPE` value | Driver package | +|----------|----------------|----------------| +| PostgreSQL | `postgresql` | `asyncpg` (included) | +| MySQL | `mysql` | `aiomysql` (included) | +| SQL Server | `mssql` | `aioodbc` (install separately) | +| Oracle | `oracle` | `cx_oracle_async` (install separately) | + +--- + +## Quick Start + +### 1. Enable Database Testing + +Set environment variables in `.env`: + +```bash +DB_TEST=true +DB_TYPE=postgresql +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=testdb +DB_USER=postgres +DB_PASSWORD=password +``` + +### 2. Use the `db_client` Fixture + +The `db_client` fixture is defined in `conftest.py`. Tests that use it will +**skip automatically** when `DB_TEST` is not `true`: + +```python +async def test_user_exists(db_client): + user = await db_client.fetch_one( + "SELECT * FROM users WHERE email = :email", + {"email": "qa@example.com"}, + ) + assert user is not None + assert user["status"] == "active" +``` + +### 3. Start Infrastructure (Optional) + +Use Docker Compose for a ready-to-go database: + +```bash +docker-compose --profile with-db up --abort-on-container-exit +``` + +--- + +## DatabaseClient API + +### `fetch_one(query, params)` → `dict | None` + +Fetch a single row as a dictionary. Returns `None` if no rows match. + +```python +user = await db_client.fetch_one( + "SELECT id, name, email, status FROM users WHERE id = :id", + {"id": 42}, +) +assert user is not None +assert user["status"] == "active" +``` + +### `fetch_all(query, params)` → `list[dict]` + +Fetch all matching rows as a list of dictionaries. + +```python +active_users = await db_client.fetch_all( + "SELECT id, name FROM users WHERE status = :status", + {"status": "active"}, +) +assert len(active_users) > 0 +``` + +### `execute(query, params)` → result + +Execute an INSERT, UPDATE, or DELETE statement. Commits automatically. + +```python +await db_client.execute( + "UPDATE users SET status = :status WHERE id = :id", + {"status": "inactive", "id": 42}, +) +``` + +### `session_maker()` — SQLAlchemy session + +For advanced ORM queries use the async session context manager directly: + +```python +from sqlalchemy import text, select +from sqlalchemy.orm import DeclarativeBase, mapped_column, Mapped + +class User(DeclarativeBase): + __tablename__ = "users" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + email: Mapped[str] + status: Mapped[str] + +async def test_orm_query(db_client): + async with db_client.session_maker() as session: + result = await session.execute( + select(User).where(User.status == "active").limit(10) + ) + users = result.scalars().all() + assert len(users) > 0 +``` + +--- + +## Redis Testing + +### Configuration + +```bash +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_DB=0 +``` + +### Using `RedisClient` in Tests + +```python +from helpers.redis_client import RedisClient +from utils.config import Config + +@pytest_asyncio.fixture +async def redis(): + cfg = Config.get_redis_config() + async with RedisClient(port=str(cfg["port"]), db=int(cfg["db"])) as client: + yield client +``` + +### RedisClient API + +```python +# Basic operations +await client.set("key", "value") +raw = await client.get("key") # Returns bytes or None +value = raw.decode() if raw else None + +await client.delete("key") # Returns count deleted +exists = await client.exists("key") # Returns 1 or 0 + +# List keys +keys = await client.list_keys("prefix:*") + +# Bulk get +values = await client.mget(["key1", "key2", "key3"]) + +# Context manager (auto-close) +async with RedisClient() as client: + await client.set("temp", "value") +``` + +--- + +## Docker Setup + +Start all services with Docker Compose: + +```bash +# Start everything (tests + PostgreSQL + Redis) +docker-compose --profile with-db --profile with-redis up --abort-on-container-exit + +# PostgreSQL only +docker-compose --profile with-db up --abort-on-container-exit + +# Redis only +docker-compose --profile with-redis up --abort-on-container-exit + +# Tests without external services +docker-compose up --abort-on-container-exit + +# Cleanup +docker-compose down -v +``` + +--- + +## Configuration Reference + +| Variable | Default | Description | +|----------|---------|-------------| +| `DB_TEST` | `false` | Set `true` to enable database tests | +| `DB_TYPE` | `postgresql` | `postgresql` \| `mysql` \| `mssql` \| `oracle` | +| `DB_HOST` | `localhost` | Database host | +| `DB_PORT` | `5432` | Database port | +| `DB_NAME` | `testdb` | Database name | +| `DB_USER` | `postgres` | Database username | +| `DB_PASSWORD` | `password` | Database password | +| `REDIS_HOST` | `localhost` | Redis host | +| `REDIS_PORT` | `6379` | Redis port | +| `REDIS_DB` | `0` | Redis database number (0–15) | + +--- + +## Complete Examples + +### Example 1: Data Integrity Validation + +```python +async def test_order_creates_inventory_record(db_client): + """After creating an order via UI/API, verify database state.""" + order_id = "ORD-2024-001" + + # Verify order exists + order = await db_client.fetch_one( + "SELECT * FROM orders WHERE order_ref = :ref", + {"ref": order_id}, + ) + assert order is not None + assert order["status"] == "pending" + + # Verify inventory was decremented + inv = await db_client.fetch_one( + "SELECT stock_count FROM inventory WHERE product_id = :pid", + {"pid": order["product_id"]}, + ) + assert inv["stock_count"] >= 0 +``` + +### Example 2: Test Data Setup and Teardown + +```python +import pytest + +@pytest.fixture +async def test_user(db_client): + """Create a user for the test and remove it afterwards.""" + await db_client.execute( + "INSERT INTO users (name, email, status) VALUES (:name, :email, :status)", + {"name": "Test User", "email": "test_fixture@example.com", "status": "active"}, + ) + user = await db_client.fetch_one( + "SELECT * FROM users WHERE email = :email", + {"email": "test_fixture@example.com"}, + ) + yield user + await db_client.execute( + "DELETE FROM users WHERE id = :id", + {"id": user["id"]}, + ) + +async def test_user_profile(db_client, test_user): + """Test using the pre-created test user.""" + fetched = await db_client.fetch_one( + "SELECT * FROM users WHERE id = :id", + {"id": test_user["id"]}, + ) + assert fetched["name"] == "Test User" +``` + +### Example 3: Redis Cache Verification + +```python +async def test_session_stored_in_redis(redis_client, page, login): + """After login, verify that the session token is cached in Redis.""" + from utils.config import Config + + await login(Config.get_test_username(), Config.get_test_password(), Config.get_base_url()) + + # Extract token from browser storage + token = await page.evaluate("localStorage.getItem('authToken')") + assert token + + # Verify Redis has the session + session_data = await redis_client.get(f"session:{token}") + assert session_data is not None + assert json.loads(session_data.decode())["user_id"] > 0 +``` + +### Example 4: Cross-Layer Validation (UI + DB) + +```python +async def test_registration_persists_to_database(page, db_client): + """Register via UI, then confirm the user exists in the database.""" + from tests.test_ui_examples import RegistrationPage + from utils.test_helpers import TestDataGenerator + + email = TestDataGenerator.random_email() + + reg = RegistrationPage(page) + await page.goto('https://app.example.com/register') + await reg.register(email=email) + await reg.validation.assert_visible('#successBanner') + + # Cross-check with database + user = await db_client.fetch_one( + "SELECT id, email, status FROM users WHERE email = :email", + {"email": email}, + ) + assert user is not None + assert user["status"] == "active" +``` + +--- + +See the `tests/test_database_examples.py` file for runnable examples. diff --git a/docs/UI_TESTING.md b/docs/UI_TESTING.md new file mode 100644 index 0000000..64ce032 --- /dev/null +++ b/docs/UI_TESTING.md @@ -0,0 +1,482 @@ +# UI Testing Guide + +Complete guide for UI automation using the Playwright Python Async Framework. + +> **Quick reference?** See [core/ui/README.md](../core/ui/README.md) +> **AI-powered healing?** See [core/ui/ai/README.md](../core/ui/ai/README.md) + +--- + +## Table of Contents + +- [Overview](#overview) +- [Page Object Model](#page-object-model) +- [Form Interactions](#form-interactions) +- [Validation](#validation) +- [Browser Services](#browser-services) +- [Components Reference](#components-reference) +- [Test Markers](#test-markers) +- [Configuration](#configuration) +- [Complete Examples](#complete-examples) + +--- + +## Overview + +The UI testing layer is built on top of **Playwright** and follows the **Page Object Model (POM)** pattern. Every page object inherits from `BasePage`, which provides lazy-loaded services and convenience shortcuts. + +``` +BasePage +├── fill_data() ← Auto-detecting form filler +├── edit_item() ← Clear + refill +├── validate_edit_view() ← Assert form field values +├── validate_details_view() ← Assert read-only view +│ +├── Services (accessed via self.) +│ ├── attribute ← DOM attribute manipulation +│ ├── element_resolver ← Element resolution & metadata +│ ├── strategy_factory ← Form-filling strategies +│ ├── screenshot ← Evidence capture +│ ├── storage ← LocalStorage / SessionStorage / Cookies +│ ├── tab_window ← Multi-tab management +│ ├── validation ← Element assertions +│ └── wait ← Page-load waiting +``` + +--- + +## Page Object Model + +### Creating a Page Object + +Every page object must inherit from `BasePage` and call `super().__init__(page)`: + +```python +from playwright.async_api import Page +from core.ui.base_page import BasePage + +class ProductPage(BasePage): + def __init__(self, page: Page): + super().__init__(page) # Required! + + # Define selectors as instance variables + self.search_input = '#search' + self.search_button = '#searchBtn' + self.results_list = '.results-item' + self.add_to_cart = '[data-testid="add-cart"]' + + async def search(self, query: str) -> None: + await self.fill_data({self.search_input: query}) + await self.page.click(self.search_button) + await self.wait.wait_for_page_load() + + async def result_count(self) -> int: + return await self.page.locator(self.results_list).count() +``` + +### Using in Tests + +```python +async def test_product_search(page): + await page.goto('https://shop.example.com/products') + product_page = ProductPage(page) + + await product_page.search('laptop') + + count = await product_page.result_count() + assert count > 0 +``` + +--- + +## Form Interactions + +### `fill_data()` — Create / Fill + +Automatically detects the field type and uses the correct interaction strategy: + +```python +await self.fill_data({ + '#name': 'John Doe', # Text input + '#email': 'john@example.com', # Email input + '#age': '30', # Number input + '#department': 'Engineering', # dropdown interaction. + + Demonstrates: + - Auto-detected select strategy via fill_data() + - Validating the selected value + """ + await page.goto("https://the-internet.herokuapp.com/dropdown") + dropdown_page = HerokuDropdownPage(page) + + await dropdown_page.fill_data({dropdown_page.dropdown: "Option 1"}) + + selected = await page.locator(f"{dropdown_page.dropdown} option:checked").inner_text() + assert selected == "Option 1" + + +@pytest.mark.regression +async def test_page_screenshot_on_demand(page: Page) -> None: + """ + Regression test: manual screenshot capture. + + Demonstrates: + - screenshot service usage + - Evidence capture for reporting + """ + await page.goto("https://the-internet.herokuapp.com/") + base = BasePage(page) + + # Take a named screenshot (saved to screenshots/ directory) + await base.screenshot.take_screenshot("homepage_evidence") + + +@pytest.mark.regression +async def test_storage_manipulation(page: Page) -> None: + """ + Regression test: localStorage read/write via Storage service. + + Demonstrates: + - Storage service for setting and reading localStorage + """ + await page.goto("https://the-internet.herokuapp.com/") + base = BasePage(page) + + await base.storage.set_local_storage("test_key", "hello_framework") + value = await base.storage.get_local_storage("test_key") + assert value == "hello_framework" + + +@pytest.mark.regression +async def test_dynamic_content_wait(page: Page) -> None: + """ + Regression test: waiting for dynamic page content. + + Demonstrates: + - wait service for page-load events + - locator count verification + """ + await page.goto("https://the-internet.herokuapp.com/dynamic_content") + content_page = HerokuDynamicContentPage(page) + + await content_page.wait.wait_for_page_load() + rows = page.locator(content_page.content_rows) + count = await rows.count() + assert count > 0, "Expected at least one dynamic content row" + + +@pytest.mark.regression +async def test_tab_window_management(page: Page) -> None: + """ + Regression test: multi-tab management using TabWindow service. + + Demonstrates: + - Opening a link that triggers a new tab + - Closing tabs + """ + await page.goto("https://the-internet.herokuapp.com/windows") + base = BasePage(page) + + # Click the link that opens a new window/tab + new_page = await base.tab_window.wait_for_new_tab_and_switch( + lambda: page.click("a[href='/windows/new']") + ) + + assert new_page is not None + await new_page.wait_for_load_state() + assert "New Window" in await new_page.title() + await new_page.close() + + +@pytest.mark.integration +async def test_full_crud_workflow_example(page: Page) -> None: + """ + Integration test: login → interact → logout workflow. + + Demonstrates a complete end-to-end flow using multiple services. + """ + login_page = HerokuLoginPage(page) + await page.goto("https://the-internet.herokuapp.com/login") + await login_page.login("tomsmith", "SuperSecretPassword!") + await login_page.validation.assert_visible(".flash.success") + + # Interact with the secure page + await login_page.validation.assert_visible("h2") + heading = await page.locator("h2").inner_text() + assert "Secure Area" in heading + + # Logout + await page.click("a[href='/logout']") + await login_page.wait.wait_for_page_load() + assert "/login" in page.url + + +@pytest.mark.unit +def test_data_generator_produces_valid_data() -> None: + """ + Unit test: TestDataGenerator generates correctly-typed values. + + Demonstrates the test data utilities available in utils/test_helpers.py. + These run without a browser. + """ + name = TestDataGenerator.random_name() + assert isinstance(name, str) + assert len(name) > 0 + + email = TestDataGenerator.random_email() + assert "@" in email + + phone = TestDataGenerator.random_phone("us") + assert "(" in phone and ")" in phone + + future_date = TestDataGenerator.random_future_date() + assert len(future_date) == 10 # YYYY-MM-DD + + price = TestDataGenerator.random_price() + assert float(price) > 0 diff --git a/utils/config.py b/utils/config.py index 27384e8..2ad681d 100644 --- a/utils/config.py +++ b/utils/config.py @@ -343,22 +343,6 @@ def get_api_password() -> str: return os.getenv("API_PASSWORD", Config.get_test_password()) # ========== Basic Auth Settings ========== - @staticmethod - def get_api_username() -> str: - """Get API username for login flows or basic auth.""" - return os.getenv("API_USERNAME", Config.get_test_username()) - - @staticmethod - def get_api_password() -> str: - """Get API password for login flows or basic auth.""" - return os.getenv("API_PASSWORD", Config.get_test_password()) - - # ========== Bearer Token Settings ========== - @staticmethod - def get_api_bearer_token() -> str: - """Get API bearer token for authentication.""" - return os.getenv("API_BEARER_TOKEN", "") - # ========== OAuth2 Settings ========== @staticmethod def get_oauth_client_id() -> str: @@ -381,11 +365,6 @@ def get_oauth_scope() -> str: return os.getenv("OAUTH_SCOPE", "") # ========= API Key Settings ========== - @staticmethod - def get_api_key() -> str: - """Get API key for authentication.""" - return os.getenv("API_KEY", "") - @staticmethod def get_api_key_header_name() -> str: """Get API key header name."""