commit 9795660e1ff4a9faec9ed6efe67ae1cf3c8895cb Author: KamilM1205 Date: Mon Jan 19 23:32:11 2026 +0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99e615d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.pytest_cache/ +allure-report/ +allure-results/ +logs/ +reports/ +screenshots/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/__pycache__/__init__.cpython-313.pyc b/config/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d83ce32 Binary files /dev/null and b/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/config/__pycache__/api_config.cpython-313.pyc b/config/__pycache__/api_config.cpython-313.pyc new file mode 100644 index 0000000..60dc3bf Binary files /dev/null and b/config/__pycache__/api_config.cpython-313.pyc differ diff --git a/config/__pycache__/environment.cpython-313.pyc b/config/__pycache__/environment.cpython-313.pyc new file mode 100644 index 0000000..0271924 Binary files /dev/null and b/config/__pycache__/environment.cpython-313.pyc differ diff --git a/config/__pycache__/session_config.cpython-313.pyc b/config/__pycache__/session_config.cpython-313.pyc new file mode 100644 index 0000000..7ba9fd9 Binary files /dev/null and b/config/__pycache__/session_config.cpython-313.pyc differ diff --git a/config/__pycache__/settings.cpython-313.pyc b/config/__pycache__/settings.cpython-313.pyc new file mode 100644 index 0000000..5150494 Binary files /dev/null and b/config/__pycache__/settings.cpython-313.pyc differ diff --git a/config/__pycache__/ui_config.cpython-313.pyc b/config/__pycache__/ui_config.cpython-313.pyc new file mode 100644 index 0000000..f4b55fc Binary files /dev/null and b/config/__pycache__/ui_config.cpython-313.pyc differ diff --git a/config/api_config.py b/config/api_config.py new file mode 100644 index 0000000..6f85543 --- /dev/null +++ b/config/api_config.py @@ -0,0 +1,76 @@ +import os +from typing import Dict, Any, List +from config.environment import EnvironmentLoader + +class APIConfig: + """Настройки API""" + + # Базовые настройки API + API_BASE_URL = EnvironmentLoader.get_env_variable('API_BASE_URL', 'http://localhost:8080/api/v1') + API_VERSION = EnvironmentLoader.get_env_variable('API_VERSION', 'v1') + API_TIMEOUT = EnvironmentLoader.get_env_variable('API_TIMEOUT', 30) # секунды + + # Настройки запросов + MAX_RETRIES = EnvironmentLoader.get_env_variable('MAX_RETRIES', 3) + RETRY_DELAY = EnvironmentLoader.get_env_variable('RETRY_DELAY', 1) # секунды + RETRY_BACKOFF_FACTOR = EnvironmentLoader.get_env_variable('RETRY_BACKOFF_FACTOR', 2) + + # Настройки валидации + VALIDATE_SCHEMAS = EnvironmentLoader.get_env_variable('VALIDATE_SCHEMAS', True) + STRICT_VALIDATION = EnvironmentLoader.get_env_variable('STRICT_VALIDATION', False) + + # Настройки тестовых данных + TEST_DATA_PREFIX = EnvironmentLoader.get_env_variable('TEST_DATA_PREFIX', 'test_') + CLEANUP_AFTER_TESTS = EnvironmentLoader.get_env_variable('CLEANUP_AFTER_TESTS', True) + CLEANUP_ONLY_FAILED = EnvironmentLoader.get_env_variable('CLEANUP_ONLY_FAILED', False) + + # Endpoints (можно переопределить через окружение) + ENDPOINTS = { + 'auth_login': EnvironmentLoader.get_env_variable('ENDPOINT_AUTH_LOGIN', '/login'), + 'auth_logout': EnvironmentLoader.get_env_variable('ENDPOINT_AUTH_LOGOUT', '/logout'), + 'users_create': EnvironmentLoader.get_env_variable('ENDPOINT_USERS_CREATE', '/team/'), + 'users_get_all': EnvironmentLoader.get_env_variable('ENDPOINT_USERS_GET_ALL', '/members/'), + 'users_get_by_id': EnvironmentLoader.get_env_variable('ENDPOINT_USERS_GET_BY_ID', '/members/{id}'), + 'users_get_by_name': EnvironmentLoader.get_env_variable('ENDPOINT_USERS_GET_BY_NAME', '/members/name/{name}'), + 'users_update': EnvironmentLoader.get_env_variable('ENDPOINT_USERS_UPDATE', '/team/{id}'), + 'users_delete': EnvironmentLoader.get_env_variable('ENDPOINT_USERS_DELETE', '/team/{id}'), + 'posts_create': EnvironmentLoader.get_env_variable('ENDPOINT_POSTS_CREATE', '/post'), + 'posts_get_all': EnvironmentLoader.get_env_variable('ENDPOINT_POSTS_GET_ALL', '/post'), + 'posts_get_by_id': EnvironmentLoader.get_env_variable('ENDPOINT_POSTS_GET_BY_ID', '/post/{id}'), + 'posts_get_by_offset': EnvironmentLoader.get_env_variable('ENDPOINT_POSTS_GET_BY_OFFSET', '/post/offset/{offset}'), + 'posts_update': EnvironmentLoader.get_env_variable('ENDPOINT_POSTS_UPDATE', '/post/{id}'), + 'posts_delete': EnvironmentLoader.get_env_variable('ENDPOINT_POSTS_DELETE', '/post/{id}'), + 'images_upload': EnvironmentLoader.get_env_variable('ENDPOINT_IMAGES_UPLOAD', '/images/'), + 'images_get': EnvironmentLoader.get_env_variable('ENDPOINT_IMAGES_GET', '/images/{path}'), + 'images_delete': EnvironmentLoader.get_env_variable('ENDPOINT_IMAGES_DELETE', '/images/{path}'), + 'images_list': EnvironmentLoader.get_env_variable('ENDPOINT_IMAGES_LIST', '/images'), + } + + # Настройки по умолчанию для запросов + DEFAULT_HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0', + 'Accept': 'application/json', + 'Accept-Language': EnvironmentLoader.get_env_variable('API_ACCEPT_LANGUAGE', 'en-US,en;q=0.9'), + } + + # Настройки прокси (если нужно) + PROXY_ENABLED = EnvironmentLoader.get_env_variable('PROXY_ENABLED', False) + PROXY_HTTP = EnvironmentLoader.get_env_variable('PROXY_HTTP', None) + PROXY_HTTPS = EnvironmentLoader.get_env_variable('PROXY_HTTPS', None) + + @classmethod + def get_endpoint(cls, endpoint_name: str, **kwargs) -> str: + """Получение endpoint с подстановкой параметров""" + endpoint_template = cls.ENDPOINTS.get(endpoint_name) + if not endpoint_template: + raise ValueError(f"Endpoint '{endpoint_name}' not found in configuration") + + return cls.API_BASE_URL + endpoint_template.format(**kwargs) + + @classmethod + def get_all_endpoints(cls) -> Dict[str, str]: + """Получение всех endpoints""" + return cls.ENDPOINTS.copy() + +# Экспорт конфигурации +api_config = APIConfig diff --git a/config/environment.py b/config/environment.py new file mode 100644 index 0000000..cde5b38 --- /dev/null +++ b/config/environment.py @@ -0,0 +1,152 @@ +import os +import sys +from pathlib import Path +from typing import Optional, Dict, Any +from dotenv import load_dotenv, find_dotenv +import logging + +logger = logging.getLogger(__name__) + +class EnvironmentLoader: + """Класс для загрузки переменных окружения из .env файлов""" + + # Порядок загрузки файлов .env (от более специфичного к общему) + ENV_FILES_ORDER = [ + '.env.local', # Локальные переопределения (не в git) + f'.env.{os.getenv("ENV", "development")}', # Окружение: .env.test, .env.production + '.env', # Основной файл + ] + + @classmethod + def load_environment(cls, env_file: Optional[str] = None): + """ + Загрузка переменных окружения. + + Args: + env_file: Конкретный файл для загрузки (если None, используется порядок из ENV_FILES_ORDER) + """ + if env_file: + # Загрузка конкретного файла + env_path = Path(env_file) + if env_path.exists(): + logger.info(f"Loading environment from specified file: {env_path}") + load_dotenv(dotenv_path=env_path, override=True) + else: + logger.warning(f"Specified env file not found: {env_path}") + return + + # Автоматическая загрузка в определенном порядке + env_loaded = False + + for env_filename in cls.ENV_FILES_ORDER: + env_path = find_dotenv(env_filename, usecwd=True) + if env_path: + logger.info(f"Loading environment from: {env_path}") + load_dotenv(dotenv_path=env_path, override=True) + env_loaded = True + + if not env_loaded: + logger.warning("No .env files found, using system environment variables") + + @classmethod + def get_env_variable(cls, key: str, default: Any = None, required: bool = False) -> Any: + """ + Получение переменной окружения с проверкой. + + Args: + key: Ключ переменной окружения + default: Значение по умолчанию, если переменная не найдена + required: Обязательна ли переменная (вызывает исключение, если не найдена) + + Returns: + Значение переменной окружения + + Raises: + ValueError: Если переменная required=True и не найдена + """ + value = os.getenv(key) + + if value is None: + if required: + raise ValueError(f"Required environment variable '{key}' is not set") + return default + + # Автоматическое преобразование типов + if value.lower() in ('true', 'false'): + return value.lower() == 'true' + elif value.isdigit(): + return int(value) + elif cls._is_float(value): + return float(value) + elif value.startswith('[') and value.endswith(']'): + # Список значений, разделенных запятыми + return [item.strip() for item in value[1:-1].split(',') if item.strip()] + + return value + + @staticmethod + def _is_float(value: str) -> bool: + """Проверка, можно ли преобразовать строку в float""" + try: + float(value) + return True + except ValueError: + return False + + @classmethod + def validate_environment(cls): + """Валидация обязательных переменных окружения""" + required_vars = [ + # 'API_BASE_URL', + # 'UI_BASE_URL', + ] + + missing_vars = [] + for var in required_vars: + if not os.getenv(var): + missing_vars.append(var) + + if missing_vars: + raise EnvironmentError( + f"Missing required environment variables: {', '.join(missing_vars)}\n" + f"Please set them in .env file or system environment." + ) + + @classmethod + def print_environment_info(cls): + """Вывод информации о текущем окружении (для отладки)""" + env_info = { + 'ENV': os.getenv('ENV', 'development'), + 'API_BASE_URL': os.getenv('API_BASE_URL'), + 'UI_BASE_URL': os.getenv('UI_BASE_URL'), + 'DEBUG': os.getenv('DEBUG', 'False'), + 'LOG_LEVEL': os.getenv('LOG_LEVEL', 'INFO'), + } + + logger.info("Current environment configuration:") + for key, value in env_info.items(): + logger.info(f" {key}: {value}") + + @classmethod + def get_all_env_variables(cls, prefix: str = '') -> Dict[str, Any]: + """ + Получение всех переменных окружения с опциональным префиксом. + + Args: + prefix: Фильтр по префиксу (например, 'API_' для всех API настроек) + + Returns: + Словарь переменных окружения + """ + env_vars = {} + + for key, _ in os.environ.items(): + if len(prefix) == 0 and not key.startswith(prefix): + continue + + env_vars[key] = cls.get_env_variable(key) + + return env_vars + +# Инициализация при импорте модуля +EnvironmentLoader.load_environment() diff --git a/config/session_config.py b/config/session_config.py new file mode 100644 index 0000000..f821148 --- /dev/null +++ b/config/session_config.py @@ -0,0 +1,42 @@ +from typing import Dict +from config.environment import EnvironmentLoader + +class SessionConfig: + """Настройки сессий и авторизации""" + + # Настройки сессий + SESSION_TIMEOUT = EnvironmentLoader.get_env_variable('SESSION_TIMEOUT', 1800) # 30 минут + SESSION_COOKIE_NAME = EnvironmentLoader.get_env_variable('SESSION_COOKIE_NAME', 'session') + SESSION_COOKIE_SECURE = EnvironmentLoader.get_env_variable('SESSION_COOKIE_SECURE', True) + SESSION_COOKIE_HTTPONLY = EnvironmentLoader.get_env_variable('SESSION_COOKIE_HTTPONLY', False) + + # Настройки администратора для тестов + ADMIN_USERNAME = EnvironmentLoader.get_env_variable('ADMIN_USERNAME', 'muts') + ADMIN_PASSWORD = EnvironmentLoader.get_env_variable('ADMIN_PASSWORD', 'Abc1205') + + @classmethod + def get_admin_credentials(cls) -> Dict[str, str]: + """Получение учетных данных администратора""" + return { + "username": cls.ADMIN_USERNAME, + "password": cls.ADMIN_PASSWORD, + } + + @classmethod + def get_session_headers(cls) -> Dict[str, str]: + """Получение заголовков для сессий""" + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0", + "Accept": "application/json", + "Content-Type": "application/json", + "Host": "localhost:8080", + "Origin": "http://localhost:8080", + } + + if cls.SESSION_COOKIE_SECURE: + headers["X-Requested-With"] = "XMLHttpRequest" + + return headers + +# Экспорт конфигурации +session_config = SessionConfig diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..b14b6fd --- /dev/null +++ b/config/settings.py @@ -0,0 +1,168 @@ +import logging +from typing import Dict, Any +from pathlib import Path +from config.environment import EnvironmentLoader + +# Инициализация логгера +logger = logging.getLogger(__name__) + +class Settings: + """Основные настройки приложения""" + + # Загрузка переменных окружения + ENV = EnvironmentLoader.get_env_variable('ENV', 'development') + DEBUG = EnvironmentLoader.get_env_variable('DEBUG', False) + + # Базовые URL + API_BASE_URL = EnvironmentLoader.get_env_variable('API_BASE_URL', 'http://localhost:8080/api/v1') + UI_BASE_URL = EnvironmentLoader.get_env_variable('UI_BASE_URL', 'http://localhost:5173') + + # Настройки логирования + LOG_LEVEL = EnvironmentLoader.get_env_variable('LOG_LEVEL', 'INFO').upper() + LOG_FORMAT = EnvironmentLoader.get_env_variable( + 'LOG_FORMAT', + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + LOG_FILE = EnvironmentLoader.get_env_variable('LOG_FILE', 'logs/tests.log') + + # Настройки тестов + TEST_TIMEOUT = EnvironmentLoader.get_env_variable('TEST_TIMEOUT', 30) # секунды + TEST_RETRIES = EnvironmentLoader.get_env_variable('TEST_RETRIES', 3) + TEST_PARALLEL_WORKERS = EnvironmentLoader.get_env_variable('TEST_PARALLEL_WORKERS', 'auto') + + # Настройки Allure + ALLURE_RESULTS_DIR = EnvironmentLoader.get_env_variable('ALLURE_RESULTS_DIR', 'allure-results') + ALLURE_REPORT_DIR = EnvironmentLoader.get_env_variable('ALLURE_REPORT_DIR', 'allure-report') + + # Настройки ожиданий + IMPLICIT_WAIT = EnvironmentLoader.get_env_variable('IMPLICIT_WAIT', 10) # секунды + EXPLICIT_WAIT = EnvironmentLoader.get_env_variable('EXPLICIT_WAIT', 30) # секунды + POLL_FREQUENCY = EnvironmentLoader.get_env_variable('POLL_FREQUENCY', 0.5) # секунды + + # Базовые пути + PROJECT_ROOT = Path(__file__).parent.parent + TESTS_DIR = PROJECT_ROOT / 'tests' + DATA_DIR = TESTS_DIR / 'data' + REPORTS_DIR = PROJECT_ROOT / 'reports' + + # Создание необходимых директорий + @classmethod + def create_directories(cls): + """Создание необходимых директорий""" + directories = [ + cls.REPORTS_DIR, + cls.DATA_DIR, + Path(cls.LOG_FILE).parent if cls.LOG_FILE else None, + Path(cls.ALLURE_RESULTS_DIR).parent if cls.ALLURE_RESULTS_DIR else None, + ] + + for directory in directories: + if directory and not directory.exists(): + directory.mkdir(parents=True, exist_ok=True) + logger.debug(f"Created directory: {directory}") + + @classmethod + def get_logging_config(cls) -> Dict[str, Any]: + """Конфигурация логирования""" + return { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'standard': { + 'format': cls.LOG_FORMAT, + 'datefmt': '%Y-%m-%d %H:%M:%S', + }, + 'detailed': { + 'format': '%(asctime)s - %(name)s - %(levelname)s - %(module)s:%(lineno)d - %(message)s', + 'datefmt': '%Y-%m-%d %H:%M:%S', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'level': cls.LOG_LEVEL, + 'formatter': 'standard', + 'stream': 'ext://sys.stdout', + }, + 'file': { + 'class': 'logging.FileHandler', + 'level': cls.LOG_LEVEL, + 'formatter': 'detailed', + 'filename': cls.LOG_FILE, + 'mode': 'a', + }, + }, + 'loggers': { + '': { # root logger + 'handlers': ['console', 'file'], + 'level': cls.LOG_LEVEL, + 'propagate': True, + }, + 'tests': { + 'handlers': ['console', 'file'], + 'level': cls.LOG_LEVEL, + 'propagate': False, + }, + 'api': { + 'handlers': ['console', 'file'], + 'level': cls.LOG_LEVEL, + 'propagate': False, + }, + 'ui': { + 'handlers': ['console', 'file'], + 'level': cls.LOG_LEVEL, + 'propagate': False, + }, + }, + } + + @classmethod + def setup_logging(cls): + """Настройка логирования""" + import logging.config + logging.config.dictConfig(cls.get_logging_config()) + logger.info(f"Logging configured with level: {cls.LOG_LEVEL}") + + @classmethod + def validate(cls): + """Валидация настроек""" + # Проверка обязательных переменных + EnvironmentLoader.validate_environment() + + # Проверка URL + import validators + if not validators.url(cls.API_BASE_URL): + logger.warning(f"API_BASE_URL might be invalid: {cls.API_BASE_URL}") + + if not validators.url(cls.UI_BASE_URL): + logger.warning(f"UI_BASE_URL might be invalid: {cls.UI_BASE_URL}") + + # Создание директорий + cls.create_directories() + + logger.info(f"Settings validated for environment: {cls.ENV}") + + @classmethod + def print_summary(cls): + """Вывод сводки настроек""" + summary = f""" + ===== Environment Settings ===== + Environment: {cls.ENV} + Debug Mode: {cls.DEBUG} + API Base URL: {cls.API_BASE_URL} + UI Base URL: {cls.UI_BASE_URL} + Log Level: {cls.LOG_LEVEL} + Test Timeout: {cls.TEST_TIMEOUT}s + Test Retries: {cls.TEST_RETRIES} + Parallel Workers: {cls.TEST_PARALLEL_WORKERS} + ================================= + """ + logger.info(summary) + +# Инициализация настроек при импорте +Settings.validate() +Settings.setup_logging() +Settings.print_summary() + +# Экспорт настроек +settings = Settings diff --git a/config/ui_config.py b/config/ui_config.py new file mode 100644 index 0000000..7091d44 --- /dev/null +++ b/config/ui_config.py @@ -0,0 +1,90 @@ +from typing import Dict, Any +from config.environment import EnvironmentLoader + +class UIConfig: + """Настройки UI тестов""" + + # Базовые настройки + UI_BASE_URL = EnvironmentLoader.get_env_variable('UI_BASE_URL', 'http://localhost:5173') + UI_TIMEOUT = EnvironmentLoader.get_env_variable('UI_TIMEOUT', 30) # секунды + + # Настройки браузера + BROWSER_NAME = EnvironmentLoader.get_env_variable('BROWSER_NAME', 'firefox').lower() + BROWSER_HEADLESS = EnvironmentLoader.get_env_variable('BROWSER_HEADLESS', True) + BROWSER_WIDTH = EnvironmentLoader.get_env_variable('BROWSER_WIDTH', 1920) + BROWSER_HEIGHT = EnvironmentLoader.get_env_variable('BROWSER_HEIGHT', 1080) + BROWSER_LANGUAGE = EnvironmentLoader.get_env_variable('BROWSER_LANGUAGE', 'en') + BROWSER_FULLSCREEN = EnvironmentLoader.get_env_variable('BROWSER_FULLSCREEN', False) + + # Настройки Chrome + CHROME_HEADLESS_NEW = EnvironmentLoader.get_env_variable('CHROME_HEADLESS_NEW', True) + CHROME_DISABLE_GPU = EnvironmentLoader.get_env_variable('CHROME_DISABLE_GPU', True) + CHROME_NO_SANDBOX = EnvironmentLoader.get_env_variable('CHROME_NO_SANDBOX', True) + CHROME_DISABLE_DEV_SHM = EnvironmentLoader.get_env_variable('CHROME_DISABLE_DEV_SHM', True) + + # Настройки Firefox + FIREFOX_HEADLESS = EnvironmentLoader.get_env_variable('FIREFOX_HEADLESS', True) + + # Настройки ожиданий + IMPLICIT_WAIT = EnvironmentLoader.get_env_variable('IMPLICIT_WAIT', 10) # секунды + EXPLICIT_WAIT = EnvironmentLoader.get_env_variable('EXPLICIT_WAIT', 30) # секунды + PAGE_LOAD_TIMEOUT = EnvironmentLoader.get_env_variable('PAGE_LOAD_TIMEOUT', 60) # секунды + SCRIPT_TIMEOUT = EnvironmentLoader.get_env_variable('SCRIPT_TIMEOUT', 30) # секунды + + # Настройки скриншотов + SCREENSHOTS_ON_FAILURE = EnvironmentLoader.get_env_variable('SCREENSHOTS_ON_FAILURE', True) + SCREENSHOTS_DIR = EnvironmentLoader.get_env_variable('SCREENSHOTS_DIR', 'screenshots') + SCREENSHOTS_FORMAT = EnvironmentLoader.get_env_variable('SCREENSHOTS_FORMAT', 'png') + + # Пути + URLS = { + "team": EnvironmentLoader.get_env_variable('UI_URL_TEAM', '/team'), + "blog": EnvironmentLoader.get_env_variable('UI_URL_BLOG', '/blog') + } + + @classmethod + def get_url(cls, url_name: str, **kwargs) -> str: + """Получение endpoint с подстановкой параметров""" + url_template = cls.URLS.get(url_name) + if not url_template: + raise ValueError(f"URL '{url_name}' not found in configuration") + + return cls.UI_BASE_URL + url_template.format(**kwargs) + + + @classmethod + def get_browser_options(cls) -> Dict[str, Any]: + """Получение опций браузера""" + options = { + 'headless': cls.BROWSER_HEADLESS, + 'width': cls.BROWSER_WIDTH, + 'height': cls.BROWSER_HEIGHT, + 'language': cls.BROWSER_LANGUAGE, + } + + if cls.BROWSER_NAME == 'chrome': + options.update({ + 'headless_new': cls.CHROME_HEADLESS_NEW, + 'disable_gpu': cls.CHROME_DISABLE_GPU, + 'no_sandbox': cls.CHROME_NO_SANDBOX, + 'disable_dev_shm': cls.CHROME_DISABLE_DEV_SHM, + }) + elif cls.BROWSER_NAME == 'firefox': + options.update({ + 'headless': cls.FIREFOX_HEADLESS, + }) + + return options + + @classmethod + def get_wait_config(cls) -> Dict[str, Any]: + """Получение конфигурации ожиданий""" + return { + 'implicit': cls.IMPLICIT_WAIT, + 'explicit': cls.EXPLICIT_WAIT, + 'page_load': cls.PAGE_LOAD_TIMEOUT, + 'script': cls.SCRIPT_TIMEOUT, + } + +# Экспорт конфигурации +ui_config = UIConfig diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e00d456 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +selenium==3.141.0 +pytest==7.4.3 +urllib3<=2.0 +allure-pytest +dotenv +validators +colorama diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh new file mode 100755 index 0000000..08b90ea --- /dev/null +++ b/scripts/run_tests.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +ENV_FILE="${1:-.env.test}" + +echo "Loading environment from: $ENV_FILE" + +# Экспорт переменных окружения +if [ -f "$ENV_FILE" ]; then + export $(grep -v '^#' "$ENV_FILE" | xargs) +else + echo "Warning: Environment file $ENV_FILE not found" +fi + +mkdir allure-results + +# Запуск тестов +echo "Running tests..." +pytest tests/ \ + -v \ + --alluredir=allure-results \ + --junitxml=reports/junit.xml \ + --log-level=DEBUG + +# Генерация Allure отчета +if command -v allure &>/dev/null; then + echo "Generating Allure report..." + allure generate allure-results -o allure-report + echo "Allure report generated: allure-report/index.html" + echo "Serve allure results" + allure serve allure-results +else + echo "Allure CLI not found, skipping report generation" +fi diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 0000000..64ac6c8 --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,64 @@ +import logging +import allure +import pytest +from config.session_config import session_config +from tests.api.utils.api_client import APIClient + +logger = logging.getLogger(__name__) + +@pytest.fixture(scope="class") +def api_client(api_base_url): + """Клиент для работы с API""" + return APIClient(base_url=api_base_url) + +@pytest.fixture(scope="class") +def admin_credentials(): + """Учетные данные администратора""" + return session_config.get_admin_credentials() + +@pytest.fixture(scope="class") +def auth_admin(api_client: APIClient, admin_credentials): + with allure.step("Admin authentication fixture"): + logger.info("Authentificate admin") + + success = api_client.login( + admin_credentials["username"], + admin_credentials["password"] + ) + + assert success is True + assert api_client.logged_in is True + + logger.info("Admin authenticated") + + yield api_client + + api_client.logout() + +@pytest.fixture(scope="class") +def auth_user(api_client: APIClient, auth_admin: APIClient, api_user_auth_data): + id = '' + + with allure.step("User auth fixture"): + logger.info("Creating new user for auth") + + resp = auth_admin.create_user(api_user_auth_data) + assert resp.status_code is 201 + id = resp.json()['id'] + + + logger.info(f"Auth as user: {api_user_auth_data['username']}") + + resp = api_client.login( + api_user_auth_data['username'], + api_user_auth_data['password'] + ) + + assert resp + assert api_client.logged_in + + yield api_client + + api_client.logout() + auth_admin.delete_user(id) + diff --git a/tests/api/test_posts.py b/tests/api/test_posts.py new file mode 100644 index 0000000..ffddf14 --- /dev/null +++ b/tests/api/test_posts.py @@ -0,0 +1,206 @@ +from typing import Any, Dict +import pytest +import allure +import logging + +from tests.api.utils.api_client import APIClient + +logger = logging.getLogger(__name__) + + +# Фикстура для получения списка постов из API +@pytest.fixture(scope="function") +def list_of_posts(api_client): + with allure.step("Get all posts"): + resp = api_client.get_all_posts() + assert resp.status_code == 200 + return resp.json() + +# Отдельные классы вариант +@allure.feature("Posts") +@allure.story("Guest permissions") +class TestGuestPosts: + """Тестирование операций с постами под гостем (неавторизованный доступ)""" + + @allure.title("Guest: Creating new posts - should fail") + def test_guest_posts_creating(self, api_client, api_post_data): + with allure.step("Logged as guest"): + logger.info("Guest trying to create posts") + for post in api_post_data: + resp = api_client.create_post(post) + assert resp.status_code == 401 + + @allure.title("Guest: Update posts - should fail") + def test_guest_posts_update(self, api_client, list_of_posts): + posts = list_of_posts + + with allure.step("Guest trying to change posts data"): + for post in posts: + logger.info(f"Guest changing post: {post['title']}") + post["title"] = "Changed by guest" + post["description"] = "Changed by guest" + post["content"] = "Changed by guest" + + resp = api_client.update_post(post["id"], post) + assert resp.status_code == 401 + + @allure.title("Guest: Get all posts - should succeed") + def test_guest_get_all_posts(self, api_client): + with allure.step("Guest getting all posts"): + logger.info("Guest getting all posts") + resp = api_client.get_all_posts() + assert resp.status_code == 200 + + @allure.title("Guest: Get post by ID - should succeed") + def test_guest_get_post_by_id(self, api_client, list_of_posts): + posts = list_of_posts + if posts: + post_id = posts[0]["id"] + + with allure.step(f"Guest getting post with ID {post_id}"): + logger.info(f"Guest getting post: {post_id}") + resp = api_client.get_post(post_id) + assert resp.status_code == 200 + + @allure.title("Guest: Delete posts - should fail") + def test_guest_posts_deleting(self, api_client, list_of_posts): + posts = list_of_posts + + with allure.step("Guest trying to delete posts"): + for post in posts: + logger.info(f"Guest deleting post: {post['title']}") + resp = api_client.delete_post(post["id"]) + assert resp.status_code == 401 + + +@allure.feature("Posts") +@allure.story("Admin permissions") +class TestAdminPosts: + """Тестирование операций с постами под администратором""" + + @allure.title("Admin: Creating new posts - should succeed") + def test_admin_posts_creating(self, auth_admin, api_post_data, api_user_data): + id = '' + with allure.step("Logged as admin"): + pass + with allure.step("Create new user"): + user = api_user_data[0] + resp = auth_admin.create_user(user) + assert resp.status_code == 201 + id = resp.json()['id'] + with allure.step("Create posts"): + logger.info("Admin creating posts") + for post in api_post_data: + post['userId'] = id + resp = auth_admin.create_post(post) + assert resp.status_code == 201 + + @allure.title("Admin: Update posts - should succeed") + def test_admin_posts_update(self, auth_admin, list_of_posts): + posts = list_of_posts + + with allure.step("Admin changing posts data"): + for post in posts: + logger.info(f"Admin changing post: {post['title']}") + new_post: Dict[str, Any] = {} + new_post["title"] = "Changed by admin." + new_post["description"] = "Changed by admin. Test data." + new_post["content"] = "Changed by admin." + + resp = auth_admin.update_post(post["id"], new_post) + assert resp.status_code == 200 + + @allure.title("Admin: Get all posts - should succeed") + def test_admin_get_all_posts(self, auth_admin): + with allure.step("Admin getting all posts"): + logger.info("Admin getting all posts") + resp = auth_admin.get_all_posts() + assert resp.status_code == 200 + + @allure.title("Admin: Get post by ID - should succeed") + def test_admin_get_post_by_id(self, auth_admin, list_of_posts): + posts = list_of_posts + if posts: + post_id = posts[0]["id"] + + with allure.step(f"Admin getting post with ID {post_id}"): + logger.info(f"Admin getting post: {post_id}") + resp = auth_admin.get_post(post_id) + assert resp.status_code == 200 + + @allure.title("Admin: Delete posts - should succeed") + def test_admin_posts_deleting(self, auth_admin, list_of_posts): + posts = list_of_posts + id = posts[0]['userId'] + + with allure.step("Admin deleting posts"): + for post in posts: + logger.info(f"Admin deleting post: {post['title']}") + resp = auth_admin.delete_post(post["id"]) + assert resp.status_code == 200 + + with allure.step("Delete user"): + resp = auth_admin.delete_user(id) + assert resp.status_code == 200 + + +@allure.feature("Posts") +@allure.story("Regular user permissions") +class TestRegularUserPosts: + """Тестирование операций с постами под обычным пользователем""" + + @allure.title("User: Creating new posts - should succeed") + def test_user_posts_creating(self, auth_user: APIClient, api_post_data): + id = auth_user.get_all_users().json()[0]['id'] + + with allure.step("Logged as user"): + pass + + with allure.step("Create new posts"): + logger.info("User creating posts") + for post in api_post_data: + post['userId'] = id + resp = auth_user.create_post(post) + assert resp.status_code == 201 + + @allure.title("User: Update posts - should succeed") + def test_user_posts_update(self, auth_user, list_of_posts): + posts = list_of_posts + + with allure.step("User changing posts data"): + for post in posts: + logger.info(f"User changing post: {post['title']}") + post["title"] = "Changed by user" + post["description"] = "Changed by user" + post["content"] = "Changed by user" + + resp = auth_user.update_post(post["id"], post) + assert resp.status_code == 200 + + @allure.title("User: Get all posts - should succeed") + def test_user_get_all_posts(self, auth_user): + with allure.step("User getting all posts"): + logger.info("User getting all posts") + resp = auth_user.get_all_posts() + assert resp.status_code == 200 + + @allure.title("User: Get post by ID - should succeed") + def test_user_get_post_by_id(self, auth_user, list_of_posts): + posts = list_of_posts + if posts: + post_id = posts[0]["id"] + + with allure.step(f"User getting post with ID {post_id}"): + logger.info(f"User getting post: {post_id}") + resp = auth_user.get_post(post_id) + assert resp.status_code == 200 + + @allure.title("User: Delete posts - should succeed") + def test_user_posts_deleting(self, auth_user, list_of_posts): + posts = list_of_posts + + with allure.step("User deleting posts"): + for post in posts: + logger.info(f"User deleting post: {post['title']}") + resp = auth_user.delete_post(post["id"]) + assert resp.status_code == 200 diff --git a/tests/api/test_users.py b/tests/api/test_users.py new file mode 100644 index 0000000..fff2d99 --- /dev/null +++ b/tests/api/test_users.py @@ -0,0 +1,133 @@ +import pytest +import allure +import logging + +from tests.api.utils.api_client import APIClient + +logger = logging.getLogger(__name__) + + +# Фикстура для получения списка пользователей +@pytest.fixture(scope="class") +def list_of_users(api_client): + with allure.step("Get all users"): + resp = api_client.get_all_users() + assert resp.status_code == 200 + return resp.json() + + +# Тест для гостя (неавторизованный пользователь) +@allure.story("Guest permissions") +class TestGuestUsers: + """Тестирование операций с пользователями под гостем (неавторизованный доступ)""" + + @allure.title("Guest: Creating new user - should fail") + def test_guest_users_creating(self, api_client: APIClient, api_user_data): + assert api_client.logged_in == False + with allure.step("Logged as guest - trying to create users"): + logger.info("Guest trying to create users") + for user in api_user_data: + resp = api_client.create_user(user) + assert resp.status_code == 401, \ + f"Guest should not be able to create users. Got {resp.status_code}" + + @allure.title("Guest: Update user - should fail") + def test_guest_users_update(self, api_client: APIClient, list_of_users): + users = list_of_users + + with allure.step("Guest trying to change users data"): + for user in users: + logger.info(f"Guest changing user: {user['username']}") + user["motto"] = "Changed by guest" + resp = api_client.update_user(user["id"], user) + assert resp.status_code == 401, \ + f"Guest should not be able to update users. Got {resp.status_code}" + + @allure.title("Guest: Deleting users - should fail") + def test_guest_users_deleting(self, api_client: APIClient, list_of_users): + users = list_of_users + + with allure.step("Guest trying to delete users"): + for user in users: + logger.info(f"Guest deleting user: {user['username']}") + resp = api_client.delete_user(user["id"]) + assert resp.status_code == 401, \ + f"Guest should not be able to delete users. Got {resp.status_code}" + + +# Тест для администратора +@allure.story("Admin permissions") +class TestAdminUsers: + """Тестирование операций с пользователями под администратором""" + + @allure.title("Admin: Creating new user - should succeed") + def test_admin_users_creating(self, auth_admin, api_user_data): + with allure.step("Logged as admin - creating users"): + logger.info("Admin creating users") + for user in api_user_data: + resp = auth_admin.create_user(user) + assert resp.status_code == 201, \ + f"Admin should be able to create users. Got {resp.status_code}" + + @allure.title("Admin: Update user - should succeed") + def test_admin_users_update(self, auth_admin, list_of_users): + users = list_of_users + + with allure.step("Admin changing users data"): + for user in users: + logger.info(f"Admin changing user: {user['username']}") + user["motto"] = "Changed by admin" + user["password"] = "SomeRandomPass1205" + resp = auth_admin.update_user(user["id"], user) + assert resp.status_code == 200, \ + f"Admin should be able to update users. Got {resp.status_code}" + + @allure.title("Admin: Deleting users - should succeed") + def test_admin_users_deleting(self, auth_admin, list_of_users): + users = list_of_users + + with allure.step("Admin deleting users"): + for user in users: + logger.info(f"Admin deleting user: {user['username']}") + resp = auth_admin.delete_user(user["id"]) + assert resp.status_code == 200, \ + f"Admin should be able to delete users. Got {resp.status_code}" + + +# Тест для обычного пользователя +@allure.story("Regular user permissions") +class TestRegularUserUsers: + """Тестирование операций с пользователями под обычным пользователем""" + + @allure.title("User: Creating new user - should succeed") + def test_user_users_creating(self, auth_user, api_user_data): + with allure.step("Logged as regular user - creating users"): + logger.info("Regular user creating users") + for user in api_user_data: + resp = auth_user.create_user(user) + assert resp.status_code == 201, \ + f"Regular user should be able to create users. Got {resp.status_code}" + + @allure.title("User: Update user - should succeed") + def test_user_users_update(self, auth_user, list_of_users): + users = list_of_users + + with allure.step("Regular user changing users data"): + for user in users: + logger.info(f"Regular user changing: {user['username']}") + user["motto"] = "Changed by regular user" + user["password"] = "SomeRandomPass1205" + resp = auth_user.update_user(user["id"], user) + assert resp.status_code == 200, \ + f"Regular user should be able to update users. Got {resp.status_code}" + + @allure.title("User: Deleting users - should succeed") + def test_user_users_deleting(self, auth_user, list_of_users): + users = list_of_users + + with allure.step("Regular user deleting users"): + for user in users: + logger.info(f"Regular user deleting: {user['username']}") + resp = auth_user.delete_user(user["id"]) + assert resp.status_code == 200, \ + f"Regular user should be able to delete users. Got {resp.status_code}" diff --git a/tests/api/utils/api_client.py b/tests/api/utils/api_client.py new file mode 100644 index 0000000..54e99a7 --- /dev/null +++ b/tests/api/utils/api_client.py @@ -0,0 +1,213 @@ +import json +import requests +import allure +import logging +from typing import Optional, Dict, Any +from urllib.parse import urljoin +from config.api_config import api_config +from config.session_config import session_config + +logger = logging.getLogger(__name__) + +class APIClient: + def __init__(self, base_url: Optional[str] = None): + self.base_url = base_url or api_config.API_BASE_URL + self.session = requests.Session() + self.session.cookies.clear() + self.logged_in = False + logger.info("New API created.") + + # Настройка сессии + self._configure_session() + + def _configure_session(self): + """Настройка HTTP сессии""" + # Установка заголовков по умолчанию + self.session.headers.update(api_config.DEFAULT_HEADERS) + self.session.headers.update(session_config.get_session_headers()) + + logger.debug(f"API Client configured for {self.base_url}") + + def login(self, username: str, password: str) -> bool: + """Логин пользователя (создание сессии)""" + login_data = { + "username": username, + "password": password + } + + endpoint = api_config.get_endpoint('auth_login') + response = self.post(endpoint, json=login_data) + + if response.status_code == 200: + self.logged_in = True + logger.info(f"Successfully logged in as {username}") + + # Логирование cookies для отладки + if self.session.cookies: + cookies_info = dict(self.session.cookies) + logger.info(f"Session cookies: {cookies_info}") + + # Проверяем наличие сессионного cookie + session_cookie = session_config.SESSION_COOKIE_NAME + if session_cookie in cookies_info: + logger.info(f"Session cookie '{session_cookie}' is set") + + return True + else: + logger.error(f"Login failed for {username}: {response.status_code}") + logger.debug(f"Response: {response.text}") + return False + + def logout(self) -> bool: + """Выход из системы (закрытие сессии)""" + if not self.logged_in: + logger.warning("Attempted logout without being logged in") + return True + + endpoint = api_config.get_endpoint('auth_logout') + response = self.get(endpoint) + + if response.status_code == 200: + self.logged_in = False + + # Очищаем cookies + self.session.cookies.clear() + + logger.info("Successfully logged out") + return True + else: + logger.error(f"Logout failed: {response.status_code}") + return False + + def ensure_logged_in(self, username: str, password: str): + """Убедиться, что пользователь залогинен""" + if not self.logged_in: + return self.login(username, password) + return True + + def _send_request( + self, + method: str, + endpoint: str, + **kwargs + ) -> requests.Response: + """Отправка HTTP запроса с поддержкой сессий""" + url = urljoin(self.base_url, endpoint) + + with allure.step(f"{method.upper()} {endpoint}"): + # Добавляем логирование + self._log_request(method, url, kwargs) + + try: + response = self.session.request(method, url, headers=api_config.DEFAULT_HEADERS, **kwargs) + except requests.exceptions.RequestException as e: + logger.error(f"Request failed: {e}") + raise + + # Логирование ответа + self._log_response(response) + + return response + + def _log_request(self, method: str, url: str, kwargs: Dict[str, Any]): + """Логирование деталей запроса""" + request_details = ( + f"Request: {method} {url}\n" + f"Headers: {dict(self.session.headers)}\n" + f"Cookies: {dict(self.session.cookies)}\n" + f"Body: {kwargs.get('json', 'N/A')}" + ) + + allure.attach( + request_details, + name="Request Details", + attachment_type=allure.attachment_type.TEXT + ) + + logger.debug(f"{method} {url}") + + def _log_response(self, response: requests.Response): + """Логирование деталей ответа""" + response_details = ( + f"Status Code: {response.status_code}\n" + f"Response Headers: {dict(response.headers)}\n" + f"Response Cookies: {dict(response.cookies)}\n" + f"Response Body: {response.text[:500]}..." + ) + + allure.attach( + response_details, + name="Response Details", + attachment_type=allure.attachment_type.TEXT + ) + + logger.debug(f"Response: {response.status_code}") + + # Логирование ошибок + if response.status_code >= 400: + logger.warning(f"Request failed with status {response.status_code}") + logger.debug(f"Error response: {response.text}") + + # Методы HTTP запросов + def get(self, endpoint: str, **kwargs) -> requests.Response: + return self._send_request("GET", endpoint, **kwargs) + + def post(self, endpoint: str, **kwargs) -> requests.Response: + return self._send_request("POST", endpoint, **kwargs) + + def put(self, endpoint: str, **kwargs) -> requests.Response: + return self._send_request("PUT", endpoint, **kwargs) + + def delete(self, endpoint: str, **kwargs) -> requests.Response: + return self._send_request("DELETE", endpoint, **kwargs) + + def patch(self, endpoint: str, **kwargs) -> requests.Response: + return self._send_request("PATCH", endpoint, **kwargs) + + # Специальные методы для endpoints + def create_user(self, user_data: Dict[str, Any]) -> requests.Response: + endpoint = api_config.get_endpoint('users_create') + return self.post(endpoint, json=user_data) + + def get_user(self, user_id: str) -> requests.Response: + endpoint = api_config.get_endpoint('users_get_by_id', id=user_id) + return self.get(endpoint) + + def get_all_users(self) -> requests.Response: + endpoint = api_config.get_endpoint('users_get_all') + return self.get(endpoint) + + def update_user(self, user_id: str, user_data: Dict[str, Any]) -> requests.Response: + endpoint = api_config.get_endpoint('users_update', id=user_id) + return self.put(endpoint, json=user_data) + + def delete_user(self, user_id: str) -> requests.Response: + endpoint = api_config.get_endpoint('users_delete', id=user_id) + return self.delete(endpoint) + + def create_post(self, post_data: Dict[str, Any]) -> requests.Response: + endpoint = api_config.get_endpoint('posts_create') + return self.post(endpoint, json=post_data) + + def get_post(self, post_id: str) -> requests.Response: + endpoint = api_config.get_endpoint('posts_get_by_id', id=post_id) + return self.get(endpoint) + + def get_all_posts(self) -> requests.Response: + endpoint = api_config.get_endpoint('posts_get_all') + return self.get(endpoint) + + def update_post(self, post_id: str, post_data: Dict[str, Any]) -> requests.Response: + endpoint = api_config.get_endpoint('posts_update', id=post_id) + return self.put(endpoint, json=post_data) + + def delete_post(self, post_id: str) -> requests.Response: + endpoint = api_config.get_endpoint("posts_delete", id=post_id) + return self.delete(endpoint) + + def upload_image(self, file_path: str, field_name: str = "file") -> requests.Response: + endpoint = api_config.get_endpoint('images_upload') + + with open(file_path, 'rb') as f: + files = {field_name: (file_path, f)} + return self.post(endpoint, files=files) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..089513f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,106 @@ +import pytest +import logging +from config.settings import settings +from config.api_config import api_config +from config.ui_config import ui_config +from fixtures.data_fixtures import * + +logger = logging.getLogger(__name__) + +# Вывод информации о конфигурации +logger.info("=" * 50) +logger.info(f"Running tests in {settings.ENV} environment") +logger.info(f"API Base URL: {api_config.API_BASE_URL}") +logger.info(f"UI Base URL: {ui_config.UI_BASE_URL}") +logger.info("=" * 50) + +def pytest_addoption(parser): + """Добавление кастомных опций командной строки""" + parser.addoption( + "--env-file", + action="store", + default=None, + help="Path to custom .env file" + ) + parser.addoption( + "--browser", + action="store", + default=ui_config.BROWSER_NAME, + help="Browser to use for UI tests" + ) + parser.addoption( + "--headless", + action="store_true", + default=ui_config.BROWSER_HEADLESS, + help="Run browser in headless mode" + ) + parser.addoption( + "--api-url", + action="store", + default=api_config.API_BASE_URL, + help="Override API base URL" + ) + parser.addoption( + "--ui-url", + action="store", + default=ui_config.UI_BASE_URL, + help="Override UI base URL" + ) + +def pytest_configure(config): + """Конфигурация pytest""" + # Переопределение настроек из командной строки + if config.getoption("--api-url"): + api_config.API_BASE_URL = config.getoption("--api-url") + + if config.getoption("--ui-url"): + ui_config.UI_BASE_URL = config.getoption("--ui-url") + + if config.getoption("--browser"): + ui_config.BROWSER_NAME = config.getoption("--browser") + + if config.getoption("--headless") is not None: + ui_config.BROWSER_HEADLESS = config.getoption("--headless") + + # Настройка маркеров + config.addinivalue_line( + "markers", + "smoke: Smoke tests - critical functionality" + ) + config.addinivalue_line( + "markers", + "regression: Regression tests - full functionality" + ) + config.addinivalue_line( + "markers", + "api: API tests" + ) + config.addinivalue_line( + "markers", + "ui: UI tests" + ) + config.addinivalue_line( + "markers", + "slow: Slow running tests" + ) + +@pytest.fixture(scope="session") +def env_config(): + """Конфигурация окружения""" + return { + "env": settings.ENV, + "debug": settings.DEBUG, + "api_url": api_config.API_BASE_URL, + "ui_url": ui_config.UI_BASE_URL, + "log_level": settings.LOG_LEVEL, + } + +@pytest.fixture(scope="session") +def api_base_url(): + """Базовый URL API""" + return api_config.API_BASE_URL + +@pytest.fixture(scope="session") +def ui_base_url(): + """Базовый URL UI""" + return ui_config.UI_BASE_URL diff --git a/tests/data/api_posts.json b/tests/data/api_posts.json new file mode 100644 index 0000000..90f1b59 --- /dev/null +++ b/tests/data/api_posts.json @@ -0,0 +1,38 @@ +[ + { + "category": "Web Development", + "content": "Разбираем основы React.js: компоненты, состояние и пропсы. Создадим простое приложение-список задач, чтобы понять, как работает виртуальный DOM и управление состоянием.", + "description": "Введение в React.js для начинающих", + "tags": ["React", "фронтенд", "JavaScript"], + "title": "Первый проект на React.js: список задач" + }, + { + "category": "Cybersecurity", + "content": "Обзор основных методов защиты веб‑приложений: HTTPS, CSP, защита от SQL‑инъекций и XSS. Приведём примеры кода для безопасной обработки пользовательских данных.", + "description": "Основы безопасности веб‑приложений", + "tags": ["безопасность", "веб", "защита данных"], + "title": "Как защитить веб‑приложение: 5 ключевых методов" + }, + { + "category": "Cloud Computing", + "content": "Сравниваем AWS, Google Cloud и Azure: цены, сервисы и сценарии использования. Разберём, как выбрать облачную платформу для стартапа и крупного бизнеса.", + "description": "Выбор облачного провайдера: сравнение", + "tags": ["облако", "AWS", "Google Cloud", "Azure"], + "title": "AWS vs Google Cloud vs Azure: что выбрать?" + }, + { + "category": "DevOps", + "content": "Настройка CI/CD с GitHub Actions: автоматизируем тестирование и деплой. Покажем, как создать пайплайн для Node.js‑приложения за 10 минут.", + "description": "CI/CD на практике с GitHub Actions", + "tags": ["DevOps", "CI/CD", "GitHub Actions"], + "title": "Автоматизация сборки и деплоя: GitHub Actions" + }, + { + "category": "Mobile Development", + "content": "Создаём кросс‑платформенное приложение на Flutter: от установки SDK до первого экрана. Разберём архитектуру и преимущества Flutter перед Native разработкой.", + "description": "Начало работы с Flutter", + "tags": ["Flutter", "мобильная разработка", "кросс‑платформа"], + "title": "Flutter: пишем первое мобильное приложение" + } +] + diff --git a/tests/data/api_user_auth.json b/tests/data/api_user_auth.json new file mode 100644 index 0000000..126b948 --- /dev/null +++ b/tests/data/api_user_auth.json @@ -0,0 +1,13 @@ +{ + "avatar": "🔍", + "description": "QA‑инженер с фокусом на автоматизированное тестирование и CI/CD.", + "joinDate": "2023-10-01T20:00:00Z", + "motto": "Тестирование — это поиск истины.", + "name": "Андрей Васильев", + "password": "TestPas1205", + "projects": ["Web App Testing", "API Automation"], + "role": "QA Engineer", + "skills": ["Selenium", "JUnit", "Postman", "CI/CD", "TestRail"], + "speciality": "Good user", + "username": "andrey_user" + } diff --git a/tests/data/api_users.json b/tests/data/api_users.json new file mode 100644 index 0000000..07cf48d --- /dev/null +++ b/tests/data/api_users.json @@ -0,0 +1,132 @@ +[ + { + "avatar": "🧑", + "description": "Опытный разработчик Full‑Stack с 8‑летним стажем. Специализируется на веб‑приложениях и микросервисах.", + "joinDate": "2023-05-12T12:00:00Z", + "motto": "Код — это поэзия логики.", + "name": "Алексей Петров", + "password": "TestPas1205", + "projects": ["E‑commerce Platform", "CRM System", "Task Manager"], + "role": "Senior Developer", + "skills": ["JavaScript", "Python", "Docker", "Kubernetes", "PostgreSQL"], + "speciality": "Web Development", + "username": "alex_dev" + }, + { + "avatar": "👩", + "description": "UX/UI‑дизайнер с фокусом на пользовательский опыт и адаптивные интерфейсы.", + "joinDate": "2022-11-03T11:00:00Z", + "motto": "Дизайн — это не про красоту, а про удобство.", + "name": "Мария Иванова", + "password": "TestPas1205", + "projects": ["Mobile Banking App", "E‑learning Platform"], + "role": "UI/UX Designer", + "skills": ["Figma", "Adobe XD", "User Research", "Prototyping"], + "speciality": "User Experience", + "username": "maria_design" + }, + { + "avatar": "👨‍💻", + "description": "Системный администратор с экспертизой в облачных решениях и кибербезопасности.", + "joinDate": "2024-01-20T10:00:00Z", + "motto": "Безопасность — не опция, а необходимость.", + "name": "Дмитрий Соколов", + "password": "TestPas1205", + "projects": ["Cloud Migration", "Network Security Audit"], + "role": "SysAdmin", + "skills": ["AWS", "Azure", "Linux", "Firewall", "VPN"], + "speciality": "Cloud & Security", + "username": "dmitry_sys" + }, + { + "avatar": "👩‍💼", + "description": "Менеджер проектов с опытом ведения кросс‑функциональных команд в IT‑сфере.", + "joinDate": "2023-08-15T14:12:00Z", + "motto": "Сроки — святое, но качество — важнее.", + "name": "Анна Кузнецова", + "password": "TestPas1205", + "projects": ["ERP Implementation", "Agile Transformation"], + "role": "Project Manager", + "skills": ["Agile", "Scrum", "Jira", "Stakeholder Management"], + "speciality": "Project Management", + "username": "anna_pm" + }, + { + "avatar": "🤖", + "description": "Специалист по машинному обучению и анализу данных. Работает с NLP и компьютерным зрением.", + "joinDate": "2024-03-10T16:34:00Z", + "motto": "Данные — новая нефть, а ML — двигатель.", + "name": "Иван Морозов", + "password": "TestPas1205", + "projects": ["Chatbot Development", "Image Recognition System"], + "role": "ML Engineer", + "skills": ["Python", "TensorFlow", "PyTorch", "NLP", "Data Visualization"], + "speciality": "Machine Learning", + "username": "ivan_ml" + }, + { + "avatar": "🎨", + "description": "Графический дизайнер, создаёт брендовые стили и маркетинговые материалы.", + "joinDate": "2023-06-22T12:44:00Z", + "motto": "Каждый пиксель имеет значение.", + "name": "Елена Волкова", + "password": "TestPas1205", + "projects": ["Brand Identity", "Social Media Graphics"], + "role": "Graphic Designer", + "skills": ["Photoshop", "Illustrator", "InDesign", "Branding"], + "speciality": "Graphic Design", + "username": "elena_art" + }, + { + "avatar": "📊", + "description": "Аналитик данных с опытом работы в BI‑инструментах и SQL.", + "joinDate": "2022-09-05T14:32:00Z", + "motto": "Факты говорят громче слов.", + "name": "Сергей Новиков", + "password": "TestPas1205", + "projects": ["Sales Dashboard", "Customer Churn Analysis"], + "role": "Data Analyst", + "skills": ["SQL", "Power BI", "Excel", "Tableau", "Python"], + "speciality": "Data Analytics", + "username": "sergey_data" + }, + { + "avatar": "📱", + "description": "Мобильный разработчик под iOS и Android. Специализируется на кросс‑платформенных решениях.", + "joinDate": "2024-02-18T18:23:00Z", + "motto": "Приложения — мосты между людьми и технологиями.", + "name": "Ольга Фёдорова", + "password": "TestPas1205", + "projects": ["Fitness App", "Delivery Service"], + "role": "Mobile Developer", + "skills": ["Swift", "Kotlin", "Flutter", "React Native"], + "speciality": "Mobile Development", + "username": "olga_mobile" + }, + { + "avatar": "🔍", + "description": "QA‑инженер с фокусом на автоматизированное тестирование и CI/CD.", + "joinDate": "2023-10-01T20:00:00Z", + "motto": "Тестирование — это поиск истины.", + "name": "Андрей Васильев", + "password": "TestPas1205", + "projects": ["Web App Testing", "API Automation"], + "role": "QA Engineer", + "skills": ["Selenium", "JUnit", "Postman", "CI/CD", "TestRail"], + "speciality": "Quality Assurance", + "username": "andrey_qa" + }, + { + "avatar": "📚", + "description": "Технический писатель, создаёт документацию и гайды для сложных систем.", + "joinDate": "2024-04-14T16:43:00Z", + "motto": "Ясность — ключ к пониманию.", + "name": "Наталья Смирнова", + "password": "TestPas1205", + "projects": ["API Documentation", "User Manual for ERP"], + "role": "Technical Writer", + "skills": ["Markdown", "Confluence", "Diagrams", "API Docs"], + "speciality": "Technical Documentation", + "username": "natalia_docs" + } +] diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/data_fixtures.py b/tests/fixtures/data_fixtures.py new file mode 100644 index 0000000..fd50f3a --- /dev/null +++ b/tests/fixtures/data_fixtures.py @@ -0,0 +1,24 @@ +import json +from pathlib import Path +import pytest + +def _load_data(filename: str): + current_file = Path(__file__).resolve() + data_path = current_file.parent.parent.joinpath("data") + data_file_path = data_path.joinpath(filename) + with open(data_file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + return data + +@pytest.fixture(scope="session") +def api_user_data(): + return _load_data('api_users.json') + +@pytest.fixture(scope="session") +def api_post_data(): + return _load_data('api_posts.json') + +@pytest.fixture(scope="session") +def api_user_auth_data(): + return _load_data('api_user_auth.json') diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..5c587a5 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,15 @@ +[pytest] +testpaths = tests +pythonpath = ../ +addopts = + -v + --strict-markers + --alluredir=reports/allure-results/ + --clean-alluredir + -p no:warnings +markers = + smoke: Smoke tests + regression: Regression tests + api: API tests + ui: UI tests + slow: Slow running tests diff --git a/tests/ui/__init__.py b/tests/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py new file mode 100644 index 0000000..b358efe --- /dev/null +++ b/tests/ui/conftest.py @@ -0,0 +1,130 @@ +import logging +import allure +import pytest +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.support.ui import WebDriverWait +from datetime import datetime + +from config.ui_config import UIConfig +from tests.api.utils.api_client import APIClient +from utils import waiters + +from api.utils.api_client import APIClient +from api.conftest import * +from fixtures.data_fixtures import * + +logger = logging.getLogger(__name__) + +@pytest.fixture +def driver(request): + """Фикстура для браузера""" + logger.info("=" * 50) + logger.info("Initializing webdriver") + + headless = UIConfig.BROWSER_HEADLESS + browser_name = UIConfig.BROWSER_NAME + fullscreen = UIConfig.BROWSER_FULLSCREEN + + logger.info(f"Headless: {headless}") + logger.info("Browser: {browser_name}") + + logger.info("=" * 50) + + if browser_name == "chrome": + options = Options() + if headless: + options.add_argument("--headless") + if fullscreen: + options.add_argument("--start-maximized") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--window-size=1920,1080") + + driver = webdriver.Chrome( + options=options + ) + else: + options = webdriver.FirefoxOptions() + if headless: + options.add_argument("--headless") + if fullscreen: + options.add_argument("--kiosk") + options.add_argument("--width=1920") + options.add_argument("--height=1080") + driver = webdriver.Firefox( + options=options + ) + + driver.implicitly_wait(10) + driver.maximize_window() + + waiters.waiter = WebDriverWait(driver, 10) + + def fin(): + if request.node.rep_call.failed: + try: + # Делаем скриншот при падении теста + screenshot = driver.get_screenshot_as_png() + allure.attach( + screenshot, + name=f"screenshot_{datetime.now().strftime('%Y%m%d_%H%M%S')}", + attachment_type=allure.attachment_type.PNG + ) + + # Получаем текущий URL + current_url = driver.current_url + allure.attach( + current_url, + name="current_url", + attachment_type=allure.attachment_type.TEXT + ) + + # Получаем исходный код страницы + page_source = driver.page_source + allure.attach( + page_source, + name="page_source", + attachment_type=allure.attachment_type.HTML + ) + except: + pass + driver.quit() + + request.addfinalizer(fin) + + yield driver + driver.quit() + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + """ + Хук для получения результатов теста + """ + outcome = yield + rep = outcome.get_result() + setattr(item, "rep_" + rep.when, rep) + +@pytest.fixture(scope="function") +def gen_data_fixture(auth_admin: APIClient, api_user_data, api_post_data): + with allure.step("Generating data"): + id = '' + user = api_user_data[0] + resp = auth_admin.create_user(user) + assert resp.status_code == 201 + id = resp.json()['id'] + + post = api_post_data[0] + post['userId'] = id + auth_admin.create_post(post) + yield + + with allure.step("Deleting generated data"): + posts = auth_admin.get_all_posts().json() + users = auth_admin.get_all_users().json() + + for post in posts: + auth_admin.delete_post(post['id']) + + for user in users: + auth_admin.delete_user(user['id']) diff --git a/tests/ui/pages/__init__.py b/tests/ui/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ui/pages/admin_page.py b/tests/ui/pages/admin_page.py new file mode 100644 index 0000000..f1c4608 --- /dev/null +++ b/tests/ui/pages/admin_page.py @@ -0,0 +1,134 @@ +import allure +from selenium.webdriver.chrome.webdriver import WebDriver +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from tests.ui.pages.base_page import BasePage + + +class AdminPage(BasePage): + # Локаторы + + # Логин + LOGIN_BLOCK = (By.XPATH, "//div[contains(@id, 'operations-user-post_login')]//button[contains(@class, 'opblock-summary-control')]") + LOGIN_TRYITOUT_BTN = (By.XPATH, "//div[contains(@id, 'operations-user-post_login')]//button[contains(@class, 'try-out__btn')]") + LOGIN_TEXTAREA = (By.XPATH, "//div[contains(@id, 'operations-user-post_login')]//textarea[contains(@class, 'body-param__text')]") + LOGIN_EXECUTE_BTN = (By.XPATH, "//div[contains(@id, 'operations-user-post_login')]//button[contains(@class, 'execute')]") + LOGIN_STATUS_CODE = (By.XPATH, "//div[contains(@id, 'operations-user-post_login')]//table[contains(@class, 'live-responses-table')]//tbody//td[contains(@class, 'response-col_status')]") + + # Пользователь + USER_BLOCK = (By.XPATH, "//div[contains(@id, 'operations-user-post_team_')]//button[contains(@class, 'opblock-summary-control')]") + USER_TRYITOUT_BTN = (By.XPATH, "//div[contains(@id, 'operations-user-post_team_')]//button[contains(@class, 'try-out__btn')]") + USER_TEXTAREA = (By.XPATH, "//div[contains(@id, 'operations-user-post_team_')]//textarea[contains(@class, 'body-param__text')]") + USER_EXECUTE_BTN = (By.XPATH, "//div[contains(@id, 'operations-user-post_team_')]//button[contains(@class, 'execute')]") + USER_RESPONSE = (By.XPATH, "//div[contains(@id, 'operations-user-post_team_')]//table[contains(@class, 'live-responses-table')]//tbody//td[contains(@class, 'response-col_description')]//code") + USER_STATUS_CODE = (By.XPATH, "//div[contains(@id, 'operations-user-post_team_')]//table[contains(@class, 'live-responses-table')]//tbody//td[contains(@class, 'response-col_status')]") + + # Данные для входа + LOGIN_DATA = [Keys.BACKSPACE, Keys.ENTER, '\t"password": "Abc1205",', Keys.ENTER, '\t"username": "muts"', Keys.ENTER, '}'] + USER_RAW_DATA = """ + "avatar": "🔍", + "description": "QA‑инженер с фокусом на автоматизированное тестирование и CI/CD.", + "joinDate": "2023-10-01T20:00:00Z", + "motto": "Тестирование — это поиск истины.", + "name": "Андрей Васильев", + "password": "TestPas1205", + "projects": ["Web App Testing", "API Automation"], + "role": "QA Engineer", + "skills": ["Selenium", "JUnit", "Postman", "CI/CD", "TestRail"], + "speciality": "Good user", + "username": "andrey_user" + } + """ + USER_DATA = [Keys.BACKSPACE, USER_RAW_DATA] + + def __init__(self, driver: WebDriver): + super().__init__(driver) + + @allure.step('Открытие страницы администратора') + def open_admin_page(self): + self.open('http://localhost:8080/swagger/index.html') + + @allure.step('Проверка наличия блока логина') + def is_login_block_visible(self): + return self.is_visible(self.LOGIN_BLOCK) + + @allure.step('Проверка наличия кнопки "try it out"') + def is_login_tryitout_btn_visible(self): + return self.is_visible(self.LOGIN_TRYITOUT_BTN) + + @allure.step('Проверка наличия поля ввода') + def is_login_textarea_visible(self): + return self.is_visible(self.LOGIN_TEXTAREA) + + @allure.step('Проверка наличия кнопки выполнения запроса') + def is_login_execute_btn_visible(self): + return self.is_visible(self.LOGIN_EXECUTE_BTN) + + @allure.step('Нажатие на блок логина') + def click_login_block(self): + self.click(self.LOGIN_BLOCK) + + @allure.step('Нажатие на кнопку "try it out"') + def click_login_tryitout_btn(self): + self.click(self.LOGIN_TRYITOUT_BTN) + + @allure.step('Ввод текста в поле логина') + def enter_text_to_login_textarea(self, text: list): + field = self.find_element(self.LOGIN_TEXTAREA) + field.clear() + + for event in text: + field.send_keys(event) + + @allure.step('Нажатие на кнопку выполнения запроса') + def click_login_execute_btn(self): + self.click(self.LOGIN_EXECUTE_BTN) + + @allure.step('Получение статуса выполнения входа') + def get_login_status(self): + return self.get_text(self.LOGIN_STATUS_CODE) + + @allure.step('Проверка наличия блока пользователя') + def is_user_block_visible(self): + return self.is_visible(self.USER_BLOCK) + + @allure.step('Проверка наличия кнопки "try it out"') + def is_user_tryitout_btn_visible(self): + return self.is_visible(self.USER_TRYITOUT_BTN) + + @allure.step('Проверка наличия поля ввода') + def is_user_textarea_visible(self): + return self.is_visible(self.USER_TEXTAREA) + + @allure.step('Проверка наличия кнопки выполнения запроса') + def is_user_execute_btn_visible(self): + return self.is_visible(self.USER_EXECUTE_BTN) + + @allure.step('Нажатие на блок пользователя') + def click_user_block(self): + self.click(self.USER_BLOCK) + + @allure.step('Нажатие на кнопку "try it out"') + def click_user_tryitout_btn(self): + self.click(self.USER_TRYITOUT_BTN) + + @allure.step('Ввод текста в поле добавления пользователя') + def enter_text_to_user_textarea(self, text: list): + field = self.find_element(self.USER_TEXTAREA) + field.clear() + + for event in text: + field.send_keys(event) + + @allure.step('Нажатие на кнопку выполнения запроса') + def click_user_execute_btn(self): + self.click(self.USER_EXECUTE_BTN) + + @allure.step('Получение ответа на запрос создания пользователя') + def get_user_response(self): + elem = self.find_element(self.USER_RESPONSE) + return elem.get_attribute('innerText') + + @allure.step('Получение статуса создания пользователя') + def get_user_status(self): + return self.get_text(self.USER_STATUS_CODE) diff --git a/tests/ui/pages/base_page.py b/tests/ui/pages/base_page.py new file mode 100644 index 0000000..71bffcf --- /dev/null +++ b/tests/ui/pages/base_page.py @@ -0,0 +1,99 @@ +from selenium.webdriver import ActionChains +from selenium.webdriver.chrome.webdriver import WebDriver +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.common.exceptions import TimeoutException, NoSuchElementException +import allure +import logging + +logger = logging.getLogger(__name__) + + +class BasePage: + def __init__(self, driver: WebDriver): + self.driver = driver + self.wait = WebDriverWait(driver, 10) + self.logger = logger + + @allure.step("Открыть URL: {url}") + def open(self, url): + self.driver.get(url) + self.logger.info(f"Открыта страница: {url}") + + @allure.step("Обновить страницу") + def refresh(self): + self.driver.refresh() + self.logger.info("Страница обновлена") + + @allure.step("Найти элемент: {locator}") + def find_element(self, locator): + try: + element = self.wait.until(EC.visibility_of_element_located(locator)) + self.logger.debug(f"Элемент найден: {locator}") + return element + except TimeoutException: + self.logger.error(f"Элемент не найден: {locator}") + allure.attach( + self.driver.get_screenshot_as_png(), + name="element_not_found", + attachment_type=allure.attachment_type.PNG + ) + raise + + @allure.step("Кликнуть на элемент: {locator}") + def click(self, locator): + element = self.find_element(locator) + element.click() + self.logger.info(f"Клик по элементу: {locator}") + + @allure.step("Ввести текст '{text}' в элемент: {locator}") + def type_text(self, locator, text): + element = self.find_element(locator) + element.clear() + element.send_keys(text) + self.logger.info(f"Введен текст '{text}' в элемент: {locator}") + + @allure.step("Получить текст элемента: {locator}") + def get_text(self, locator): + element = self.find_element(locator) + text = element.text + self.logger.info(f"Получен текст '{text}' из элемента: {locator}") + return text + + @allure.step("Проверить, что элемент видим: {locator}") + def is_visible(self, locator): + try: + element = self.find_element(locator) + is_displayed = element.is_displayed() + self.logger.info(f"Элемент {locator} видим: {is_displayed}") + return is_displayed + except (TimeoutException, NoSuchElementException): + return False + + @allure.step("Получить текущий URL") + def get_current_url(self): + url = self.driver.current_url + self.logger.info(f"Текущий URL: {url}") + return url + + @allure.step("Сделать скриншот") + def take_screenshot(self, name="screenshot"): + screenshot = self.driver.get_screenshot_as_png() + allure.attach( + screenshot, + name=name, + attachment_type=allure.attachment_type.PNG + ) + self.logger.info(f"Скриншот сохранен: {name}") + + @allure.step("Ожидание загрузки элемента: {locator}") + def wait_for_element(self, locator, timeout=10): + wait = WebDriverWait(self.driver, timeout) + element = wait.until(EC.visibility_of_element_located(locator)) + self.logger.info(f"Элемент загружен: {locator}") + return element + + @allure.step("Скролл до элемента") + def scroll_to_element(self, locator): + element = self.find_element(locator) + self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element) diff --git a/tests/ui/pages/home_page.py b/tests/ui/pages/home_page.py new file mode 100644 index 0000000..f19737c --- /dev/null +++ b/tests/ui/pages/home_page.py @@ -0,0 +1,92 @@ +import allure +from selenium.webdriver.common.by import By +from config.ui_config import UIConfig +from tests.ui.pages.base_page import BasePage + + +class HomePage(BasePage): + # Локаторы + LOGO = (By.CLASS_NAME, "logo-text") + TEAM_TITLE = (By.XPATH, "//div[contains(@class, 'team-panel')]//h3") + ARTICLE_TITLE = (By.XPATH, "//div[contains(@class, 'blog-panel')]//h3") + TIME = (By.ID, "current-time") + TEAM_SECTION = (By.CLASS_NAME, "team-carousel") + ARTICLE_SECTION = (By.CLASS_NAME, "articles-list") + TEAM_NOT_FOUND = (By.XPATH, "//div[contains(@class, 'team-carousel')]//div[contains(@class, 'empty-state')]/span[not(contains(@class, 'empty-icon'))]") + ARTICLE_NOT_FOUND = (By.XPATH, "//div[contains(@class, 'articles-list')]//div[contains(@class, 'empty-state')]/span[not(contains(@class, 'empty-icon'))]") + AUTHOR_CARD = (By.CLASS_NAME, "team-member-card") + ARTICLE_CARD = (By.CLASS_NAME, "article-preview") + TEAM_VIEW_ALL = (By.XPATH, "//div[contains(@class, 'team-panel')]//a[contains(@class, 'view-all')]") + ARTICLE_VIEW_ALL = (By.XPATH, "//div[contains(@class, 'blog-panel')]//a[contains(@class, 'view-all')]") + + def __init__(self, driver): + super().__init__(driver) + + @allure.step("Открытие домашней страницы") + def open_home_page(self): + self.open(UIConfig.UI_BASE_URL) + + @allure.step("Проверка большого лого") + def check_home_logo(self): + text = self.get_text(self.LOGO) + assert text == "Team" + return text + + @allure.step("Проверка заголовка списка команды") + def check_home_team(self): + text = self.get_text(self.TEAM_TITLE) + assert text == "MEET THE TEAM" + return text + + @allure.step("Проверка заголовка списка постов") + def check_home_posts(self): + text = self.get_text(self.ARTICLE_TITLE) + assert text == "LATEST ARTICLES" + return text + + @allure.step("Проверить наличие раздела 'Meet the Team'") + def is_meet_the_team_section_displayed(self): + return self.is_visible(self.TEAM_SECTION) + + @allure.step("Проверить наличие раздела 'Latest Articles'") + def is_latest_articles_section_displayed(self): + return self.is_visible(self.ARTICLE_SECTION) + + @allure.step("Проверить сообщение 'No team members found'") + def check_no_team_members_message(self): + message = self.get_text(self.TEAM_NOT_FOUND) + assert "No team members found" in message, \ + f"Ожидалось сообщение 'No team members found', получено '{message}'" + return message + + @allure.step("Проверить сообщение 'No articles found'") + def check_no_articles_message(self): + message = self.get_text(self.ARTICLE_NOT_FOUND) + assert "No articles found" in message, \ + f"Ожидалось сообщение 'No articles found', получено '{message}'" + return message + + @allure.step("Проверить наличие карточки автора") + def is_member_card_displayed(self): + return self.is_visible(self.AUTHOR_CARD) + + @allure.step("Проверить наличие карточки статьи") + def is_article_card_displayed(self): + return self.is_visible(self.ARTICLE_CARD) + + @allure.step("Проверить видимость кнопки VIEW ALL TEAM") + def is_team_view_all_displayed(self): + return self.is_visible(self.TEAM_VIEW_ALL) + + @allure.step("Проверить видимость кнопки VIEW ALL TEAM") + def is_article_view_all_displayed(self): + return self.is_visible(self.ARTICLE_VIEW_ALL) + + @allure.step("Проверить кликабельность кнопки VIEW ALL TEAM") + def click_view_all_team_button(self): + self.click(self.TEAM_VIEW_ALL) + + @allure.step("Проверить кликабельность кнопки VIEW ALL TEAM") + def click_view_all_article_button(self): + self.click(self.ARTICLE_VIEW_ALL) + diff --git a/tests/ui/test_admin_page.py b/tests/ui/test_admin_page.py new file mode 100644 index 0000000..b8d0462 --- /dev/null +++ b/tests/ui/test_admin_page.py @@ -0,0 +1,158 @@ + +import json +import time +import allure +import pytest +from selenium.webdriver.chrome.webdriver import WebDriver + +from config.ui_config import UIConfig +from tests.api.utils.api_client import APIClient +from tests.ui.pages.admin_page import AdminPage + + +@allure.epic("Страница администратора") +@allure.feature("Основной функционал") +class TestAdminPage: + @allure.story("Загрузка страницы") + @allure.title("Проверка успешной загрузки страницы") + @allure.severity(allure.severity_level.BLOCKER) + @pytest.mark.smoke + @pytest.mark.ui + def test_page_load_succesfull(self, driver): + admin_page = AdminPage(driver) + + with allure.step('1. Открыть страницу'): + admin_page.open_admin_page() + admin_page.take_screenshot("admin_page") + + @allure.story("Структура страницы") + @allure.title("Проверка отображения элементов") + @allure.severity(allure.severity_level.BLOCKER) + @pytest.mark.smoke + @pytest.mark.ui + def test_page_elements_visiblity(self, driver): + admin_page = AdminPage(driver) + + with allure.step('1. Открыть страницу'): + admin_page.open_admin_page() + admin_page.take_screenshot("admin_page") + + with allure.step('2. Проверка наличия кнопки блока логина'): + assert admin_page.is_login_block_visible(), 'Кнопка блока логина не отобразилась' + + with allure.step('3. Проверка невидимости кнопки "try it out"'): + assert not admin_page.is_login_tryitout_btn_visible(), 'Кнопка не должна быть видима' + + with allure.step('4. Проверка невидимости поля ввода'): + assert not admin_page.is_login_textarea_visible(), 'Поле ввода не должно быть видимо' + + with allure.step('5. Проверка невидимости кнопки выполнения запроса'): + assert not admin_page.is_login_execute_btn_visible(), 'Кнопка выполнения запроса не должна быть видимой' + + @allure.story("Функциональность") + @allure.title("Проверка функциональности страницы") + @allure.severity(allure.severity_level.BLOCKER) + @pytest.mark.smoke + @pytest.mark.ui + def test_page_login_process(self, driver: WebDriver): + admin_page = AdminPage(driver) + + with allure.step('1. Открыть страницу'): + admin_page.open_admin_page() + admin_page.wait_for_element(admin_page.LOGIN_BLOCK) + admin_page.scroll_to_element(admin_page.LOGIN_BLOCK) + admin_page.take_screenshot("admin_page") + + with allure.step('2. Нажать на блок логина'): + admin_page.click_login_block() + assert admin_page.is_login_tryitout_btn_visible(), 'Кнопка "try it out" должна быть видимой' + + with allure.step('3. Нажать на кнопку "try it out"'): + admin_page.click_login_tryitout_btn() + assert admin_page.is_login_textarea_visible(), 'Поле ввода должно быть видимым' + assert admin_page.is_login_execute_btn_visible(), 'Кнопка выполнения запроса должна быть видимой' + admin_page.scroll_to_element(admin_page.LOGIN_TRYITOUT_BTN) + admin_page.take_screenshot('admin_page_login_block') + + with allure.step('4. Ввод данных для входа'): + admin_page.scroll_to_element(admin_page.LOGIN_TEXTAREA) + admin_page.take_screenshot("admin_page_login_area_unchanged") + admin_page.enter_text_to_login_textarea(admin_page.LOGIN_DATA) + time.sleep(0.5) + admin_page.take_screenshot("admin_page_login_text_entered") + + with allure.step('5. Выполнение запроса'): + admin_page.click_login_execute_btn() + time.sleep(2.0) + admin_page.scroll_to_element(admin_page.LOGIN_STATUS_CODE) + admin_page.take_screenshot("admin_page_login_request_executed") + + with allure.step('6. Проверка статуса выполнения запроса'): + admin_page.scroll_to_element(admin_page.LOGIN_STATUS_CODE) + assert int(admin_page.get_login_status()) == 200 + + @allure.story("Добавление пользователя") + @allure.title("Проверка функции добавления пользователя") + @allure.severity(allure.severity_level.BLOCKER) + @pytest.mark.smoke + @pytest.mark.ui + def test_page_user_adding(self, driver: WebDriver, auth_admin: APIClient): + admin_page = AdminPage(driver) + user_id = '' + + with allure.step('1. Открыть страницу'): + admin_page.open_admin_page() + admin_page.wait_for_element(admin_page.USER_BLOCK) + admin_page.scroll_to_element(admin_page.USER_BLOCK) + admin_page.take_screenshot("admin_page") + + with allure.step("2. Авторизоваться"): + admin_page.click_login_block() + admin_page.click_login_tryitout_btn() + admin_page.enter_text_to_login_textarea(admin_page.LOGIN_DATA) + time.sleep(0.5) + admin_page.click_login_execute_btn() + time.sleep(2.0) + admin_page.scroll_to_element(admin_page.LOGIN_STATUS_CODE) + admin_page.take_screenshot("admin_page_login_request_executed") + + with allure.step('3. Нажать на блок пользователя'): + admin_page.click_user_block() + assert admin_page.is_user_tryitout_btn_visible(), 'Кнопка "try it out" должна быть видимой' + + with allure.step('4. Нажать на кнопку "try it out"'): + admin_page.click_user_tryitout_btn() + assert admin_page.is_user_textarea_visible(), 'Поле ввода должно быть видимым' + assert admin_page.is_user_execute_btn_visible(), 'Кнопка выполнения запроса должна быть видимой' + admin_page.scroll_to_element(admin_page.USER_TRYITOUT_BTN) + admin_page.take_screenshot('admin_page_user_block') + + with allure.step('5. Ввод данных для регистрации пользователя'): + admin_page.scroll_to_element(admin_page.USER_TEXTAREA) + admin_page.take_screenshot("admin_page_user_area_unchanged") + admin_page.enter_text_to_user_textarea(admin_page.USER_DATA) + time.sleep(0.5) + admin_page.take_screenshot("admin_page_user_text_entered") + + with allure.step('6. Выполнение запроса'): + admin_page.click_user_execute_btn() + time.sleep(2.0) + admin_page.scroll_to_element(admin_page.USER_STATUS_CODE) + admin_page.take_screenshot("admin_page_user_request_executed") + + with allure.step('7. Проверка статуса выполнения запроса'): + admin_page.scroll_to_element(admin_page.LOGIN_STATUS_CODE) + assert int(admin_page.get_login_status()) == 200 + + with allure.step('8. Сохраняем id пользователя для удаления'): + content = admin_page.get_user_response() + user_id = json.loads(content)['id'] + + with allure.step('9. Проверка, что пользователь добавлен на сайт'): + admin_page.open(UIConfig.get_url('team')) + time.sleep(2) + admin_page.take_screenshot('team_page_user') + + with allure.step('10. Удаляем пользователя через API'): + auth_admin.delete_user(user_id) + diff --git a/tests/ui/test_home_page.py b/tests/ui/test_home_page.py new file mode 100644 index 0000000..9e24e08 --- /dev/null +++ b/tests/ui/test_home_page.py @@ -0,0 +1,224 @@ +import pytest +import allure +from selenium.webdriver.chrome.webdriver import WebDriver +from config.ui_config import UIConfig +import time + +from tests.ui.pages.home_page import HomePage + + +@allure.epic("Домашняя страница") +@allure.feature("Основной функционал") +class TestTeamPage: + + @allure.story("Загрузка страницы") + @allure.title("Проверка успешной загрузки страницы") + @allure.severity(allure.severity_level.BLOCKER) + @pytest.mark.smoke + @pytest.mark.ui + def test_page_load_successfully(self, driver): + """ + Тест проверяет успешную загрузку страницы Team + """ + home_page = HomePage(driver) + + with allure.step("1. Открыть страницу"): + home_page.open_home_page() + home_page.take_screenshot("home_page_loaded") + + with allure.step("2. Проверить заголовок окна браузера"): + page_title = driver.title + allure.attach(page_title, name="page_title", attachment_type=allure.attachment_type.TEXT) + assert page_title, "Заголовок страницы пустой" + + @allure.story("Контент страницы") + @allure.title("Проверка текстового содержания страницы") + @allure.severity(allure.severity_level.CRITICAL) + @pytest.mark.regression + def test_page_content_validation(self, driver): + """ + Тест проверяет корректность текстового содержания страницы + """ + home_page = HomePage(driver) + + with allure.step("1. Открыть страницу"): + home_page.open_home_page() + + with allure.step("2. Проверить заголовок"): + title = home_page.get_text(home_page.LOGO) + assert title == "Team", f"Неверный заголовок: {title}" + + + with allure.step("4. Проверить сообщения о пустом состоянии"): + team_message = home_page.check_no_team_members_message() + articles_message = home_page.check_no_articles_message() + + allure.attach( + f"Сообщение о команде: {team_message}\nСообщение о статьях: {articles_message}", + name="empty_state_messages", + attachment_type=allure.attachment_type.TEXT + ) + + with allure.step("5. Сделать скриншот всей страницы"): + driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") + time.sleep(1) + home_page.take_screenshot("full_page_content") + + @allure.story("Структура страницы") + @allure.title("Проверка структуры и порядка элементов") + @allure.severity(allure.severity_level.NORMAL) + @pytest.mark.ui + def test_page_structure(self, driver): + """ + Тест проверяет правильность структуры расположения элементов + """ + home_page = HomePage(driver) + + with allure.step("1. Открыть страницу Team"): + home_page.open_home_page() + + with allure.step("2. Проверить наличие всех разделов"): + assert home_page.is_meet_the_team_section_displayed(), \ + "Раздел 'MEET THE TEAM' не отображается" + assert home_page.is_latest_articles_section_displayed(), \ + "Раздел 'LATEST ARTICLES' не отображается" + + with allure.step("3. Проверить кнопку VIEW ALL team"): + button = home_page.is_team_view_all_displayed() + assert button, "Кнопка не отобразилась" + + with allure.step("4. Проверить кнопку VIEW ALL article"): + button = home_page.is_article_view_all_displayed() + assert button, "Кнопка не отобразилась" + + @allure.story("Функциональность") + @allure.title("Проверка кликабельности элементов") + @allure.severity(allure.severity_level.NORMAL) + @pytest.mark.regression + def test_elements_interactivity(self, driver): + """ + Тест проверяет кликабельность интерактивных элементов + """ + home_page = HomePage(driver) + + with allure.step("1. Открыть страницу Team"): + home_page.open_home_page() + + with allure.step("2. Проверить кликабельность кнопки VIEW ALL team"): + button_team = home_page.find_element(home_page.TEAM_VIEW_ALL) + assert button_team.is_enabled(), "Кнопка VIEW ALL не активна" + + # Проверяем, что это ссылка + tag_name = button_team.tag_name + assert tag_name.lower() in ['a', 'button'], \ + f"Элемент не является ссылкой или кнопкой (тег: {tag_name})" + + with allure.step("3. Проверить кликабельность кнопки VIEW ALL article"): + button_article = home_page.find_element(home_page.ARTICLE_VIEW_ALL) + assert button_article.is_enabled(), "Кнопка VIEW ALL не активна" + + # Проверяем, что это ссылка + tag_name = button_article.tag_name + assert tag_name.lower() in ['a', 'button'], \ + f"Элемент не является ссылкой или кнопкой (тег: {tag_name})" + + + with allure.step("4. Навести курсор на кнопку VIEW ALL team"): + from selenium.webdriver.common.action_chains import ActionChains + actions = ActionChains(driver) + actions.move_to_element(button_team).perform() + time.sleep(0.5) + + # Проверяем изменение стиля (опционально) + home_page.take_screenshot("team_view_all_button_hover") + + with allure.step("4. Навести курсор на кнопку VIEW ALL article"): + from selenium.webdriver.common.action_chains import ActionChains + actions = ActionChains(driver) + actions.move_to_element(button_article).perform() + time.sleep(0.5) + + # Проверяем изменение стиля (опционально) + home_page.take_screenshot("article_view_all_button_hover") + + + @allure.story("Навигация") + @allure.title("Проверка поведения кнопки VIEW ALL") + @allure.severity(allure.severity_level.CRITICAL) + @pytest.mark.smoke + def test_view_all_button_functionality(self, driver: WebDriver): + """ + Тест проверяет функциональность кнопки VIEW ALL + """ + home_page = HomePage(driver) + + with allure.step("1. Открыть страницу Team"): + home_page.open_home_page() + initial_url = driver.current_url + allure.attach(initial_url, name="initial_url", attachment_type=allure.attachment_type.TEXT) + + with allure.step("2. Нажать кнопку VIEW ALL team"): + home_page.click_view_all_team_button() + time.sleep(2) # Ждем загрузки + + new_url = driver.current_url + allure.attach(new_url, name="new_url", attachment_type=allure.attachment_type.TEXT) + + with allure.step("3. Проверить изменение URL"): + # Проверяем, что URL изменился (или остался тем же, если это anchor link) + assert new_url == UIConfig.get_url("team"), "Совершен переход на неверную страницу" + + with allure.step("4. Возвращаемся на главную"): + driver.back() + + with allure.step("2. Нажать кнопку VIEW ALL article"): + home_page.click_view_all_article_button() + time.sleep(2) # Ждем загрузки + + new_url = driver.current_url + allure.attach(new_url, name="new_url", attachment_type=allure.attachment_type.TEXT) + + with allure.step("3. Проверить изменение URL"): + # Проверяем, что URL изменился (или остался тем же, если это anchor link) + assert new_url == UIConfig.get_url("blog"), "Совершен переход на неверную страницу" + + @allure.story("Контент страницы") + @allure.title("Проверка появления карточек") + @allure.severity(allure.severity_level.CRITICAL) + @pytest.mark.regression + def test_cards_visiblity(self, driver, gen_data_fixture): + """ + Тест проверяет появление предложенных карточек + """ + home_page = HomePage(driver) + + with allure.step("1. Открыть страницу"): + home_page.open_home_page() + + with allure.step("2. Проверка существования карточки автора"): + card = home_page.is_member_card_displayed() + assert card, "Карточка пользователя не отобразилась" + + with allure.step("3. Проверка реакции карточки на наведение"): + from selenium.webdriver.common.action_chains import ActionChains + member_card = home_page.find_element(home_page.AUTHOR_CARD) + actions = ActionChains(driver) + actions.move_to_element(member_card).perform() + time.sleep(0.5) + + home_page.take_screenshot("Member_card_hower_reaction") + + with allure.step("4. Проверка существования карточки статьи"): + card = home_page.is_article_card_displayed() + assert card, "Карточка статьи не отобразилась" + + with allure.step("5. Проверка реакции карточки на наведение"): + from selenium.webdriver.common.action_chains import ActionChains + member_card = home_page.find_element(home_page.ARTICLE_CARD) + actions = ActionChains(driver) + actions.move_to_element(member_card).perform() + time.sleep(0.5) + + home_page.take_screenshot("Member_card_hower_reaction") + + diff --git a/tests/ui/utils/__init__.py b/tests/ui/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ui/utils/wait.py b/tests/ui/utils/wait.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/__pycache__/logger.cpython-313.pyc b/utils/__pycache__/logger.cpython-313.pyc new file mode 100644 index 0000000..92a1fbc Binary files /dev/null and b/utils/__pycache__/logger.cpython-313.pyc differ diff --git a/utils/__pycache__/waiters.cpython-313.pyc b/utils/__pycache__/waiters.cpython-313.pyc new file mode 100644 index 0000000..10eb559 Binary files /dev/null and b/utils/__pycache__/waiters.cpython-313.pyc differ diff --git a/utils/allure_helpers.py b/utils/allure_helpers.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..946dc08 --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,398 @@ +import logging +import sys +import os +from pathlib import Path +from typing import Optional, Dict, Any, Union +from datetime import datetime +import json +import traceback +import inspect + +# Цвета для консольного вывода (только если поддерживается) +from colorama import init, Fore, Back, Style +init(autoreset=True) + +class TestLogger: + """Кастомный логгер для тестов с поддержкой Allure и консольного вывода""" + + # Уровни логирования с цветами + LEVEL_COLORS = { + 'DEBUG': Fore.CYAN, + 'INFO': Fore.GREEN, + 'WARNING': Fore.YELLOW, + 'ERROR': Fore.RED, + 'CRITICAL': Fore.RED + Back.WHITE, + } + + # Уровни логирования для Allure + ALLURE_LEVELS = { + 'DEBUG': 'debug', + 'INFO': 'info', + 'WARNING': 'warning', + 'ERROR': 'error', + 'CRITICAL': 'critical', + } + + _instances = {} # Кэш инстансов логгеров + + def __init__(self, name: str, level: str = 'INFO', + log_to_file: bool = True, + log_to_console: bool = True, + log_to_allure: bool = True): + """ + Инициализация логгера + + Args: + name: Имя логгера (обычно __name__) + level: Уровень логирования (DEBUG, INFO, WARNING, ERROR, CRITICAL) + log_to_file: Логировать в файл + log_to_console: Логировать в консоль + log_to_allure: Логировать в Allure отчет + """ + self.name = name + self.log_to_file = log_to_file + self.log_to_console = log_to_console + self.log_to_allure = log_to_allure + + # Создаем стандартный логгер + self.logger = logging.getLogger(name) + self.logger.setLevel(getattr(logging, level.upper())) + + # Удаляем существующие обработчики + self.logger.handlers.clear() + + # Форматтеры + self._setup_formatters() + + # Обработчики + self._setup_handlers() + + # Для хранения контекста теста + self.test_context = {} + + def _setup_formatters(self): + """Настройка форматтеров""" + # Подробный формат для файла + self.file_formatter = logging.Formatter( + fmt='%(asctime)s.%(msecs)03d | %(levelname)-8s | %(name)s | %(filename)s:%(lineno)d | %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + # Простой формат для консоли + self.console_formatter = logging.Formatter( + fmt='%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s', + datefmt='%H:%M:%S' + ) + + # JSON формат для машинной обработки + self.json_formatter = JSONFormatter() + + def _setup_handlers(self): + """Настройка обработчиков""" + # Консольный обработчик + if self.log_to_console: + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(self.logger.level) + console_handler.setFormatter(self.console_formatter) + + console_handler.setFormatter(ColorFormatter()) + + self.logger.addHandler(console_handler) + + # Файловый обработчик + if self.log_to_file: + self._setup_file_handler() + + def _setup_file_handler(self): + """Настройка файлового обработчика""" + from config.settings import settings + + log_file = Path(settings.LOG_FILE) + log_file.parent.mkdir(parents=True, exist_ok=True) + + file_handler = logging.FileHandler(log_file, encoding='utf-8') + file_handler.setLevel(self.logger.level) + file_handler.setFormatter(self.file_formatter) + self.logger.addHandler(file_handler) + + # JSON лог для машинной обработки + json_log_file = log_file.parent / f"{log_file.stem}_json{log_file.suffix}" + json_handler = logging.FileHandler(json_log_file, encoding='utf-8') + json_handler.setLevel(self.logger.level) + json_handler.setFormatter(self.json_formatter) + self.logger.addHandler(json_handler) + + def set_test_context(self, **kwargs): + """Установка контекста теста (имя теста, тестовые данные и т.д.)""" + self.test_context.update(kwargs) + + def clear_test_context(self): + """Очистка контекста теста""" + self.test_context.clear() + + def debug(self, message: str, **kwargs): + """Логирование на уровне DEBUG""" + self._log('DEBUG', message, **kwargs) + + def info(self, message: str, **kwargs): + """Логирование на уровне INFO""" + self._log('INFO', message, **kwargs) + + def warning(self, message: str, **kwargs): + """Логирование на уровне WARNING""" + self._log('WARNING', message, **kwargs) + + def error(self, message: str, exception: Optional[Exception] = None, **kwargs): + """Логирование на уровне ERROR""" + if exception: + message = f"{message} | Exception: {exception} | Traceback: {traceback.format_exc()}" + self._log('ERROR', message, **kwargs) + + def critical(self, message: str, **kwargs): + """Логирование на уровне CRITICAL""" + self._log('CRITICAL', message, **kwargs) + + def _log(self, level: str, message: str, **kwargs): + """Внутренний метод логирования""" + # Добавляем контекст к сообщению + if self.test_context: + context_str = ' | '.join(f"{k}: {v}" for k, v in self.test_context.items()) + message = f"{message} | Context: {context_str}" + + # Добавляем дополнительные поля + if kwargs: + extra_fields = ' | '.join(f"{k}: {v}" for k, v in kwargs.items()) + message = f"{message} | {extra_fields}" + + # Получаем информацию о вызывающем коде + curr_frame = inspect.currentframe() + lineno = None + func_name = None + filename = None + + if curr_frame and curr_frame.f_back: + frame = curr_frame.f_back.f_back + if frame: + filename = frame.f_code.co_filename + lineno = frame.f_lineno + func_name = frame.f_code.co_name + + extra = { + 'filename': Path(filename).name if filename else '-', + 'lineno': lineno, + 'func_name': func_name, + 'test_context': self.test_context.copy(), + 'timestamp': datetime.now().isoformat(), + } + + # Логируем через стандартный логгер + log_method = getattr(self.logger, level.lower()) + log_method(message, extra=extra) + + # Логируем в Allure + if self.log_to_allure: + self._log_to_allure(level, message) + + def _log_to_allure(self, level: str, message: str): + """Логирование в Allure отчет""" + try: + import allure + + allure_level = self.ALLURE_LEVELS.get(level, 'info') + getattr(allure.step, allure_level)(message) + except ImportError: + pass # Allure не установлен + except Exception as e: + self.logger.warning(f"Failed to log to Allure: {e}") + + def log_api_request(self, method: str, url: str, + headers: Optional[dict[str, Any]] = None, + body: Any = None, + response: Any = None): + """Логирование API запроса""" + log_data = { + 'type': 'API_REQUEST', + 'method': method, + 'url': url, + 'timestamp': datetime.now().isoformat(), + } + + if headers: + log_data['headers'] = {k: v for k, v in headers.items() if k.lower() != 'authorization'} + + if body: + log_data['body'] = str(body)[:500] + ('...' if len(str(body)) > 500 else '') + + if response: + log_data['response_status'] = getattr(response, 'status_code', None) + log_data['response_body'] = str(getattr(response, 'text', ''))[:500] + ('...' if len(str(getattr(response, 'text', ''))) > 500 else '') + + self.debug(f"API Request: {method} {url}", **log_data) + + def log_ui_action(self, action: str, element: Optional[str] = None, + value: Optional[str] = None, success: bool = True): + """Логирование UI действий""" + log_data = { + 'type': 'UI_ACTION', + 'action': action, + 'element': element, + 'value': value, + 'success': success, + 'timestamp': datetime.now().isoformat(), + } + + level = 'INFO' if success else 'ERROR' + message = f"UI Action: {action}" + if element: + message += f" on {element}" + if value: + message += f" with value: {value}" + + self._log(level, message, **log_data) + + def log_test_start(self, test_name: str, test_params: Optional[dict] = None): + """Логирование начала теста""" + self.set_test_context(test_name=test_name) + + log_data = { + 'type': 'TEST_START', + 'test_name': test_name, + 'timestamp': datetime.now().isoformat(), + } + + if test_params: + log_data['parameters'] = test_params + + self.info(f"🚀 Starting test: {test_name}", **log_data) + + def log_test_end(self, test_name: str, status: str, + duration: float = None, error: str = None): + """Логирование окончания теста""" + log_data = { + 'type': 'TEST_END', + 'test_name': test_name, + 'status': status, + 'timestamp': datetime.now().isoformat(), + } + + if duration is not None: + log_data['duration_seconds'] = round(duration, 2) + + if error: + log_data['error'] = error + + emoji = '✅' if status == 'PASSED' else '❌' if status == 'FAILED' else '⚠️' + self.info(f"{emoji} Test {status}: {test_name} " + f"(Duration: {duration:.2f}s)" if duration else "", **log_data) + + self.clear_test_context() + + def log_screenshot(self, screenshot_path: str, description: str = "Screenshot"): + """Логирование скриншота""" + log_data = { + 'type': 'SCREENSHOT', + 'path': screenshot_path, + 'description': description, + 'timestamp': datetime.now().isoformat(), + } + + self.info(f"📸 Screenshot saved: {description} -> {screenshot_path}", **log_data) + + # Прикрепляем к Allure + if self.log_to_allure: + try: + import allure + if Path(screenshot_path).exists(): + with open(screenshot_path, 'rb') as f: + allure.attach( + f.read(), + name=description, + attachment_type=allure.attachment_type.PNG + ) + except Exception as e: + self.warning(f"Failed to attach screenshot to Allure: {e}") + + @classmethod + def get_logger(cls, name: Optional[str] = None, **kwargs) -> 'TestLogger': + """Фабричный метод для получения логгера (singleton pattern)""" + if name is None: + # Получаем имя вызывающего модуля + curr_frame = inspect.currentframe() + if curr_frame: + frame = curr_frame.f_back + module = inspect.getmodule(frame) + name = module.__name__ if module else '__main__' + + if name not in cls._instances and name: + cls._instances[name] = cls(name, **kwargs) + + return cls._instances[name] + + +class ColorFormatter(logging.Formatter): + """Форматтер с цветами для консоли""" + + FORMATS = { + logging.DEBUG: Fore.CYAN + '%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s' + Style.RESET_ALL, + logging.INFO: Fore.GREEN + '%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s' + Style.RESET_ALL, + logging.WARNING: Fore.YELLOW + '%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s' + Style.RESET_ALL, + logging.ERROR: Fore.RED + '%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s' + Style.RESET_ALL, + logging.CRITICAL: Fore.RED + Back.WHITE + '%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s' + Style.RESET_ALL, + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno, self.FORMATS[logging.INFO]) + formatter = logging.Formatter(log_fmt, datefmt='%H:%M:%S') + return formatter.format(record) + + +class JSONFormatter(logging.Formatter): + """Форматтер для JSON логов (удобно для машинной обработки)""" + + def format(self, record): + log_record = { + 'timestamp': datetime.now().isoformat(), + 'level': record.levelname, + 'logger': record.name, + 'message': record.getMessage(), + 'module': record.module, + 'function': record.funcName, + 'line': record.lineno, + 'thread': record.threadName, + 'process': record.processName, + } + + if not record: + return json.dumps(log_record, ensure_ascii=False) + + # Добавляем дополнительные поля из extra + if hasattr(record, 'test_context'): + log_record['test_context'] = record.test_context + + if hasattr(record, 'filename'): + log_record['filename'] = record.filename + + # Добавляем exception info если есть + if record.exc_info: + log_record['exception'] = { + 'type': record.exc_info[0].__name__ if record.exc_info[0] else 'Unknown exception type', + 'message': str(record.exc_info[1]), + 'traceback': self.formatException(record.exc_info), + } + + return json.dumps(log_record, ensure_ascii=False) + + +# Глобальный логгер по умолчанию +def get_logger(name: Optional[str] = None, **kwargs) -> TestLogger: + """ + Утилита для получения логгера + + Args: + name: Имя логгера (обычно __name__) + **kwargs: Дополнительные параметры для TestLogger + + Returns: + Экземпляр TestLogger + """ + return TestLogger.get_logger(name, **kwargs) diff --git a/utils/waiters.py b/utils/waiters.py new file mode 100644 index 0000000..0e45ad1 --- /dev/null +++ b/utils/waiters.py @@ -0,0 +1,4 @@ +from selenium.webdriver.support.ui import WebDriverWait + + +waiter: WebDriverWait