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]
|
||||
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 = []
|
||||
|
||||
[project.scripts]
|
||||
pytfm = "pytfm:main"
|
||||
dependencies = [
|
||||
"playwright>=1.42.0",
|
||||
"httpx>=0.28.0",
|
||||
"pytest>=9.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"pydantic>=2.0.0",
|
||||
]
|
||||
|
||||
[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"]
|
||||
|
||||
@@ -1,2 +1,21 @@
|
||||
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
|
||||
- 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