Compare commits

...

4 Commits

Author SHA1 Message Date
a2f73017eb refactor: SmartLocator with __getattr__ proxying, integrate into BasePage
Ultraworked with Sisyphus(https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-15 20:26:43 +03:00
e62b43b46c feat: расширение API клиента и добавление auth/web модулей
Основные изменения:
- API Client:
  * Переработан api_client.py — улучшена архитектура
  * Добавлены exceptions.py — кастомные исключения для API
  * Добавлены response.py — обертка для API ответов
  * Добавлены transport.py — HTTP транспорт с retry logic

- Auth модуль (новый):
  * Добавлен src/pytfm/auth/__init__.py
  * AuthProvider интерфейс для аутентификации в тестах
  * Поддержка cookie-based auth для E2E тестов

- Web модуль:
  * Добавлен web/locator.py — SmartLocator для Page Object Model
  * Обновлен web/pom.py — улучшена работа с locators
  * Обновлен web/__init__.py — экспорты для POM

- Generators:
  * Расширен generators/__init__.py — PostDataGenerator для блога
  * Добавлены методы для генерации тестовых постов

- Pytest plugin:
  * Добавлен pytest_plugin.py — фикстуры для playwright
  * Интеграция с AuthProvider для автоматической аутентификации

- Конфигурация:
  * Обновлен pyproject.toml — зависимости и entry points
2026-05-07 19:58:12 +03:00
6b9fb649c6 feat(types): add py.typed marker for PEP 561 compliance
Add py.typed file to mark package as typed for mypy compatibility.

This allows mypy to recognize pytfm as a properly typed package.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-02 23:31:12 +03:00
ab6b4b32b1 feature: init test framework 2026-05-02 23:15:40 +03:00
15 changed files with 2810 additions and 8 deletions

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# MkDocs output
site/
# Python cache (ignore all)
**/__pycache__/
*.py[cod]
*$py.class
*.pyc
*.pyo
# IDE
.idea/
.vscode/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
# mypy
.mypy_cache/
# ruff
.ruff_cache/
# Environment
.env
.venv/
venv/
# uv cache
.uv/
blog.db

View File

@@ -1,17 +1,51 @@
[project]
name = "pytfm"
version = "0.1.0"
description = "Add your description here"
description = "Python test framework for UI and API testing"
readme = "README.md"
authors = [
{ name = "Sergey Vanyushkin", email = "pi3c@yandex.ru" }
]
requires-python = ">=3.13"
dependencies = []
dependencies = [
"playwright>=1.42.0",
"httpx>=0.28.0",
"pytest>=9.0.0",
"pytest-playwright>=0.7.0",
"pytest-asyncio>=0.23.0",
"pydantic>=2.0.0",
"mimesis>=19.1.0",
]
[project.scripts]
pytfm = "pytfm:main"
[project.entry-points."pytest11"]
pytfm = "pytfm.pytest_plugin"
[build-system]
requires = ["uv_build>=0.10.7,<0.11.0"]
build-backend = "uv_build"
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/pytfm"]
[dependency-groups]
dev = [
"ruff>=0.15.0",
"mypy>=1.20.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
pythonpath = ["src"]
testpaths = ["tests"]
[tool.mypy]
strict = true
[tool.ruff]
target-version = "py313"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "W", "B", "C4", "SIM"]
ignore = ["E501"]

View File

@@ -1,2 +1,37 @@
def main() -> None:
print("Hello from pytfm!")
"""Python Test Framework (pytfm) - A testing framework for UI and API testing.
This package provides tools for building automated tests including:
- Page Object Model (POM) for web UI testing with Playwright
- HTTP API client for REST API testing
- SmartLocator for enhanced element interactions
- Test fixtures and utilities
Example:
>>> from pytfm.web import BasePage, SmartLocator
>>> from pytfm.api import APIClient
>>>
>>> # Use in your tests
>>> async with APIClient("http://api.example.com") as client:
... response = await client.get("/posts")
>>>
>>> # SmartLocator usage
>>> submit_btn = SmartLocator.by_testid("btn-submit")
>>> await page.click(submit_btn)
"""
from pytfm.api import ApiClient, ApiResponse
from pytfm.generators import PostDataGenerator, TestDataGenerator
from pytfm.web import AsyncBasePage, AsyncSmartLocator, BasePage, LocatorConfig, SmartLocator
__version__ = "0.1.0"
__all__ = [
"ApiClient",
"ApiResponse",
"AsyncBasePage",
"AsyncSmartLocator",
"BasePage",
"LocatorConfig",
"PostDataGenerator",
"SmartLocator",
"TestDataGenerator",
]

43
src/pytfm/api/__init__.py Normal file
View File

@@ -0,0 +1,43 @@
"""API testing module with synchronous HTTP client.
This module provides a synchronous HTTP client for testing REST APIs
with authentication, Pydantic validation, and pluggable transports.
Example:
>>> from pytfm.api import ApiClient, ApiResponse
>>> from pytfm.auth import InMemoryAuthProvider
Anonymous request:
>>> with ApiClient(base_url="http://api.example.com") as client:
... response = client.get("/health")
... response.assert_response(status_code=200)
Authenticated request:
>>> provider = InMemoryAuthProvider()
>>> user = provider.create_user("test", "pass", "test@test.com")
>>> with ApiClient(
... base_url="http://api.example.com",
... user=user,
... auth_provider=provider,
... ) as client:
... response = client.get("/profile")
... response.assert_response(status_code=200)
"""
from pytfm.api.api_client import ApiClient
from pytfm.api.exceptions import ApiStatusError, ApiTestError, ApiValidationError
from pytfm.api.response import ApiResponse, RawResponse
from pytfm.api.transport import HttpxSyncTransport, SyncTransport
__all__ = [
"ApiClient",
"ApiResponse",
"ApiStatusError",
"ApiTestError",
"ApiValidationError",
"HttpxSyncTransport",
"RawResponse",
"SyncTransport",
]

416
src/pytfm/api/api_client.py Normal file
View File

@@ -0,0 +1,416 @@
"""Synchronous API client for HTTP testing.
This module provides :class:`ApiClient` — a synchronous HTTP client
with built-in authentication, request serialization, and Pydantic
response validation.
Example:
Basic usage without authentication:
>>> with ApiClient(base_url="http://api.example.com") as client:
... response = client.get("/health")
... response.assert_response(status_code=200)
With authentication:
>>> user = auth_provider.create_user("test", "pass", "test@test.com")
>>> with ApiClient(
... base_url="http://api.example.com",
... user=user,
... auth_provider=auth_provider,
... ) as client:
... response = client.get("/profile")
... profile = response.assert_response(UserProfile)
With Pydantic models:
>>> class CreateUserRequest(BaseModel):
... name: str
... email: str
>>> class UserResponse(BaseModel):
... id: int
... name: str
>>> with ApiClient(base_url="http://api.example.com") as client:
... req = CreateUserRequest(name="Alice", email="alice@test.com")
... response = client.post("/users", json=req)
... user = response.assert_response(UserResponse, status_code=201)
... print(user.id)
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel
from pytfm.api.response import ApiResponse
from pytfm.api.transport import HttpxSyncTransport, SyncTransport
from pytfm.auth import AuthProvider, TestUser
class ApiClient:
"""Synchronous HTTP client for API testing with auth and validation.
Wraps a :class:`~pytfm.api.transport.SyncTransport` to provide
convenient methods for sending HTTP requests with automatic
authentication, request body serialization, and response wrapping.
The client **must** be used as a context manager to manage the
transport lifecycle.
Attributes:
base_url: Base URL for all API requests.
Example:
Anonymous requests:
>>> with ApiClient(base_url="http://api.example.com") as client:
... response = client.get("/public")
... response.assert_response(status_code=200)
Authenticated requests:
>>> user = TestUser(id="1", username="test", email="t@test.com",
... password="pass")
>>> with ApiClient(
... base_url="http://api.example.com",
... user=user,
... auth_provider=auth_provider,
... ) as client:
... response = client.get("/private")
... response.assert_response(status_code=200)
Custom default headers:
>>> with ApiClient(
... base_url="http://api.example.com",
... headers={"X-Custom": "value"},
... ) as client:
... response = client.get("/items")
"""
def __init__(
self,
base_url: str = "",
*,
user: TestUser | None = None,
auth_provider: AuthProvider | None = None,
transport: SyncTransport | None = None,
headers: dict[str, str] | None = None,
timeout: float = 30.0,
) -> None:
"""Initialize the API client.
Args:
base_url: Base URL for all API requests. Defaults to empty
string, allowing absolute URLs in request methods.
user: Optional test user for automatic authentication.
auth_provider: Optional auth provider for user login.
transport: Optional custom sync transport. Defaults to
:class:`~pytfm.api.transport.HttpxSyncTransport`.
headers: Default headers included in every request.
timeout: Default request timeout in seconds.
Note:
If both ``user`` and ``auth_provider`` are provided, the
client automatically authenticates the user when entering
the context manager via :meth:`login`.
Example:
>>> client = ApiClient(base_url="http://api.example.com")
>>> with client:
... pass
"""
self.base_url = base_url.rstrip("/")
self._user = user
self._auth_provider = auth_provider
self._transport = transport if transport is not None else HttpxSyncTransport()
self._default_headers = headers or {}
self._timeout = timeout
self._token: str | None = None
self._entered = False
def __enter__(self) -> ApiClient:
"""Enter the runtime context and initialize the transport.
If a user and auth provider were provided at initialization,
authentication is performed at this stage.
Returns:
Self for use in context manager.
Raises:
RuntimeError: If the transport fails to initialize.
"""
self._transport.__enter__()
self._entered = True
if self._user is not None and self._auth_provider is not None:
self._authenticate()
return self
def __exit__(self, *args: Any) -> None:
"""Exit the runtime context and close the transport."""
self._entered = False
self._transport.__exit__(*args)
def _authenticate(self) -> None:
"""Authenticate the user and update default headers.
Calls :meth:`AuthProvider.login` with the user's credentials
and merges the resulting auth headers into the default headers.
"""
if self._auth_provider is None or self._user is None:
return
self._token = self._auth_provider.login(self._user.username, self._user.password)
auth_headers = self._auth_provider.get_api_auth_headers(self._token)
self._default_headers.update(auth_headers)
def _ensure_initialized(self) -> None:
"""Raise an error if the client is not used as context manager."""
if not self._entered:
raise RuntimeError("ApiClient not initialized. Use 'with ApiClient(...) as client:'.")
@staticmethod
def _serialize_body(
data: dict[str, Any] | BaseModel | list[Any] | None,
) -> Any:
"""Serialize a request body to a JSON-compatible format.
Pydantic models are dumped via ``model_dump(mode='json')``.
Other types are returned as-is.
Args:
data: Request body data.
Returns:
JSON-serializable data.
Example:
>>> class User(BaseModel):
... name: str
>>> ApiClient._serialize_body(User(name="Alice"))
{'name': 'Alice'}
"""
if isinstance(data, BaseModel):
return data.model_dump(mode="json", by_alias=True)
return data
def _merge_headers(self, request_headers: dict[str, str] | None) -> dict[str, str]:
"""Merge default and request-specific headers.
Request-specific headers override defaults.
Args:
request_headers: Headers for a specific request.
Returns:
Merged headers dictionary.
"""
merged = self._default_headers.copy()
if request_headers:
merged.update(request_headers)
return merged
def _build_url(self, path: str) -> str:
"""Build a full URL from base_url and path.
If ``path`` is an absolute URL (starts with ``http://`` or
``https://``), it is returned unchanged.
Args:
path: URL path or absolute URL.
Returns:
Full request URL.
Example:
>>> client = ApiClient(base_url="http://api.example.com")
>>> client._build_url("/users")
'http://api.example.com/users'
>>> client._build_url("http://other.com/api")
'http://other.com/api'
"""
if path.startswith(("http://", "https://")):
return path
path = path.lstrip("/")
if not self.base_url:
return f"/{path}" if path else ""
return f"{self.base_url}/{path}"
def request(
self,
method: str,
path: str,
*,
json: dict[str, Any] | BaseModel | list[Any] | None = None,
data: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> ApiResponse:
"""Send an HTTP request.
Args:
method: HTTP method (GET, POST, PUT, PATCH, DELETE).
path: URL path relative to ``base_url``, or absolute URL.
json: JSON-serializable body (dict, Pydantic model, or list).
data: Form data.
params: Query parameters.
headers: Additional headers for this request.
timeout: Request timeout in seconds.
Returns:
Wrapped :class:`~pytfm.api.response.ApiResponse`.
Raises:
RuntimeError: If client is not used as context manager.
Example:
>>> with ApiClient(base_url="http://api.example.com") as client:
... response = client.request(
... "GET", "/users", params={"page": "1"}
... )
"""
self._ensure_initialized()
raw = self._transport.send(
method=method,
url=self._build_url(path),
headers=self._merge_headers(headers),
json=self._serialize_body(json),
data=data,
params=params,
timeout=timeout if timeout is not None else self._timeout,
)
return ApiResponse(raw)
def get(
self,
path: str,
*,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> ApiResponse:
"""Send a GET request.
Args:
path: URL path.
params: Query parameters.
headers: Additional headers.
timeout: Request timeout in seconds.
Returns:
Wrapped ApiResponse.
Example:
>>> with ApiClient(base_url="http://api.example.com") as client:
... response = client.get("/users", params={"page": "1"})
... users = response.assert_response(UserList)
"""
return self.request("GET", path, params=params, headers=headers, timeout=timeout)
def post(
self,
path: str,
*,
json: dict[str, Any] | BaseModel | list[Any] | None = None,
data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> ApiResponse:
"""Send a POST request.
Args:
path: URL path.
json: JSON body (dict, Pydantic model, or list).
data: Form data.
headers: Additional headers.
timeout: Request timeout in seconds.
Returns:
Wrapped ApiResponse.
Example:
>>> payload = CreateUserRequest(name="Alice")
>>> with ApiClient(base_url="http://api.example.com") as client:
... response = client.post("/users", json=payload)
... user = response.assert_response(UserResponse, status_code=201)
"""
return self.request("POST", path, json=json, data=data, headers=headers, timeout=timeout)
def put(
self,
path: str,
*,
json: dict[str, Any] | BaseModel | list[Any] | None = None,
data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> ApiResponse:
"""Send a PUT request.
Args:
path: URL path.
json: JSON body.
data: Form data.
headers: Additional headers.
timeout: Request timeout in seconds.
Returns:
Wrapped ApiResponse.
"""
return self.request("PUT", path, json=json, data=data, headers=headers, timeout=timeout)
def patch(
self,
path: str,
*,
json: dict[str, Any] | BaseModel | list[Any] | None = None,
data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> ApiResponse:
"""Send a PATCH request.
Args:
path: URL path.
json: JSON body.
data: Form data.
headers: Additional headers.
timeout: Request timeout in seconds.
Returns:
Wrapped ApiResponse.
"""
return self.request(
"PATCH",
path,
json=json,
data=data,
headers=headers,
timeout=timeout,
)
def delete(
self,
path: str,
*,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> ApiResponse:
"""Send a DELETE request.
Args:
path: URL path.
headers: Additional headers.
timeout: Request timeout in seconds.
Returns:
Wrapped ApiResponse.
Example:
>>> with ApiClient(base_url="http://api.example.com") as client:
... response = client.delete("/users/123")
... response.assert_response(status_code=204)
"""
return self.request("DELETE", path, headers=headers, timeout=timeout)

122
src/pytfm/api/exceptions.py Normal file
View File

@@ -0,0 +1,122 @@
"""Custom exceptions for API testing.
This module provides a hierarchy of exceptions used by the API client
for detailed error reporting during test execution.
Example:
>>> try:
... response.assert_response(UserProfile, status_code=200)
... except ApiStatusError as exc:
... print(f"Unexpected status: {exc.actual}")
... except ApiValidationError as exc:
... print(f"Validation failed: {exc.validation_error}")
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from pydantic import BaseModel, ValidationError
class ApiTestError(Exception):
"""Base exception for all API testing failures.
Attributes:
message: Human-readable error description.
response: The ApiResponse that caused the error, if available.
Example:
>>> raise ApiTestError("Connection failed", response=response)
"""
def __init__(
self,
message: str,
*,
response: Any | None = None,
) -> None:
"""Initialize the exception.
Args:
message: Error description.
response: Associated ApiResponse instance.
"""
super().__init__(message)
self.response = response
class ApiStatusError(ApiTestError):
"""Raised when the HTTP status code does not match the expected value.
Attributes:
expected: Expected status code or collection of acceptable codes.
actual: Actual status code received from the server.
Example:
>>> raise ApiStatusError(
... expected=201,
... actual=400,
... response=response,
... )
"""
def __init__(
self,
expected: int | Iterable[int],
actual: int,
*,
response: Any,
) -> None:
"""Initialize the status error.
Args:
expected: Expected status code or container of acceptable codes.
actual: Actual status code from the response.
response: The ApiResponse that triggered the error.
"""
if isinstance(expected, int):
message = f"Expected status code {expected}, got {actual}"
else:
message = f"Expected status code in {tuple(expected)}, got {actual}"
super().__init__(message, response=response)
self.expected = expected
self.actual = actual
class ApiValidationError(ApiTestError):
"""Raised when response body fails Pydantic model validation.
Attributes:
model: The Pydantic model class used for validation.
validation_error: The original Pydantic ValidationError, if available.
Example:
>>> try:
... response.assert_response(UserProfile)
... except ApiValidationError as exc:
... print(exc.validation_error.errors())
"""
def __init__(
self,
model: type[BaseModel],
validation_error: ValidationError | None,
*,
response: Any,
) -> None:
"""Initialize the validation error.
Args:
model: Pydantic model class that validation failed against.
validation_error: Original Pydantic ValidationError or None if
the response body was empty or not JSON.
response: The ApiResponse that triggered the error.
"""
message = f"Response body validation failed for {model.__name__}"
if validation_error is not None:
message += f": {validation_error}"
super().__init__(message, response=response)
self.model = model
self.validation_error = validation_error

183
src/pytfm/api/response.py Normal file
View File

@@ -0,0 +1,183 @@
"""API response models and validation.
This module provides normalized response representation and validation
utilities for API testing.
Example:
>>> raw = RawResponse(
... status_code=200,
... headers={"content-type": "application/json"},
... text='{"id": 1, "name": "Alice"}',
... json_data={"id": 1, "name": "Alice"},
... )
>>> response = ApiResponse(raw)
>>> user = response.assert_response(UserProfile, status_code=200)
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any, TypeVar, overload
from pydantic import BaseModel, ValidationError
from pytfm.api.exceptions import ApiStatusError, ApiValidationError
T = TypeVar("T", bound=BaseModel)
@dataclass
class RawResponse:
"""Normalized raw HTTP response independent of the transport backend.
Attributes:
status_code: HTTP status code.
headers: Response headers dictionary.
text: Response body as text.
json_data: Parsed JSON body, or None if not JSON.
raw_bytes: Raw response body bytes, or None.
Example:
>>> raw = RawResponse(
... status_code=200,
... headers={},
... text="OK",
... json_data=None,
... )
"""
status_code: int
headers: dict[str, str]
text: str
json_data: dict[str, Any] | list[Any] | None = None
raw_bytes: bytes | None = None
class ApiResponse:
"""Wrapper for API responses with convenient accessors and validation.
Provides easy access to response data and methods for asserting
status codes and validating response bodies against Pydantic models.
Attributes:
status_code: HTTP status code of the response.
headers: Response headers dictionary.
text: Raw response body as string.
Example:
>>> response = client.get("/users/1")
>>> user = response.assert_response(UserProfile, status_code=200)
>>> print(user.name)
"""
def __init__(self, raw: RawResponse) -> None:
"""Initialize the response wrapper.
Args:
raw: Normalized RawResponse from the transport layer.
"""
self._raw = raw
self.status_code = raw.status_code
self.headers = raw.headers
self.text = raw.text
def json(self) -> dict[str, Any] | list[Any] | None:
"""Get parsed JSON response data.
Returns:
Parsed JSON data or None if the response body is not valid JSON.
Example:
>>> data = response.json()
>>> if data:
... print(data["id"])
"""
return self._raw.json_data
@overload
def assert_response(
self,
model: None = None,
status_code: int | Iterable[int] = 200,
) -> None: ...
@overload
def assert_response(
self,
model: type[T],
status_code: int | Iterable[int] = 200,
) -> T: ...
def assert_response(
self,
model: type[T] | None = None,
status_code: int | Iterable[int] = 200,
) -> T | None:
"""Assert status code and optionally validate response body.
Checks that the HTTP status code matches the expected value and,
if a Pydantic model is provided, parses and validates the response
body against that model.
Args:
model: Pydantic model class to validate the response body.
If None, only the status code is checked.
status_code: Expected status code or container of acceptable codes.
Defaults to 200.
Returns:
Validated Pydantic model instance if ``model`` is provided,
otherwise None.
Raises:
ApiStatusError: If the status code does not match.
ApiValidationError: If the response body fails model validation.
Example:
Status code only:
>>> response.assert_response(status_code=204)
With Pydantic model:
>>> user = response.assert_response(UserProfile, status_code=200)
>>> print(user.id)
Acceptable range of status codes:
>>> response.assert_response(status_code={200, 201})
"""
if isinstance(status_code, int):
if self.status_code != status_code:
raise ApiStatusError(
expected=status_code,
actual=self.status_code,
response=self,
)
elif self.status_code not in status_code:
raise ApiStatusError(
expected=status_code,
actual=self.status_code,
response=self,
)
if model is None:
return None
json_data = self.json()
if json_data is None:
raise ApiValidationError(
model=model,
validation_error=None,
response=self,
)
try:
return model.model_validate(json_data)
except ValidationError as exc:
raise ApiValidationError(
model=model,
validation_error=exc,
response=self,
) from exc

177
src/pytfm/api/transport.py Normal file
View File

@@ -0,0 +1,177 @@
"""Synchronous HTTP transports for API testing.
This module defines the transport protocol and provides concrete
implementations for different HTTP backends.
Example:
>>> with HttpxSyncTransport() as transport:
... raw = transport.send("GET", "http://api.example.com/health")
... print(raw.status_code)
"""
from __future__ import annotations
import contextlib
from typing import Any, Protocol
import httpx
from pytfm.api.response import RawResponse
class SyncTransport(Protocol):
"""Protocol for synchronous HTTP transports.
All sync transports must implement context manager protocol
for proper resource lifecycle management.
Example:
>>> class MyTransport:
... def __enter__(self):
... return self
... def __exit__(self, *args):
... pass
... def send(self, method, url, **kwargs):
... return RawResponse(status_code=200, headers={}, text="")
"""
def __enter__(self) -> SyncTransport:
"""Enter the runtime context."""
...
def __exit__(self, *args: Any) -> None:
"""Exit the runtime context."""
...
def send(
self,
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
json: Any | None = None,
data: Any | None = None,
params: dict[str, Any] | None = None,
timeout: float | None = None,
) -> RawResponse:
"""Send an HTTP request and return a normalized response.
Args:
method: HTTP method (GET, POST, PUT, PATCH, DELETE).
url: Full request URL.
headers: Request headers.
json: JSON-serializable request body.
data: Form data.
params: Query parameters.
timeout: Request timeout in seconds.
Returns:
Normalized RawResponse.
"""
...
class HttpxSyncTransport:
"""Synchronous HTTP transport using ``httpx.Client``.
Provides a normalized interface over ``httpx.Client`` for use
with :class:`~pytfm.api.api_client.ApiClient`.
Attributes:
_client: Underlying ``httpx.Client`` instance.
Example:
>>> with HttpxSyncTransport() as transport:
... raw = transport.send(
... "POST",
... "http://api.example.com/users",
... json={"name": "Alice"},
... )
... print(raw.status_code)
With an existing client:
>>> client = httpx.Client(timeout=60.0)
>>> transport = HttpxSyncTransport(client=client)
>>> with transport:
... raw = transport.send("GET", "http://api.example.com/health")
"""
def __init__(self, client: httpx.Client | None = None) -> None:
"""Initialize the transport.
Args:
client: Optional existing ``httpx.Client`` instance.
If not provided, a new client is created on context enter.
"""
self._client = client
self._owns_client = client is None
def __enter__(self) -> HttpxSyncTransport:
"""Enter the runtime context and initialize the client.
Returns:
Self for use in context manager.
"""
if self._client is None:
self._client = httpx.Client()
return self
def __exit__(self, *args: Any) -> None:
"""Exit the runtime context and close the client if owned."""
if self._owns_client and self._client is not None:
self._client.close()
self._client = None
def send(
self,
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
json: Any | None = None,
data: Any | None = None,
params: dict[str, Any] | None = None,
timeout: float | None = None,
) -> RawResponse:
"""Send an HTTP request via ``httpx.Client``.
Args:
method: HTTP method.
url: Full request URL.
headers: Request headers.
json: JSON body.
data: Form data.
params: Query parameters.
timeout: Request timeout in seconds.
Returns:
Normalized RawResponse.
Raises:
RuntimeError: If transport is not used as context manager.
"""
if self._client is None:
raise RuntimeError(
"HttpxSyncTransport not initialized. Use 'with HttpxSyncTransport() as transport:'."
)
response = self._client.request(
method=method,
url=url,
headers=headers,
json=json,
data=data,
params=params,
timeout=timeout,
)
json_data = None
content_type = response.headers.get("content-type", "")
if "application/json" in content_type or "text/json" in content_type:
with contextlib.suppress(ValueError):
json_data = response.json()
return RawResponse(
status_code=response.status_code,
headers=dict(response.headers),
text=response.text,
json_data=json_data,
)

225
src/pytfm/auth/__init__.py Normal file
View File

@@ -0,0 +1,225 @@
"""Authentication abstractions for UI testing.
This module provides the AuthProvider interface and concrete implementations
for managing test user authentication in browser-based tests.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
@dataclass
class TestUser:
"""Represents a test user for authentication scenarios.
Attributes:
id: Unique user identifier.
username: User login name.
email: User email address.
password: User password (plaintext for testing only).
roles: List of user roles.
meta: Additional provider-specific metadata.
"""
id: str
username: str
email: str
password: str
roles: list[str] = field(default_factory=list)
meta: dict[str, Any] = field(default_factory=dict)
class AuthProvider(ABC):
"""Abstract base class for authentication providers in tests.
Implementations handle user creation, login, and cookie generation
for browser-based authentication scenarios.
Example:
>>> class FakeKeycloakProvider(AuthProvider):
... def create_user(self, username, password, email, roles):
... return TestUser(id="1", username=username, email=email,
... password=password, roles=roles)
... def login(self, username, password):
... return "fake_token"
... def build_auth_cookie(self, token, domain):
... return {"name": "access_token", "value": token,
... "domain": domain, "path": "/", "httpOnly": True}
"""
@abstractmethod
def create_user(
self,
username: str,
password: str,
email: str,
roles: list[str] | None = None,
) -> TestUser:
"""Create a new test user.
Args:
username: Login name for the user.
password: Password for the user.
email: Email address.
roles: Optional list of roles.
Returns:
Created TestUser instance.
"""
@abstractmethod
def login(self, username: str, password: str) -> str:
"""Authenticate user and return token.
Args:
username: User login name.
password: User password.
Returns:
Authentication token string.
"""
@abstractmethod
def build_auth_cookie(self, token: str, domain: str) -> dict[str, Any]:
"""Build cookie dict for browser authentication.
Args:
token: Authentication token from login().
domain: Cookie domain (e.g. '127.0.0.1').
Returns:
Cookie dict compatible with Playwright's add_cookies().
"""
def get_api_auth_headers(self, token: str) -> dict[str, str]:
"""Return HTTP headers for API authentication.
Default implementation returns a Bearer token ``Authorization``
header. Override this method for custom authentication schemes
such as API-Key or X-Auth-Token.
Args:
token: Authentication token returned by :meth:`login`.
Returns:
Dictionary of HTTP headers to include in API requests.
Example:
>>> provider = InMemoryAuthProvider()
>>> headers = provider.get_api_auth_headers("my_token")
>>> headers
{'Authorization': 'Bearer my_token'}
Custom scheme:
>>> class ApiKeyProvider(AuthProvider):
... def get_api_auth_headers(self, token):
... return {"X-API-Key": token}
"""
return {"Authorization": f"Bearer {token}"}
class InMemoryAuthProvider(AuthProvider):
"""Simple in-memory authentication provider for testing.
Stores users and tokens in memory. Suitable for local test servers
where external auth services are not available.
Attributes:
_users: Dictionary of users by username.
_tokens: Dictionary of active tokens.
token_ttl: Token lifetime in seconds.
"""
def __init__(self, token_ttl: int = 3600) -> None:
"""Initialize the in-memory auth provider.
Args:
token_ttl: Token lifetime in seconds (default: 1 hour).
"""
self._users: dict[str, TestUser] = {}
self._tokens: dict[str, tuple[str, float]] = {}
self._token_ttl = token_ttl
self._counter = 0
def create_user(
self,
username: str,
password: str,
email: str,
roles: list[str] | None = None,
) -> TestUser:
"""Create a new in-memory test user.
Args:
username: Login name.
password: Password.
email: Email address.
roles: Optional list of roles.
Returns:
Created TestUser.
"""
self._counter += 1
user = TestUser(
id=str(self._counter),
username=username,
email=email,
password=password,
roles=roles or ["user"],
)
self._users[username] = user
return user
def login(self, username: str, password: str) -> str:
"""Authenticate user and return a simple token.
Args:
username: User login name.
password: User password.
Returns:
Simple token string.
Raises:
ValueError: If credentials are invalid.
"""
import secrets
import time
user = self._users.get(username)
if not user or user.password != password:
raise ValueError("Invalid credentials")
token = secrets.token_urlsafe(32)
self._tokens[token] = (user.id, time.time())
return token
def build_auth_cookie(self, token: str, domain: str) -> dict[str, Any]:
"""Build a standard auth cookie.
Args:
token: Authentication token.
domain: Cookie domain.
Returns:
Cookie dict for Playwright.
"""
return {
"name": "access_token",
"value": token,
"domain": domain,
"path": "/",
"httpOnly": True,
"secure": False,
"sameSite": "Lax",
}
def clear(self) -> None:
"""Clear all users and tokens."""
self._users.clear()
self._tokens.clear()
self._counter = 0

View File

@@ -0,0 +1,198 @@
"""Test data generators module.
This module provides utilities for generating test data
using the mimesis library.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from mimesis import Field, Locale
from mimesis.enums import Gender
if TYPE_CHECKING:
pass
class TestDataGenerator:
"""Generator for test data.
Provides methods to generate realistic test data for various
test scenarios using mimesis.
Attributes:
_field: Mimesis Field instance for data generation.
Example:
>>> generator = TestDataGenerator()
>>> email = generator.email()
>>> name = generator.full_name()
"""
def __init__(self, locale: Locale = Locale.EN) -> None:
"""Initialize the test data generator.
Args:
locale: Mimesis locale for data generation.
"""
self._field = Field(locale)
def email(self) -> str:
"""Generate a random email address.
Returns:
Random email address.
"""
return self._field("email")
def full_name(self, gender: Gender | None = None) -> str:
"""Generate a random full name.
Args:
gender: Optional gender for the name.
Returns:
Random full name.
"""
if gender:
return self._field("full_name", gender=gender)
return self._field("full_name")
def username(self) -> str:
"""Generate a random username.
Returns:
Random username.
"""
return self._field("username")
def password(self, length: int = 12) -> str:
"""Generate a random password.
Args:
length: Password length.
Returns:
Random password string.
"""
return self._field("password", length=length)
class PostDataGenerator:
"""Generator for blog post test data.
Provides methods to generate realistic blog post content
including titles, markdown content, and tags.
Attributes:
_field: Mimesis Field instance for data generation.
Example:
>>> generator = PostDataGenerator()
>>> post_data = generator.generate_post()
"""
_TECH_TOPICS: list[str] = [
"python",
"fastapi",
"docker",
"kubernetes",
"sqlalchemy",
"pytest",
"asyncio",
"typescript",
"react",
"postgres",
"redis",
"graphql",
"microservices",
"ci_cd",
"ddd",
]
_TECH_VERBS: list[str] = [
"Building",
"Deploying",
"Testing",
"Optimizing",
"Refactoring",
"Scaling",
"Monitoring",
"Securing",
"Automating",
"Integrating",
]
def __init__(self, locale: Locale = Locale.EN) -> None:
"""Initialize the post data generator.
Args:
locale: Mimesis locale for data generation.
"""
self._field = Field(locale)
def title(self) -> str:
"""Generate a random blog post title.
Returns:
Random post title related to technology.
"""
verb = self._field("choice", items=self._TECH_VERBS)
topic = self._field("choice", items=self._TECH_TOPICS)
suffix = self._field("choice", items=["", " in 2025", " Best Practices", " Guide"])
return f"{verb} {topic.replace('_', ' ').title()}{suffix}"
def content(self, paragraphs: int = 3) -> str:
"""Generate random markdown blog post content.
Args:
paragraphs: Number of paragraphs to generate.
Returns:
Markdown formatted post content.
"""
parts: list[str] = []
for _ in range(paragraphs):
parts.append(self._field("text.text", quantity=2))
return "\n\n".join(parts)
def tags(self, count: int = 3) -> list[str]:
"""Generate random post tags.
Args:
count: Number of tags to generate.
Returns:
List of unique tag strings.
"""
selected: list[str] = []
while len(selected) < min(count, len(self._TECH_TOPICS)):
tag = self._field("choice", items=self._TECH_TOPICS)
if tag not in selected:
selected.append(tag)
return selected
def generate_post(self) -> dict[str, str | list[str]]:
"""Generate a complete blog post data dictionary.
Returns:
Dictionary with title, content, and tags keys.
Example:
>>> generator = PostDataGenerator()
>>> post = generator.generate_post()
>>> post["title"]
'Building FastAPI Guide'
>>> post["content"]
'Lorem ipsum...'
>>> post["tags"]
['python', 'fastapi', 'docker']
"""
return {
"title": self.title(),
"content": self.content(),
"tags": self.tags(),
}

0
src/pytfm/py.typed Normal file
View File

131
src/pytfm/pytest_plugin.py Normal file
View File

@@ -0,0 +1,131 @@
"""Pytest plugin for pytfm test framework.
Provides reusable fixtures for Playwright-based E2E testing:
- Browser lifecycle (via pytest-playwright)
- Test user data generation
- Authenticated browser contexts and pages
Projects must override ``pytfm_auth_provider`` fixture to supply
an :class:`~pytfm.auth.AuthProvider` instance.
Example project conftest::
import pytest
from pytfm.auth import AuthProvider
@pytest.fixture(scope="session")
def pytfm_auth_provider():
return MyKeycloakAuthProvider()
"""
from __future__ import annotations
import uuid
from collections.abc import Generator
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
from playwright.sync_api import Browser, BrowserContext, Page
from pytfm.auth import AuthProvider
# pytest-playwright provides browser, context, page fixtures.
# We extend it with authentication helpers.
pytest_plugins = ["pytest_playwright"]
@pytest.fixture(scope="session")
def browser_type_launch_args() -> dict[str, Any]:
"""Return default browser launch arguments ensuring headless mode."""
return {"headless": True}
@pytest.fixture(scope="session")
def pytfm_auth_provider() -> "AuthProvider":
"""Return an AuthProvider instance for test authentication.
Projects MUST override this fixture in their conftest.py.
Raises:
NotImplementedError: If not overridden by the project.
"""
raise NotImplementedError(
"Override the 'pytfm_auth_provider' fixture in your project's conftest.py "
"and return an AuthProvider instance (e.g. InMemoryAuthProvider)."
)
@pytest.fixture
def test_user_data() -> dict[str, str]:
"""Generate unique test user data.
Returns:
Dictionary with username, email, and password keys.
"""
unique_id = uuid.uuid4().hex[:8]
return {
"username": f"testuser_{unique_id}",
"email": f"test_{unique_id}@example.com",
"password": "TestPass123!",
}
@pytest.fixture
def authenticated_context(
browser: "Browser",
base_url: str,
pytfm_auth_provider: "AuthProvider",
test_user_data: dict[str, str],
) -> Generator["BrowserContext", None, None]:
"""Create an authenticated browser context with a logged-in user.
Creates a user via the auth provider, logs them in, and injects
the resulting auth cookie into a new browser context.
Args:
browser: Playwright Browser instance (from pytest-playwright).
base_url: Application base URL (must be provided by project).
pytfm_auth_provider: Auth provider (must be overridden by project).
test_user_data: Generated test user credentials.
Yields:
Authenticated BrowserContext.
"""
user = pytfm_auth_provider.create_user(
username=test_user_data["username"],
password=test_user_data["password"],
email=test_user_data["email"],
roles=["user"],
)
token = pytfm_auth_provider.login(user.username, user.password)
context = browser.new_context(
viewport={"width": 1280, "height": 720},
)
cookie_domain = base_url.replace("http://", "").replace("https://", "").split(":")[0]
cookie = pytfm_auth_provider.build_auth_cookie(token, cookie_domain)
context.add_cookies([cookie]) # type: ignore[list-item]
yield context
context.close()
@pytest.fixture
def authenticated_page(authenticated_context: "BrowserContext") -> Generator["Page", None, None]:
"""Create an authenticated page from an authenticated context.
Args:
authenticated_context: BrowserContext with active session.
Yields:
Authenticated Playwright Page.
"""
page = authenticated_context.new_page()
yield page
page.close()

16
src/pytfm/web/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
"""Web testing module with Page Object Model support.
This module provides base classes and utilities for implementing
the Page Object Model pattern in web UI tests.
"""
from pytfm.web.locator import AsyncSmartLocator, LocatorConfig, SmartLocator
from pytfm.web.pom import AsyncBasePage, BasePage
__all__ = [
"AsyncBasePage",
"AsyncSmartLocator",
"BasePage",
"LocatorConfig",
"SmartLocator",
]

933
src/pytfm/web/locator.py Normal file
View File

@@ -0,0 +1,933 @@
"""Smart locator module for Playwright-based UI testing.
Provides both synchronous (:class:`SmartLocator`) and asynchronous
(:class:`AsyncSmartLocator`) variants with ``__getattr__`` proxying to
Playwright's ``Locator``, eliminating manual method wrappers.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal, Self
if TYPE_CHECKING:
from playwright.async_api import Locator as AsyncLocator
from playwright.async_api import Page as AsyncPage
from playwright.sync_api import Locator as SyncLocator
from playwright.sync_api import Page as SyncPage
@dataclass(frozen=True)
class LocatorConfig:
"""Configuration for locator search strategy.
Attributes:
selector: The selector string to locate elements.
strategy: How to interpret the selector:
``css``, ``xpath``, ``text``, ``testid``, or ``role``.
timeout: Default wait timeout in milliseconds.
visible_only: Whether to only match visible elements.
"""
selector: str
strategy: Literal["css", "xpath", "text", "testid", "role"] = "testid"
timeout: int = 5000
visible_only: bool = True
def to_playwright_selector(self) -> str:
"""Convert to a Playwright-compatible selector string.
Returns:
Selector usable with ``page.locator()``.
Example:
>>> LocatorConfig("submit", strategy="testid").to_playwright_selector()
'[data-testid="submit"]'
"""
if self.strategy == "testid":
safe = self.selector.replace('"', '\\"')
return f'[data-testid="{safe}"]'
if self.strategy == "css":
return self.selector
if self.strategy == "xpath":
return f"xpath={self.selector}"
if self.strategy == "text":
return f"text={self.selector}"
if self.strategy == "role":
parts = self.selector.split(":", 1)
role = parts[0]
name = parts[1] if len(parts) > 1 else None
if name:
return f"role={role}[name='{name}']"
return f"role={role}"
return self.selector
def _escape(value: str) -> str:
"""Escape special characters for CSS attribute selectors.
Args:
value: Raw string value.
Returns:
String safe inside ``[data-testid="..."]``.
"""
return value.replace("\\", "\\\\").replace('"', '\\"')
class SmartLocator:
"""Synchronous locator that proxies to a Playwright ``Locator``.
Factory methods create instances with a given :class:`LocatorConfig`.
Any method not defined on ``SmartLocator`` is proxied to the
underlying Playwright ``Locator`` via :meth:`__getattr__`.
The optional ``page`` kwarg can be passed to any proxied or compound
method to override the page for that single call.
Example:
>>> loc = SmartLocator.by_testid("btn-submit")
>>> loc.click(page=page) # proxied to PW Locator
>>> loc.fill("hello", page=page) # proxied
>>> loc.wait_for(state="visible") # requires page set via with_page()
"""
def __init__(
self,
config: LocatorConfig,
page: SyncPage | None = None,
) -> None:
"""Initialize the locator.
Args:
config: Search strategy configuration.
page: Optional Playwright sync ``Page`` to associate with.
"""
self._config = config
self._page = page
@property
def selector(self) -> str:
"""Playwright-compatible selector string from :class:`LocatorConfig`."""
return self._config.to_playwright_selector()
def __str__(self) -> str:
return self.selector
def __repr__(self) -> str:
return f"SmartLocator({self._config.strategy}='{self._config.selector}')"
def _get_locator(self, page: SyncPage | None = None) -> SyncLocator:
"""Resolve a Playwright ``Locator``, optionally with an override page.
Args:
page: Override page. Falls back to ``self._page``.
Returns:
Playwright ``Locator``.
Raises:
RuntimeError: If no page is available.
"""
target = page or self._page
if not target:
raise RuntimeError(
"No page attached. Pass a page= argument or use with_page()."
)
return target.locator(self.selector)
def __getattr__(self, name: str) -> Any:
"""Proxy attribute access to the underlying Playwright ``Locator``.
Returns a callable wrapper that resolves the locator lazily,
allowing a ``page`` kwarg to be passed per-call.
Args:
name: Attribute name.
Returns:
Callable proxy to the Playwright ``Locator`` method.
Raises:
AttributeError: If the attribute is private or not found on
the Playwright ``Locator``.
"""
if name.startswith("_"):
raise AttributeError(name)
def _proxy(*args: Any, **kwargs: Any) -> Any:
page = kwargs.pop("page", None)
locator = self._get_locator(page)
attr = getattr(locator, name, None)
if attr is None:
raise AttributeError(
f"'{type(self).__name__}' has no attribute '{name}'"
)
return attr(*args, **kwargs)
return _proxy
# ============ Factory Methods ============
@classmethod
def by_testid(
cls,
testid: str,
timeout: int = 5000,
visible_only: bool = True,
) -> Self:
"""Target ``[data-testid=...]``."""
return cls(
LocatorConfig(
selector=testid, strategy="testid", timeout=timeout, visible_only=visible_only
)
)
@classmethod
def by_css(
cls,
selector: str,
timeout: int = 5000,
visible_only: bool = True,
) -> Self:
"""Target with a CSS selector."""
return cls(
LocatorConfig(
selector=selector, strategy="css", timeout=timeout, visible_only=visible_only
)
)
@classmethod
def by_xpath(
cls,
xpath: str,
timeout: int = 5000,
visible_only: bool = True,
) -> Self:
"""Target with an XPath expression."""
return cls(
LocatorConfig(
selector=xpath, strategy="xpath", timeout=timeout, visible_only=visible_only
)
)
@classmethod
def by_text(
cls,
text: str,
timeout: int = 5000,
visible_only: bool = True,
) -> Self:
"""Target by text content."""
return cls(
LocatorConfig(
selector=text, strategy="text", timeout=timeout, visible_only=visible_only
)
)
@classmethod
def by_role(
cls,
role: str,
name: str | None = None,
timeout: int = 5000,
visible_only: bool = True,
) -> Self:
"""Target by ARIA role with optional accessible name."""
selector = f"{role}:{name}" if name else role
return cls(
LocatorConfig(
selector=selector, strategy="role", timeout=timeout, visible_only=visible_only
)
)
# ============ Page Association ============
def with_page(self, page: SyncPage) -> Self:
"""Bind this locator to a specific page.
Args:
page: Playwright sync ``Page``.
Returns:
New ``SmartLocator`` with the page attached.
"""
return self.__class__(self._config, page)
# ============ Compound Action Methods ============
def click_and_wait(
self,
page: SyncPage | None = None,
target_testid: str | None = None,
navigation: bool = False,
timeout: int | None = None,
) -> None:
"""Click and wait for navigation or a target element.
Args:
page: Override page. Falls back to ``self._page``.
target_testid: Wait for this ``data-testid`` after clicking.
navigation: When ``True``, wrap the click in
:meth:`Page.expect_navigation`.
timeout: Timeout in milliseconds. Defaults to config timeout.
Raises:
playwright.sync_api.TimeoutError: On navigation or element
wait timeout.
"""
locator = self._get_locator(page)
timeout_val = timeout or self._config.timeout
if navigation:
page_obj = page or self._page
if page_obj is None:
raise RuntimeError("No page available for navigation")
with page_obj.expect_navigation(
timeout=timeout_val * 2,
wait_until="load",
):
locator.click(timeout=timeout_val)
else:
locator.click(timeout=timeout_val)
if target_testid:
target_loc = self.__class__.by_testid(target_testid)
target_loc.wait_for(state="visible", page=page, timeout=timeout_val)
def fill_and_submit(
self,
page: SyncPage | None = None,
value: str = "",
submit_testid: str | None = None,
timeout: int | None = None,
) -> None:
"""Fill a field and submit the form.
Args:
page: Override page. Falls back to ``self._page``.
value: Text to fill.
submit_testid: If set, click this submit button instead of
pressing Enter.
timeout: Timeout in milliseconds. Defaults to config timeout.
"""
locator = self._get_locator(page)
timeout_val = timeout or self._config.timeout
locator.fill(value, timeout=timeout_val)
if submit_testid:
submit_loc = self.__class__.by_testid(submit_testid)
submit_loc.click(page=page, timeout=timeout_val)
else:
locator.press("Enter")
# ============ Wait Helpers ============
def wait_for_enabled(
self,
page: SyncPage | None = None,
timeout: int | None = None,
) -> None:
"""Wait for the element to become visible and enabled.
Uses Playwright's ``expect(locator).to_be_enabled()`` internally,
which works with **all** selector strategies (not just CSS).
Args:
page: Override page. Falls back to ``self._page``.
timeout: Timeout in milliseconds. Defaults to config timeout.
"""
from playwright.sync_api import expect
locator = self._get_locator(page)
timeout_val = timeout or self._config.timeout
expect(locator).to_be_enabled(timeout=timeout_val)
def wait_for_disabled(
self,
page: SyncPage | None = None,
timeout: int | None = None,
) -> None:
"""Wait for the element to become visible and disabled.
Uses Playwright's ``expect(locator).to_be_disabled()`` internally,
which works with **all** selector strategies (not just CSS).
Args:
page: Override page. Falls back to ``self._page``.
timeout: Timeout in milliseconds. Defaults to config timeout.
"""
from playwright.sync_api import expect
locator = self._get_locator(page)
timeout_val = timeout or self._config.timeout
expect(locator).to_be_disabled(timeout=timeout_val)
# ============ Selector Composition ============
def nth(self, index: int) -> Self:
"""Target the N-th matching element (zero-based).
Uses Playwright ``>> nth=`` chaining, compatible with all
selector strategies.
Args:
index: Zero-based index.
Returns:
New ``SmartLocator`` for the indexed element.
"""
new_selector = f"{self.selector} >> nth={index}"
return self.__class__(
LocatorConfig(
selector=new_selector,
strategy="css",
timeout=self._config.timeout,
visible_only=self._config.visible_only,
),
self._page,
)
def first(self) -> Self:
"""Target the first matching element."""
return self.nth(0)
def last(self) -> Self:
"""Target the last matching element."""
return self.nth(-1)
def filter(self, filter_testid: str) -> Self:
"""Narrow to elements that contain a descendant with the given testid.
Uses Playwright ``>> css=:has()`` chaining, compatible with all
selector strategies.
Args:
filter_testid: The ``data-testid`` a descendant must have.
Returns:
New ``SmartLocator`` filtered by the descendant.
"""
safe = _escape(filter_testid)
new_selector = f'{self.selector} >> css=:has([data-testid="{safe}"])'
return self.__class__(
LocatorConfig(
selector=new_selector,
strategy="css",
timeout=self._config.timeout,
visible_only=self._config.visible_only,
),
self._page,
)
def child(self, child_testid: str) -> Self:
"""Target a descendant element by ``data-testid``.
Uses Playwright ``>>`` chaining, compatible with all selector
strategies.
Args:
child_testid: The ``data-testid`` of the descendant.
Returns:
New ``SmartLocator`` for the descendant.
"""
safe = _escape(child_testid)
new_selector = f'{self.selector} >> [data-testid="{safe}"]'
return self.__class__(
LocatorConfig(
selector=new_selector,
strategy="css",
timeout=self._config.timeout,
visible_only=self._config.visible_only,
),
self._page,
)
def parent(self) -> Self:
"""Target the parent element using Playwright ``>> ..`` chaining.
Returns:
New ``SmartLocator`` for the parent.
"""
new_selector = f"{self.selector} >> .."
return self.__class__(
LocatorConfig(
selector=new_selector,
strategy="css",
timeout=self._config.timeout,
visible_only=self._config.visible_only,
),
self._page,
)
# ============ Assertion Helpers ============
def assert_visible(
self,
page: SyncPage | None = None,
visible: bool = True,
) -> None:
"""Assert the element's visibility state.
Args:
page: Override page. Falls back to ``self._page``.
visible: ``True`` = must be visible, ``False`` = must be hidden.
Raises:
AssertionError: If the visibility does not match.
"""
locator = self._get_locator(page)
is_vis = locator.is_visible()
if is_vis != visible:
raise AssertionError(
f"Expected visible={visible}, got visible={is_vis}"
)
def assert_text(
self,
expected: str,
page: SyncPage | None = None,
case_sensitive: bool = True,
) -> None:
"""Assert the element's exact text content.
Args:
expected: Expected text.
page: Override page. Falls back to ``self._page``.
case_sensitive: Compare case-sensitively.
Raises:
AssertionError: If the text does not match.
"""
locator = self._get_locator(page)
actual = locator.text_content() or ""
if not case_sensitive:
actual = actual.lower()
expected = expected.lower()
if actual != expected:
raise AssertionError(f"Expected text '{expected}', got '{actual}'")
def assert_contains_text(
self,
expected: str,
page: SyncPage | None = None,
case_sensitive: bool = True,
) -> None:
"""Assert the element's text contains a substring.
Args:
expected: Expected substring.
page: Override page. Falls back to ``self._page``.
case_sensitive: Compare case-sensitively.
Raises:
AssertionError: If the substring is not found.
"""
locator = self._get_locator(page)
actual = locator.text_content() or ""
if not case_sensitive:
actual = actual.lower()
expected = expected.lower()
if expected not in actual:
raise AssertionError(
f"Expected substring '{expected}' not found in '{actual}'"
)
def assert_count(self, expected: int, page: SyncPage | None = None) -> None:
"""Assert the number of matching elements.
Args:
expected: Expected count.
page: Override page. Falls back to ``self._page``.
Raises:
AssertionError: If the count does not match.
"""
locator = self._get_locator(page)
actual = locator.count()
if actual != expected:
raise AssertionError(f"Expected {expected} elements, got {actual}")
def assert_attribute(
self,
name: str,
expected: str,
page: SyncPage | None = None,
) -> None:
"""Assert an attribute value on the element.
Args:
name: Attribute name.
expected: Expected value.
page: Override page. Falls back to ``self._page``.
Raises:
AssertionError: If the attribute value does not match.
"""
locator = self._get_locator(page)
actual = locator.get_attribute(name)
if actual != expected:
raise AssertionError(
f"Expected attribute '{name}'='{expected}', got '{actual}'"
)
class AsyncSmartLocator:
"""Asynchronous locator proxying Playwright's async ``Locator``.
Mirror of :class:`SmartLocator` for ``playwright.async_api``.
Example:
>>> loc = AsyncSmartLocator.by_testid("btn-submit")
>>> await loc.click(page=page)
>>> await loc.wait_for(state="visible", page=page)
"""
def __init__(
self,
config: LocatorConfig,
page: AsyncPage | None = None,
) -> None:
"""Initialize the async locator.
Args:
config: Search strategy configuration.
page: Optional Playwright async ``Page`` to associate with.
"""
self._config = config
self._page = page
@property
def selector(self) -> str:
"""Playwright-compatible selector string."""
return self._config.to_playwright_selector()
def __str__(self) -> str:
return self.selector
def __repr__(self) -> str:
return f"AsyncSmartLocator({self._config.strategy}='{self._config.selector}')"
def _get_locator(self, page: AsyncPage | None = None) -> AsyncLocator:
target = page or self._page
if not target:
raise RuntimeError(
"No page attached. Pass a page= argument or use with_page()."
)
return target.locator(self.selector)
def __getattr__(self, name: str) -> Any:
"""Proxy attribute access to the underlying Playwright ``Locator``.
Returns an async wrapper that ``await`` s the corresponding
Playwright ``Locator`` method and accepts a ``page`` kwarg.
Args:
name: Attribute name.
Returns:
Async wrapper around the Playwright ``Locator`` method.
Raises:
AttributeError: If the attribute does not exist.
"""
if name.startswith("_"):
raise AttributeError(name)
async def _proxy(*args: Any, **kwargs: Any) -> Any:
page = kwargs.pop("page", None)
locator = self._get_locator(page)
attr = getattr(locator, name, None)
if attr is None:
raise AttributeError(
f"'{type(self).__name__}' has no attribute '{name}'"
)
return await attr(*args, **kwargs)
return _proxy
# ============ Factory Methods ============
@classmethod
def by_testid(
cls,
testid: str,
timeout: int = 5000,
visible_only: bool = True,
) -> Self:
return cls(
LocatorConfig(
selector=testid, strategy="testid", timeout=timeout, visible_only=visible_only
)
)
@classmethod
def by_css(
cls,
selector: str,
timeout: int = 5000,
visible_only: bool = True,
) -> Self:
return cls(
LocatorConfig(
selector=selector, strategy="css", timeout=timeout, visible_only=visible_only
)
)
@classmethod
def by_xpath(
cls,
xpath: str,
timeout: int = 5000,
visible_only: bool = True,
) -> Self:
return cls(
LocatorConfig(
selector=xpath, strategy="xpath", timeout=timeout, visible_only=visible_only
)
)
@classmethod
def by_text(
cls,
text: str,
timeout: int = 5000,
visible_only: bool = True,
) -> Self:
return cls(
LocatorConfig(
selector=text, strategy="text", timeout=timeout, visible_only=visible_only
)
)
@classmethod
def by_role(
cls,
role: str,
name: str | None = None,
timeout: int = 5000,
visible_only: bool = True,
) -> Self:
selector = f"{role}:{name}" if name else role
return cls(
LocatorConfig(
selector=selector, strategy="role", timeout=timeout, visible_only=visible_only
)
)
# ============ Page Association ============
def with_page(self, page: AsyncPage) -> Self:
return self.__class__(self._config, page)
# ============ Compound Action Methods ============
async def click_and_wait(
self,
page: AsyncPage | None = None,
target_testid: str | None = None,
navigation: bool = False,
timeout: int | None = None,
) -> None:
"""Click and wait for navigation or a target element.
Args:
page: Override async page. Falls back to ``self._page``.
target_testid: Wait for this ``data-testid`` after clicking.
navigation: Wrap click in :meth:`Page.expect_navigation`.
timeout: Timeout in milliseconds. Defaults to config timeout.
"""
locator = self._get_locator(page)
timeout_val = timeout or self._config.timeout
if navigation:
page_obj = page or self._page
if page_obj is None:
raise RuntimeError("No page available for navigation")
async with page_obj.expect_navigation(
timeout=timeout_val * 2,
wait_until="load",
):
await locator.click(timeout=timeout_val)
else:
await locator.click(timeout=timeout_val)
if target_testid:
target_loc = self.__class__.by_testid(target_testid)
await target_loc.wait_for(state="visible", page=page, timeout=timeout_val)
async def fill_and_submit(
self,
page: AsyncPage | None = None,
value: str = "",
submit_testid: str | None = None,
timeout: int | None = None,
) -> None:
"""Fill a field and submit the form."""
locator = self._get_locator(page)
timeout_val = timeout or self._config.timeout
await locator.fill(value, timeout=timeout_val)
if submit_testid:
submit_loc = self.__class__.by_testid(submit_testid)
await submit_loc.click(page=page, timeout=timeout_val)
else:
await locator.press("Enter")
# ============ Wait Helpers ============
async def wait_for_enabled(
self,
page: AsyncPage | None = None,
timeout: int | None = None,
) -> None:
"""Wait for the element to become visible and enabled.
Uses Playwright's ``expect(locator).to_be_enabled()``.
"""
from playwright.async_api import expect
locator = self._get_locator(page)
timeout_val = timeout or self._config.timeout
await expect(locator).to_be_enabled(timeout=timeout_val)
async def wait_for_disabled(
self,
page: AsyncPage | None = None,
timeout: int | None = None,
) -> None:
"""Wait for the element to become visible and disabled.
Uses Playwright's ``expect(locator).to_be_disabled()``.
"""
from playwright.async_api import expect
locator = self._get_locator(page)
timeout_val = timeout or self._config.timeout
await expect(locator).to_be_disabled(timeout=timeout_val)
# ============ Selector Composition ============
def nth(self, index: int) -> Self:
new_selector = f"{self.selector} >> nth={index}"
return self.__class__(
LocatorConfig(
selector=new_selector,
strategy="css",
timeout=self._config.timeout,
visible_only=self._config.visible_only,
),
self._page,
)
def first(self) -> Self:
return self.nth(0)
def last(self) -> Self:
return self.nth(-1)
def filter(self, filter_testid: str) -> Self:
safe = _escape(filter_testid)
new_selector = f'{self.selector} >> css=:has([data-testid="{safe}"])'
return self.__class__(
LocatorConfig(
selector=new_selector,
strategy="css",
timeout=self._config.timeout,
visible_only=self._config.visible_only,
),
self._page,
)
def child(self, child_testid: str) -> Self:
safe = _escape(child_testid)
new_selector = f'{self.selector} >> [data-testid="{safe}"]'
return self.__class__(
LocatorConfig(
selector=new_selector,
strategy="css",
timeout=self._config.timeout,
visible_only=self._config.visible_only,
),
self._page,
)
def parent(self) -> Self:
new_selector = f"{self.selector} >> .."
return self.__class__(
LocatorConfig(
selector=new_selector,
strategy="css",
timeout=self._config.timeout,
visible_only=self._config.visible_only,
),
self._page,
)
# ============ Assertion Helpers ============
async def assert_visible(
self,
page: AsyncPage | None = None,
visible: bool = True,
) -> None:
locator = self._get_locator(page)
is_vis = await locator.is_visible()
if is_vis != visible:
raise AssertionError(
f"Expected visible={visible}, got visible={is_vis}"
)
async def assert_text(
self,
expected: str,
page: AsyncPage | None = None,
case_sensitive: bool = True,
) -> None:
locator = self._get_locator(page)
actual = await locator.text_content() or ""
if not case_sensitive:
actual = actual.lower()
expected = expected.lower()
if actual != expected:
raise AssertionError(f"Expected text '{expected}', got '{actual}'")
async def assert_contains_text(
self,
expected: str,
page: AsyncPage | None = None,
case_sensitive: bool = True,
) -> None:
locator = self._get_locator(page)
actual = await locator.text_content() or ""
if not case_sensitive:
actual = actual.lower()
expected = expected.lower()
if expected not in actual:
raise AssertionError(
f"Expected substring '{expected}' not found in '{actual}'"
)
async def assert_count(
self,
expected: int,
page: AsyncPage | None = None,
) -> None:
locator = self._get_locator(page)
actual = await locator.count()
if actual != expected:
raise AssertionError(f"Expected {expected} elements, got {actual}")
async def assert_attribute(
self,
name: str,
expected: str,
page: AsyncPage | None = None,
) -> None:
locator = self._get_locator(page)
actual = await locator.get_attribute(name)
if actual != expected:
raise AssertionError(
f"Expected attribute '{name}'='{expected}', got '{actual}'"
)

253
src/pytfm/web/pom.py Normal file
View File

@@ -0,0 +1,253 @@
"""Page Object Model base classes for UI testing.
Provides both synchronous (:class:`BasePage`) and asynchronous
(:class:`AsyncBasePage`) base page objects with SmartLocator integration.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Self
from pytfm.web.locator import AsyncSmartLocator, SmartLocator
if TYPE_CHECKING:
from playwright.async_api import Page as AsyncPage
from playwright.sync_api import Page as SyncPage
class BasePage:
"""Synchronous base class for all page objects.
Uses :class:`SmartLocator` internally for all element access.
The :meth:`locator` method (aliased as :meth:`loc`) provides a shorthand
for creating ``data-testid``-based locators bound to the current page.
Example:
>>> class LoginPage(BasePage):
... path = "/login"
... def login(self, user: str, pwd: str) -> None:
... self.loc("username").fill(user)
... self.loc("password").fill(pwd)
... self.loc("submit").click()
"""
path: str = ""
def __init__(self, page: SyncPage, base_url: str) -> None:
"""Initialize the page object.
Args:
page: Playwright sync ``Page`` instance.
base_url: Base URL of the application.
"""
self.page = page
self.base_url = base_url.rstrip("/")
@property
def url(self) -> str:
"""Full page URL composed from ``base_url`` and ``path``."""
return f"{self.base_url}{self.path}"
def locator(
self,
testid: str,
timeout: int = 5000,
visible_only: bool = True,
) -> SmartLocator:
"""Create a :class:`SmartLocator` by ``data-testid`` bound to this page.
Args:
testid: The ``data-testid`` value to locate.
timeout: Default wait timeout in milliseconds.
visible_only: Only match visible elements.
Returns:
SmartLocator bound to this page for chaining.
"""
return SmartLocator.by_testid(
testid, timeout=timeout, visible_only=visible_only
).with_page(self.page)
loc = locator
def open(self) -> Self:
"""Navigate to the page URL and return self for chaining.
Returns:
Self for method chaining.
"""
self.page.goto(self.url)
return self
def wait_for_element(
self,
testid: str,
timeout: int | None = None,
) -> None:
"""Wait for an element to become visible.
Args:
testid: The ``data-testid`` of the element.
timeout: Timeout in milliseconds. Defaults to 5000.
"""
self.locator(testid, timeout=timeout or 5000).wait_for(state="visible")
def click(self, testid: str) -> None:
"""Click an element.
Args:
testid: The ``data-testid`` of the element.
"""
self.locator(testid).click()
def fill(self, testid: str, value: str) -> None:
"""Fill an element with text.
Args:
testid: The ``data-testid`` of the element.
value: Text to fill.
"""
self.locator(testid).fill(value)
def get_text(self, testid: str) -> str:
"""Get the text content of an element.
Args:
testid: The ``data-testid`` of the element.
Returns:
Text content or empty string.
"""
text = self.locator(testid).text_content()
return text if text is not None else ""
def is_visible(self, testid: str) -> bool:
"""Check if an element is visible.
Args:
testid: The ``data-testid`` of the element.
Returns:
``True`` if the element is visible.
"""
return self.locator(testid)._get_locator().is_visible()
class AsyncBasePage:
"""Asynchronous base class for page objects using ``playwright.async_api``.
Uses :class:`AsyncSmartLocator` internally for all element access.
The :meth:`locator` method (aliased as :meth:`loc`) provides an async-safe
shorthand for creating ``data-testid``-based locators bound to the page.
Example:
>>> class LoginPage(AsyncBasePage):
... path = "/login"
... async def login(self, user: str, pwd: str) -> None:
... await self.loc("username").fill(user)
... await self.loc("password").fill(pwd)
... await self.loc("submit").click()
"""
path: str = ""
def __init__(self, page: AsyncPage, base_url: str) -> None:
"""Initialize the async page object.
Args:
page: Playwright async ``Page`` instance.
base_url: Base URL of the application.
"""
self.page = page
self.base_url = base_url.rstrip("/")
@property
def url(self) -> str:
"""Full page URL composed from ``base_url`` and ``path``."""
return f"{self.base_url}{self.path}"
def locator(
self,
testid: str,
timeout: int = 5000,
visible_only: bool = True,
) -> AsyncSmartLocator:
"""Create an :class:`AsyncSmartLocator` by ``data-testid`` bound to this page.
Args:
testid: The ``data-testid`` value to locate.
timeout: Default wait timeout in milliseconds.
visible_only: Only match visible elements.
Returns:
AsyncSmartLocator bound to this page.
"""
return AsyncSmartLocator.by_testid(
testid, timeout=timeout, visible_only=visible_only
).with_page(self.page)
loc = locator
async def open(self) -> Self:
"""Navigate to the page URL and return self for chaining.
Returns:
Self for method chaining.
"""
await self.page.goto(self.url)
return self
async def wait_for_element(
self,
testid: str,
timeout: int | None = None,
) -> None:
"""Wait for an element to become visible.
Args:
testid: The ``data-testid`` of the element.
timeout: Timeout in milliseconds. Defaults to 5000.
"""
await self.locator(
testid, timeout=timeout or 5000
).wait_for(state="visible")
async def click(self, testid: str) -> None:
"""Click an element.
Args:
testid: The ``data-testid`` of the element.
"""
await self.locator(testid).click()
async def fill(self, testid: str, value: str) -> None:
"""Fill an element with text.
Args:
testid: The ``data-testid`` of the element.
value: Text to fill.
"""
await self.locator(testid).fill(value)
async def get_text(self, testid: str) -> str:
"""Get the text content of an element.
Args:
testid: The ``data-testid`` of the element.
Returns:
Text content or empty string.
"""
text = await self.locator(testid).text_content()
return text if text is not None else ""
async def is_visible(self, testid: str) -> bool:
"""Check if an element is visible.
Args:
testid: The ``data-testid`` of the element.
Returns:
``True`` if the element is visible.
"""
return await self.locator(testid)._get_locator().is_visible()