feature: init test framework
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal 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
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
9
src/pytfm/api/__init__.py
Normal file
9
src/pytfm/api/__init__.py
Normal 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
261
src/pytfm/api/api_client.py
Normal 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)
|
||||||
45
src/pytfm/generators/__init__.py
Normal file
45
src/pytfm/generators/__init__.py
Normal 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"
|
||||||
9
src/pytfm/web/__init__.py
Normal file
9
src/pytfm/web/__init__.py
Normal 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
121
src/pytfm/web/pom.py
Normal 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
|
||||||
Reference in New Issue
Block a user