diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10f9cfd --- /dev/null +++ b/.gitignore @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 0854c73..7f193f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/pytfm/__init__.py b/src/pytfm/__init__.py index 802a1e0..e161b3d 100644 --- a/src/pytfm/__init__.py +++ b/src/pytfm/__init__.py @@ -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"] \ No newline at end of file diff --git a/src/pytfm/api/__init__.py b/src/pytfm/api/__init__.py new file mode 100644 index 0000000..86d63d4 --- /dev/null +++ b/src/pytfm/api/__init__.py @@ -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"] \ No newline at end of file diff --git a/src/pytfm/api/api_client.py b/src/pytfm/api/api_client.py new file mode 100644 index 0000000..a84d5a0 --- /dev/null +++ b/src/pytfm/api/api_client.py @@ -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) diff --git a/src/pytfm/generators/__init__.py b/src/pytfm/generators/__init__.py new file mode 100644 index 0000000..0044731 --- /dev/null +++ b/src/pytfm/generators/__init__.py @@ -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" diff --git a/src/pytfm/web/__init__.py b/src/pytfm/web/__init__.py new file mode 100644 index 0000000..f600727 --- /dev/null +++ b/src/pytfm/web/__init__.py @@ -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"] diff --git a/src/pytfm/web/pom.py b/src/pytfm/web/pom.py new file mode 100644 index 0000000..9ce98ea --- /dev/null +++ b/src/pytfm/web/pom.py @@ -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