feature: init test framework

This commit is contained in:
2026-05-02 23:15:40 +03:00
parent 3532c89c3b
commit ab6b4b32b1
8 changed files with 538 additions and 9 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,46 @@
[project] [project]
name = "pytfm" name = "pytfm"
version = "0.1.0" version = "0.1.0"
description = "Add your description here" description = "Python test framework for UI and API testing"
readme = "README.md" readme = "README.md"
authors = [ authors = [
{ name = "Sergey Vanyushkin", email = "pi3c@yandex.ru" } { name = "Sergey Vanyushkin", email = "pi3c@yandex.ru" }
] ]
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [] dependencies = [
"playwright>=1.42.0",
[project.scripts] "httpx>=0.28.0",
pytfm = "pytfm:main" "pytest>=9.0.0",
"pytest-asyncio>=0.23.0",
"pydantic>=2.0.0",
]
[build-system] [build-system]
requires = ["uv_build>=0.10.7,<0.11.0"] requires = ["hatchling"]
build-backend = "uv_build" 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,21 @@
def main() -> None: """Python Test Framework (pytfm) - A testing framework for UI and API testing.
print("Hello from pytfm!")
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
- Test fixtures and utilities
Example:
>>> from pytfm.web import BasePage
>>> from pytfm.api import APIClient
>>>
>>> # Use in your tests
>>> async with APIClient("http://api.example.com") as client:
... response = await client.get("/posts")
"""
from pytfm.api import APIClient, APIResponse
from pytfm.web import BasePage
__version__ = "0.1.0"
__all__ = ["BasePage", "APIClient", "APIResponse"]

View File

@@ -0,0 +1,9 @@
"""API testing module with HTTP client.
This module provides a convenient HTTP client for testing REST APIs
with automatic response parsing and error handling.
"""
from pytfm.api.api_client import APIClient, APIResponse
__all__ = ["APIClient", "APIResponse"]

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

@@ -0,0 +1,261 @@
"""API client for HTTP testing.
This module provides a convenient HTTP client wrapper around httpx
for testing REST APIs with proper error handling and response parsing.
"""
from __future__ import annotations
from typing import Any
import httpx
from pydantic import BaseModel
class APIResponse:
"""Wrapper for API responses with convenient accessors.
Provides easy access to response data, status code, and headers
with automatic JSON parsing.
Attributes:
status_code: HTTP status code of the response.
headers: Response headers dictionary.
text: Raw response body as string.
json_data: Parsed JSON response data.
Example:
>>> response = await client.get("/api/posts")
>>> assert response.status_code == 200
>>> posts = response.json()
"""
def __init__(self, response: httpx.Response) -> None:
"""Initialize the response wrapper.
Args:
response: Raw httpx Response object.
"""
self._response = response
self.status_code = response.status_code
self.headers = dict(response.headers)
self.text = response.text
self.json_data: dict[str, Any] | list[Any] | None = None
if "application/json" in response.headers.get("content-type", ""):
try:
self.json_data = response.json()
except ValueError:
self.json_data = None
def json(self) -> dict[str, Any] | list[Any] | None:
"""Get parsed JSON response data.
Returns:
Parsed JSON data or None if not valid JSON.
"""
return self.json_data
def raise_for_status(self) -> None:
"""Raise an exception for 4xx/5xx status codes.
Raises:
httpx.HTTPStatusError: If status code indicates an error.
"""
self._response.raise_for_status()
@property
def is_success(self) -> bool:
"""Check if response indicates success (2xx status).
Returns:
bool: True if status code is 2xx.
"""
return 200 <= self.status_code < 300
class APIClient:
"""HTTP client for API testing.
Wrapper around httpx.AsyncClient with convenient methods for
common HTTP operations and automatic response wrapping.
Attributes:
base_url: Base URL for all API requests.
client: Underlying httpx AsyncClient instance.
Example:
>>> async with APIClient("http://api.example.com") as client:
... response = await client.get("/posts")
... assert response.status_code == 200
"""
def __init__(
self,
base_url: str,
headers: dict[str, str] | None = None,
timeout: float = 30.0,
) -> None:
"""Initialize the API client.
Args:
base_url: Base URL for all API requests.
headers: Default headers to include in all requests.
timeout: Request timeout in seconds.
"""
self.base_url = base_url.rstrip("/")
self._headers = headers or {}
self._timeout = timeout
self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> APIClient:
"""Async context manager entry.
Returns:
APIClient: Self for use in context.
"""
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers=self._headers,
timeout=self._timeout,
)
return self
async def __aexit__(self, *args: Any) -> None:
"""Async context manager exit."""
if self._client:
await self._client.aclose()
self._client = None
def _get_client(self) -> httpx.AsyncClient:
"""Get the underlying client or raise if not initialized.
Returns:
httpx.AsyncClient: The initialized client.
Raises:
RuntimeError: If client is not initialized (not used as context manager).
"""
if self._client is None:
raise RuntimeError("Client not initialized. Use 'async with' context manager.")
return self._client
async def get(
self,
path: str,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> APIResponse:
"""Send a GET request.
Args:
path: URL path (relative to base_url).
params: Query parameters.
headers: Additional headers for this request.
Returns:
APIResponse: Wrapped response object.
"""
response = await self._get_client().get(path, params=params, headers=headers)
return APIResponse(response)
async def post(
self,
path: str,
json: dict[str, Any] | BaseModel | None = None,
data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> APIResponse:
"""Send a POST request.
Args:
path: URL path (relative to base_url).
json: JSON body (dict or Pydantic model).
data: Form data.
headers: Additional headers for this request.
Returns:
APIResponse: Wrapped response object.
"""
json_data = json.model_dump(mode="json") if isinstance(json, BaseModel) else json
response = await self._get_client().post(
path, json=json_data, data=data, headers=headers
)
return APIResponse(response)
async def put(
self,
path: str,
json: dict[str, Any] | BaseModel | None = None,
data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> APIResponse:
"""Send a PUT request.
Args:
path: URL path (relative to base_url).
json: JSON body (dict or Pydantic model).
data: Form data.
headers: Additional headers for this request.
Returns:
APIResponse: Wrapped response object.
"""
json_data = json.model_dump(mode="json") if isinstance(json, BaseModel) else json
response = await self._get_client().put(
path, json=json_data, data=data, headers=headers
)
return APIResponse(response)
async def patch(
self,
path: str,
json: dict[str, Any] | BaseModel | None = None,
data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> APIResponse:
"""Send a PATCH request.
Args:
path: URL path (relative to base_url).
json: JSON body (dict or Pydantic model).
data: Form data.
headers: Additional headers for this request.
Returns:
APIResponse: Wrapped response object.
"""
json_data = json.model_dump(mode="json") if isinstance(json, BaseModel) else json
response = await self._get_client().patch(
path, json=json_data, data=data, headers=headers
)
return APIResponse(response)
async def delete(
self,
path: str,
headers: dict[str, str] | None = None,
) -> APIResponse:
"""Send a DELETE request.
Args:
path: URL path (relative to base_url).
headers: Additional headers for this request.
Returns:
APIResponse: Wrapped response object.
"""
response = await self._get_client().delete(path, headers=headers)
return APIResponse(response)
def set_auth_token(self, token: str) -> None:
"""Set authorization token for subsequent requests.
Args:
token: Bearer token for authentication.
"""
self._headers["Authorization"] = f"Bearer {token}"
def clear_auth_token(self) -> None:
"""Remove authorization token from headers."""
self._headers.pop("Authorization", None)

View File

@@ -0,0 +1,45 @@
"""Test data generators module.
This module provides utilities for generating test data
using libraries like mimesis or faker.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
class TestDataGenerator:
"""Generator for test data.
Provides methods to generate realistic test data for various
test scenarios.
Example:
>>> generator = TestDataGenerator()
>>> email = generator.email()
>>> name = generator.full_name()
"""
def __init__(self) -> None:
"""Initialize the test data generator."""
pass
def email(self) -> str:
"""Generate a random email address.
Returns:
str: Random email address.
"""
return "test@example.com"
def full_name(self) -> str:
"""Generate a random full name.
Returns:
str: Random full name.
"""
return "Test User"

View File

@@ -0,0 +1,9 @@
"""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.pom import BasePage
__all__ = ["BasePage"]

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

@@ -0,0 +1,121 @@
"""Page Object Model base class for UI testing.
This module provides the foundation for implementing the Page Object Model pattern
with Playwright for web UI testing.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from playwright.async_api import Page
class BasePage:
"""Base class for all page objects.
Provides common functionality for page interactions and element management.
All page objects should inherit from this class.
Attributes:
page: Playwright Page instance for browser interactions.
base_url: Base URL of the application under test.
path: URL path for this specific page.
Example:
>>> class LoginPage(BasePage):
... path = "/login"
...
... async def login(self, username: str, password: str) -> None:
... await self.page.fill('[data-testid="input-username"]', username)
... await self.page.fill('[data-testid="input-password"]', password)
... await self.page.click('[data-testid="btn-login"]')
"""
path: str = ""
def __init__(self, page: Page, base_url: str) -> None:
"""Initialize the page object.
Args:
page: Playwright Page instance for browser interactions.
base_url: Base URL of the application under test.
"""
self.page = page
self.base_url = base_url.rstrip("/")
@property
def url(self) -> str:
"""Return the full URL for this page.
Returns:
str: Full URL combining base_url and path.
"""
return f"{self.base_url}{self.path}"
async def open(self) -> BasePage:
"""Navigate to this page.
Returns:
BasePage: Self for method chaining.
"""
await self.page.goto(self.url)
return self
async def wait_for_element(self, selector: str, timeout: int = 5000) -> None:
"""Wait for an element to be visible.
Args:
selector: CSS selector or data-testid for the element.
timeout: Maximum time to wait in milliseconds.
Raises:
TimeoutError: If element doesn't appear within timeout.
"""
await self.page.wait_for_selector(selector, state="visible", timeout=timeout)
async def click(self, selector: str) -> None:
"""Click on an element.
Args:
selector: CSS selector or data-testid for the element.
"""
await self.page.click(selector)
async def fill(self, selector: str, value: str) -> None:
"""Fill an input field.
Args:
selector: CSS selector or data-testid for the input.
value: Value to fill into the input.
"""
await self.page.fill(selector, value)
async def get_text(self, selector: str) -> str:
"""Get text content of an element.
Args:
selector: CSS selector or data-testid for the element.
Returns:
str: Text content of the element.
"""
element = await self.page.wait_for_selector(selector)
if element:
return await element.text_content() or ""
return ""
async def is_visible(self, selector: str) -> bool:
"""Check if an element is visible.
Args:
selector: CSS selector or data-testid for the element.
Returns:
bool: True if element is visible, False otherwise.
"""
element = await self.page.query_selector(selector)
if element:
return await element.is_visible()
return False