Вступление

В этой статье мы разберём процесс написания API автотестов на Python, используя современные best practices. Кроме того, мы настроим их запуск в CI/CD с помощью GitHub Actions и сформируем Allure-отчёт с историей запусков. Цель статьи — не только показать, как писать качественные API автотесты, но и научить запускать их в CI/CD, получая удобные отчёты о результатах.

Мы будем использовать GitHub Actions, но аналогичная конфигурация возможна и для других CI/CD-систем, таких как GitLab CI, CircleCI или Jenkins — отличаться будет только синтаксис. Итоговый Allure-отчёт опубликуем на GitHub Pages, а также настроим сохранение истории запусков.

Тестируемый API — REST API, предоставленный сервисом FakeBank. Это учебное API, позволяющее работать с фейковыми банковскими операциями. Для тестирования будем отправлять запросы к https://api.sampleapis.com/fakebank/accounts.

Технологии

Вот стек инструментов, которые мы будем использовать:

  • Python 3.12 — для написания автотестов

  • Pytest — тестовый фреймворк

  • HTTPX — для отправки запросов к REST API

  • Pydantic — для сериализации, десериализации и валидации данных

  • Pydantic Settings — для удобной работы с конфигурацией проекта

  • Faker — для генерации случайных данных

  • Allure — для генерации детализированного отчёта

  • jsonschema — для валидации схемы JSON-ответов

  • pytest-xdist — для параллельного запуска тестов

Почему HTTPX, а не Requests?

Библиотека Requests хоть и популярна, но уже давно перестала активно развиваться. У неё до сих пор нет встроенной аннотации типов, хотя эта возможность появилась в Python ещё в версии 3.5 (а на момент написания статьи скоро выйдет уже 3.14). Прошло более 10 лет, но в Requests так и не добавили полноценную поддержку аннотаций типов, что говорит о слабой эволюции библиотеки.

Requests в своё время завоевала популярность благодаря простоте и отсутствию достойных альтернатив. Но сегодня есть более современное, мощное и удобное решениеHTTPX.

Что даёт HTTPX по сравнению с Requests:

  • Встроенные аннотации типов

  • Удобный объект Client для повторного использования соединений

  • Event hooks (хуки для обработки событий)

  • Полноценная поддержка асинхронных запросов

  • Поддержка HTTP/2

  • Современная и понятная документация

  • Множество других полезных возможностей

Всё это делает HTTPX более гибким, производительным и удобным инструментом. Requests, скорее всего, так и останется на уровне синхронных запросов без поддержки современных возможностей.

Если вы не пишете legacy-код и создаёте новый проект API автотестов на Python, то HTTPX — очевидный выбор.

Почему Pydantic?

Тут всё просто: Pydantic — это стандарт де-факто для работы с данными в Python.

Он позволяет:

  • Валидировать данные на основе аннотаций типов

  • Удобно сериализовать/десериализовать JSON

  • Гарантировать строгую типизацию на уровне данных

  • Работать с конфигурацией проекта через Pydantic Settings

У Pydantic почти нет достойных альтернатив. Стандартные dataclasses в Python даже близко не дают такой же функциональности, особенно валидации и строгой типизации данных.

Если вам нужны чистые, предсказуемые и надёжные данныеPydantic обязателен в любом современном проекте.

Почему pytest?

Потому что pytest — это лучший тестовый фреймворк для Python.

  • Гибкость: можно писать как простые, так и сложные тесты

  • Мощность: плагины, фикстуры, параметризация, маркировки, перезапуски, интеграция с Allure

  • Читаемость: тесты остаются чистыми и понятными

  • Популярность: pytest — стандарт для тестирования в Python

На фоне этого Behave, Robot Framework выглядят избыточными и усложняющими жизнь инструментами.

  • Лишняя абстракция — увеличивает сложность написания и поддержки тестов

  • Сложность отладки — тесты в стиле Gherkin выглядят красиво, но на практике мешают глубоко анализировать ошибки

  • Мнимая выгода читабельности — грамотно написанные тесты на pytest будут понятнее, чем сценарии Gherkin

Поэтому если вам нужны мощные, поддерживаемые и удобные API/UI-тесты — pytest это лучший выбор.

Модели для описания структур данных

Прежде чем работать с API https://api.sampleapis.com/fakebank/accounts, необходимо определить структуру данных, которые мы будем отправлять и получать.

Для этого используем Pydantic, так как он:

  • Позволяет автоматически валидировать данные

  • Поддерживает сериализацию и десериализацию JSON

  • Позволяет использовать встроенные валидаторы, такие как HttpUrl и EmailStr, для проверки корректности данных

  • Обеспечивает удобную типизацию

Мы определим:

  1. CreateOperationSchema – модель для создания новой операции

  2. UpdateOperationSchema – модель для обновления данных (используется для PATCH запросов)

  3. OperationSchema – расширенная модель с id, представляющая конечную структуру операции

  4. OperationsSchema – контейнер для списка операций

{
  "id": 25,
  "debit": 6.99,
  "credit": null,
  "category": "Merchandise",
  "description": "Benderbräu",
  "transactionDate": "2016-02-25"
}

/schema/operations.py

from datetime import date

from pydantic import BaseModel, Field, RootModel, ConfigDict


class CreateOperationSchema(BaseModel):
    """
    Модель для создания новой банковской операции.
    
    Поля:
    - debit (float | None): Сумма списания со счёта
    - credit (float | None): Сумма зачисления на счёт
    - category (str): Категория операции
    - description (str): Описание операции
    - transaction_date (date): Дата транзакции (передаётся в формате "transactionDate")
    """
    model_config = ConfigDict(populate_by_name=True)  # Позволяет использовать alias при сериализации/десериализации

    debit: float | None
    credit: float | None
    category: str
    description: str
    transaction_date: date = Field(alias="transactionDate")  # Указываем alias для соответствия API


class UpdateOperationSchema(BaseModel):
    """
    Модель для обновления банковской операции (используется в PATCH запросах).

    Все поля являются необязательными, так как можно обновлять только часть данных.

    Поля:
    - debit (float | None): Новая сумма списания
    - credit (float | None): Новая сумма зачисления
    - category (str | None): Новая категория
    - description (str | None): Новое описание
    - transaction_date (date | None): Новая дата транзакции (alias "transactionDate")
    """
    model_config = ConfigDict(populate_by_name=True)

    debit: float | None
    credit: float | None
    category: str | None 
    description: str | None
    transaction_date: date | None = Field(alias="transactionDate")


class OperationSchema(CreateOperationSchema):
    """
    Модель банковской операции, содержащая ID.
    
    Наследуется от CreateOperationSchema и добавляет поле:
    - id (int): Уникальный идентификатор операции
    """
    id: int


class OperationsSchema(RootModel):
    """
    Контейнер для списка операций.
    
    Поле:
    - root (list[OperationSchema]): Список операций
    """
    root: list[OperationSchema]
  • CreateOperationSchemaиспользуется при создании новой операции. Включает поля для суммы списания (debit), суммы зачисления (credit), категории (category), описания (description) и даты (transaction_date).

  • UpdateOperationSchemaпредназначена для PATCH-запросов, где можно передавать только изменяемые поля. Все параметры здесь опциональны, так как частичное обновление не требует передачи всех данных.

  • OperationSchema – расширенная версия CreateOperationSchema, добавляет поле id, которое присваивается сервером. Используется для представления операции в ответах API.

  • OperationsSchemaсписок операций. Наследуется от RootModel, что позволяет работать с массивами объектов в Pydantic.

Почему Pydantic, а не TypedDict или dataclasses?

Pydantic – это лучшее решение для работы с API-данными.

  • TypedDict и NamedTuple – подходят больше для аннотаций типов, но не для валидации данных и сериализации.

  • dataclasses – могут быть альтернативой, но у них нет встроенной валидации и сериализации JSON. Они работают хорошо в локальных моделях, но для API Pydantic – гораздо удобнее.

Pydantic позволяет автоматически проверять данные, использовать алиасы для полей и работать с JSON без дополнительных преобразований.

Генерация фейковых данных

При тестировании API нам нужно создавать множество случайных данных для разных сценариев. Чтобы автоматизировать этот процесс и избавиться от ручного заполнения, используем библиотеку Faker и реализуем класс Fake. Класс Fake будет предоставлять удобный интерфейс для генерации нужных значений.

/tools/fakers.py

from datetime import date, timedelta

from faker import Faker


class Fake:
    """
    Класс-обертка над Faker, предоставляющий удобные методы генерации фейковых данных
    для банковских операций.    
    """
    def __init__(self, faker: Faker):
        """
        Инициализирует объект Fake с экземпляром Faker.

        :param faker: Экземпляр Faker для генерации случайных данных.
        """
        self.faker = faker

    def date(self, start: timedelta = timedelta(days=-30), end: timedelta = timedelta()) -> date:
        """
        Генерирует случайную дату в заданном диапазоне.

        :param start: Начальный диапазон (по умолчанию -30 дней от текущей даты).
        :param end: Конечный диапазон (по умолчанию сегодняшняя дата).
        :return: Случайная дата в заданном диапазоне.
        """
        return self.faker.date_between(start_date=start, end_date=end)

    def money(self, start: float = -100, end: float = 100) -> float:
        """
        Генерирует случайную сумму денег.

        :param start: Минимальное значение (по умолчанию -100).
        :param end: Максимальное значение (по умолчанию 100).
        :return: Случайное число с плавающей запятой в заданном диапазоне.
        """
        return self.faker.pyfloat(min_value=start, max_value=end)

    def category(self) -> str:
        """
        Генерирует случайную категорию расходов.

        :return: Одна из предопределенных категорий ('food', 'taxi', 'fuel' и т.д.).
        """
        return self.faker.random_element(['food', 'taxi', 'fuel', 'beauty', 'restaurants'])

    def sentence(self) -> str:
        """
        Генерирует случайное описание операции.

        :return: Строка с описанием.
        """
        return self.faker.sentence()


# Создаем глобальный экземпляр `fake`, который будем использовать в других модулях.
fake = Fake(faker=Faker())

Класс Fake инкапсулирует логику библиотеки Faker и предоставляет удобный API для работы. Теперь вместо множества вызовов Faker().some_method() в коде можно просто использовать fake.some_method().

Теперь добавим фейковую генерацию прямо в модели Pydantic, используя параметр default_factory. Это позволит автоматически заполнять поля случайными значениями при создании модели.

/schema/operations.py

from datetime import date

from pydantic import BaseModel, Field, RootModel, ConfigDict

from tools.fakers import fake


class CreateOperationSchema(BaseModel):
    """
    Модель для создания новой банковской операции.
    
    Поля:
    - debit (float | None): Сумма списания со счёта
    - credit (float | None): Сумма зачисления на счёт
    - category (str): Категория операции
    - description (str): Описание операции
    - transaction_date (date): Дата транзакции (передаётся в формате "transactionDate")
    """
    model_config = ConfigDict(populate_by_name=True)

    debit: float | None = Field(default_factory=fake.money)  # Генерация случайной суммы списания со счёта
    credit: float | None = Field(default_factory=fake.money)  # Генерация случайной суммы зачисления на счёт
    category: str = Field(default_factory=fake.category)  # Генерация случайной категории
    description: str = Field(default_factory=fake.sentence)  # Генерация случайного описания
    transaction_date: date = Field(alias="transactionDate", default_factory=fake.date)  # Генерация случайной даты


class UpdateOperationSchema(BaseModel):
    """
    Модель для обновления банковской операции (используется в PATCH запросах).

    Все поля являются необязательными, так как можно обновлять только часть данных.

    Поля:
    - debit (float | None): Новая сумма списания
    - credit (float | None): Новая сумма зачисления
    - category (str | None): Новая категория
    - description (str | None): Новое описание
    - transaction_date (date | None): Новая дата транзакции (alias "transactionDate")
    """
    model_config = ConfigDict(populate_by_name=True)

    debit: float | None = Field(default_factory=fake.money)
    credit: float | None = Field(default_factory=fake.money)
    category: str | None = Field(default_factory=fake.category)
    description: str | None = Field(default_factory=fake.sentence)
    transaction_date: date | None = Field(alias="transactionDate", default_factory=fake.date)


class OperationSchema(CreateOperationSchema):
    """
    Модель банковской операции, содержащая ID.
    
    Наследуется от CreateOperationSchema и добавляет поле:
    - id (int): Уникальный идентификатор операции
    """
    id: int


class OperationsSchema(RootModel):
    """
    Контейнер для списка операций.
    
    Поле:
    - root (list[OperationSchema]): Список операций
    """
    root: list[OperationSchema]

Применение default_factory. Каждое поле получает случайное значение при создании экземпляра модели. Пример работы:

operation = CreateOperationSchema()
print(operation)

Вывод (данные случайные):

{
    "debit": -25.4,
    "credit": 87.6,
    "category": "fuel",
    "description": "Paid for fuel at a gas station.",
    "transactionDate": "2025-03-30"
}

Настройки API автотестов

Реализуем централизованный подход к управлению настройками для API автотестов. Это позволит легко изменять параметры без необходимости редактировать код.

В данном примере нам нужно хранить только URL API и таймаут запросов, но в будущем можно расширять этот механизм.

Для управления настройками будем использовать Pydantic Settings — удобную библиотеку, которая позволяет загружать переменные окружения в виде Pydantic-моделей.

config.py

from pydantic import BaseModel, HttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict


class HTTPClientConfig(BaseModel):
    """
    Настройки HTTP-клиента.

    Поля:
        url (HttpUrl): Базовый URL для API.
        timeout (float): Таймаут для запросов в секундах.
    """
    url: HttpUrl
    timeout: float

    @property
    def client_url(self) -> str:
        """
        Возвращает URL API в строковом формате.
        """
        return str(self.url)


class Settings(BaseSettings):
    """
    Главная модель для хранения всех настроек проекта.

    Загружает переменные из файла `.env` и поддерживает вложенные структуры.

    Поля:
        fake_bank_http_client (HTTPClientConfig): Настройки HTTP-клиента.
    """
    model_config = SettingsConfigDict(
        env_file='.env',  # Загружаем переменные из файла .env
        env_file_encoding='utf-8',  # Указываем кодировку файла
        env_nested_delimiter='.'  # Позволяет использовать вложенные переменные (FAKE_BANK_HTTP_CLIENT.URL)
    )

    fake_bank_http_client: HTTPClientConfig  # Настройки HTTP-клиента
  • Класс HTTPClientConfig

    • Наследуется от BaseModel (Pydantic).

    • Описывает базовые настройки HTTP-клиента:

      • url: HttpUrl — базовый адрес API (Pydantic автоматически проверит, что это корректный URL).

      • timeout: float — таймаут запросов.

    • Добавили @property client_url, чтобы возвращать url в строковом формате.

  • Класс Settings

    • Наследуется от BaseSettings (Pydantic Settings).

    • model_config определяет:

      • Где искать переменные (из файла .env).

      • Кодировку файла (utf-8).

      • Поддержку вложенных переменных (env_nested_delimiter='.').

    • fake_bank_http_client: HTTPClientConfig — добавляет вложенные настройки для HTTP-клиента.

.env

FAKE_BANK_HTTP_CLIENT.URL="https://api.sampleapis.com"
FAKE_BANK_HTTP_CLIENT.TIMEOUT=100
  • FAKE_BANK_HTTP_CLIENT.URL — адрес API, который будет использовать HTTP-клиент.

  • FAKE_BANK_HTTP_CLIENT.TIMEOUT — таймаут запросов (в секундах).

  • Благодаря env_nested_delimiter='.', переменные в файле .env автоматически конвертируются в вложенные структуры внутри Settings.

Теперь можно просто инициализировать Settings и использовать его:

settings = Settings()

print(settings.fake_bank_http_client.client_url)  # "https://api.sampleapis.com"
print(settings.fake_bank_http_client.timeout)     # 100

Важно! Глобальную переменную settings добавлять не будем — вместо этого будем инициализировать settings на уровне фикстур.

API клиенты

Теперь реализуем API клиент для работы с API https://api.sampleapis.com/fakebank/accounts. Однако перед этим создадим базовый API клиент, который будет использоваться для выполнения стандартных HTTP-запросов. В качестве HTTP-клиента будем использовать httpx.Client.

/clients/base_client.py

from typing import Any

import allure
from httpx import Client, URL, Response, QueryParams
from httpx._types import RequestData, RequestFiles

from config import HTTPClientConfig


class BaseClient:
    """
    Базовый клиент для выполнения HTTP-запросов.

    Этот класс предоставляет основные методы для выполнения HTTP-запросов 
    (GET, POST, PATCH, DELETE) и использует httpx.Client для выполнения 
    запросов. Каждый метод добавлен с использованием allure для генерации 
    отчетов о тестах.
    """
  
    def __init__(self, client: Client):
        """
        Инициализация клиента.
        :param client: Экземпляр httpx.Client
        """
        self.client = client

    @allure.step("Make GET request to {url}")
    def get(self, url: URL | str, params: QueryParams | None = None) -> Response:
        """
        Выполняет GET-запрос.

        :param url: URL эндпоинта
        :param params: Query параметры запроса
        :return: HTTP-ответ
        """
        return self.client.get(url, params=params)

    @allure.step("Make POST request to {url}")
    def post(
            self,
            url: URL | str,
            json: Any | None = None,
            data: RequestData | None = None,
            files: RequestFiles | None = None
    ) -> Response:
        """
        Выполняет POST-запрос.

        :param url: URL эндпоинта
        :param json: JSON тело запроса
        :param data: Данные формы
        :param files: Файлы для загрузки
        :return: HTTP-ответ
        """
        return self.client.post(url, json=json, data=data, files=files)

    @allure.step("Make PATCH request to {url}")
    def patch(self, url: URL | str, json: Any | None = None) -> Response:
        """
        Выполняет PATCH-запрос.

        :param url: URL эндпоинта
        :param json: JSON тело запроса
        :return: HTTP-ответ
        """
        return self.client.patch(url, json=json)

    @allure.step("Make DELETE request to {url}")
    def delete(self, url: URL | str) -> Response:
        """
        Выполняет DELETE-запрос.

        :param url: URL эндпоинта
        :return: HTTP-ответ
        """
        return self.client.delete(url)


def get_http_client(config: HTTPClientConfig) -> Client:
    """
    Функция для инициализации HTTP-клиента.

    :param config: Конфигурация HTTP-клиента
    :return: Экземпляр httpx.Client
    """
    return Client(
        timeout=config.timeout,  # Устанавливаем таймаут для всех запросов
        base_url=config.client_url,  # Базовый URL для API
    )
  • BaseClient — класс, который инкапсулирует базовые HTTP-методы (GET, POST, PATCH, DELETE) для взаимодействия с API. Для каждого метода добавлен декоратор allure.step, который позволяет отслеживать шаги выполнения тестов в отчете.

  • get_http_client — функция для создания экземпляра httpx.Client с необходимыми настройками (например, URL и таймаут), переданными через конфигурацию.

Важно! Чтобы избежать ошибок и дублирования адресов эндпоинтов в проекте, рекомендуется вынести все URI в отдельный Enum. Это позволит централизованно управлять URL-адресами и избежать опечаток.

/tools/routes.py

from enum import Enum


class APIRoutes(str, Enum):
    """
    Перечисление всех URI-адресов API для проекта.

    Это перечисление позволяет централизованно управлять всеми маршрутами API, 
    что помогает избежать ошибок при их использовании и упрощает масштабирование.
    """
    CARDS = "/fakebank/cards"
    CLIENTS = "/fakebank/clients"
    OPERATIONS = "/fakebank/accounts"  # Основной URI для работы с операциями
    STATEMENTS = "/fakebank/statements"
    NOTIFICATIONS = "/fakebank/notifications"

    def __str__(self):
        return self.value
  • APIRoutes — перечисление всех возможных эндпоинтов, с которыми будет работать приложение. Это позволяет централизовать и стандартизировать использование адресов.

  • В реальных проектах вам возможно придется добавлять новые маршруты, и это будет намного удобнее, если они будут прописаны в одном месте.

Теперь напишем API клиент для работы с операциями, используя API https://api.sampleapis.com/fakebank/accounts. Опишем методы, которые необходимо реализовать для работы с операциями в API:

  • get_operations_api — получит список операций.

  • get_operation_api — получит информацию об операции по operation_id.

  • create_operation_api — создаст операцию и вернет её данные.

  • update_operation_api — обновит операцию по operation_id и вернет обновленные данные.

  • delete_operation_api — удалит операцию по operation_id

/clients/operations_client.py

import allure
from httpx import Response

from clients.base_client import BaseClient, get_http_client
from config import Settings
from schema.operations import CreateOperationSchema, UpdateOperationSchema, OperationSchema
from tools.routes import APIRoutes


class OperationsClient(BaseClient):
    """
    Клиент для взаимодействия с операциями.
    """
  
    @allure.step("Get list of operations")
    def get_operations_api(self) -> Response:
        """
        Получить список всех операций.
        
        :return: Ответ от сервера с информацией о всех операциях.
        """
        return self.get(APIRoutes.OPERATIONS)

    @allure.step("Get operation by id {operation_id}")
    def get_operation_api(self, operation_id: int) -> Response:
        """
        Получить операцию по идентификатору.

        :param operation_id: Идентификатор операции.
        :return: Ответ от сервера с информацией об операции.
        """
        return self.get(f"{APIRoutes.OPERATIONS}/{operation_id}")

    @allure.step("Create operation")
    def create_operation_api(self, operation: CreateOperationSchema) -> Response:
        """
        Создать операцию.

        :param operation: Данные для создания новой операции.
        :return: Ответ от сервера с информацией о созданной операции.
        """
        return self.post(
            APIRoutes.OPERATIONS,
            json=operation.model_dump(mode='json', by_alias=True)  # Сериализуем объект в JSON перед отправкой
        )

    @allure.step("Update operation by id {operation_id}")
    def update_operation_api(
            self,
            operation_id: int,
            operation: UpdateOperationSchema
    ) -> Response:
        """
        Обновить операцию по идентификатору.

        :param operation_id: Идентификатор операции, которую нужно обновить.
        :param operation: Данные для обновления операции.
        :return: Ответ от сервера с обновленными данными операции.
        """
        return self.patch(
            f"{APIRoutes.OPERATIONS}/{operation_id}",
             json=operation.model_dump(mode='json', by_alias=True, exclude_none=True)
        )

    @allure.step("Delete operation by id {operation_id}")
    def delete_operation_api(self, operation_id: int) -> Response:
        """
        Удалить операцию по идентификатору.

        :param operation_id: Идентификатор операции, которую нужно удалить.
        :return: Ответ от сервера с результатом удаления операции.
        """
        return self.delete(f"{APIRoutes.OPERATIONS}/{operation_id}")

    def create_operation(self) -> OperationSchema:
        """
        Упрощенный метод для создания новой операции.

        Этот метод создает операцию с помощью схемы `CreateOperationSchema`, отправляет запрос
        на создание, а затем преобразует ответ в объект `OperationSchema`.

        :return: Объект `OperationSchema`, представляющий созданную операцию.
        """
        # Создаем запрос с фейковыми данными (по умолчанию для теста)
        request = CreateOperationSchema()
        # Отправляем запрос на создание
        response = self.create_operation_api(request)
        # Возвращаем созданную операцию как объект схемы
        return OperationSchema.model_validate_json(response.text)


def get_operations_client(settings: Settings) -> OperationsClient:
    """
    Функция для создания экземпляра OperationsClient с нужными настройками.

    :param settings: Конфигурация с настройками для работы с API.
    :return: Экземпляр клиента для работы с операциями.
    """
    return OperationsClient(client=get_http_client(settings.fake_bank_http_client))
  • OperationsClient — класс, который наследует от BaseClient и предоставляет методы для работы с операциями (получение списка операций, создание, обновление, удаление операции).

  • Каждый метод аннотирован с помощью @allure.step, что позволяет добавлять шаги в отчет Allure. Это помогает отслеживать выполнение шагов и параметров запросов в тестах.

  • Для сериализации объектов в JSON используется метод model_dump, который поддерживает алиасы и может исключать поля с None значениями.

  • get_operations_client — функция для создания экземпляра OperationsClient. Она принимает настройки и использует их для создания HTTP-клиента с нужными параметрами.

  • get_http_client — этот метод создает экземпляр httpx.Client с настройками из конфигурации.

Важно! Обратите внимание, что шаги для Allure были добавлены на двух уровнях: для BaseClient и OperationsClient.

  1. Шаги для BaseClient: На уровне BaseClient шаги содержат подробную техническую информацию о том, какой HTTP-метод использовался (например, GET, POST, PATCH, DELETE), куда был отправлен запрос, с каким телом и параметрами. Это позволяет нам точно видеть все детали запроса, которые были отправлены на сервер. Например, мы можем узнать:

    • Используемый HTTP-метод.

    • URL, по которому был отправлен запрос.

    • Тело запроса (если оно было).

  2. Шаги для OperationsClient: На уровне OperationsClient шаги отражают описание выполняемых действий с точки зрения бизнес-логики. Здесь не отображаются технические детали (например, сам HTTP-запрос), а скорее бизнесовые действия, такие как "Получение списка операций", "Создание новой операции" и т.д. Это делает отчет Allure более понятным и ориентированным на бизнес-логику, не перегружая его техническими деталями.

    В случае, если нужно получить более детальную информацию (например, какие параметры были переданы в запросе или какие методы использовались), можно раскрыть шаги с детальным описанием, где будут представлены все технические детали.

  3. Декоратор @allure.step: Важно отметить, что мы используем декоратор @allure.step специально, чтобы в отчет автоматически прикреплялись все параметры, передаваемые в методы и функции. Это позволяет нам в отчете видеть не только, какие шаги были выполнены, но и какие данные были переданы в каждом запросе, обеспечивая полную прозрачность всех действий.

Таким образом, с помощью такого подхода мы достигаем:

  • Гибкости в отчете, где можно раскрывать нужные детали on-demand.

  • Чистоты и понятности отчета, где бизнес-логику и технические детали можно разделить.

Логирование взаимодействий с API

Для удобного анализа логов при запуске на CI/CD, а также при локальной отладке, добавим логирование HTTP-запросов и ответов. Это позволит:

  • Видеть, какие запросы отправляются (метод, URL).

  • Получать информацию о статусе ответа и причине, если запрос завершился неудачно.

  • Анализировать взаимодействие с API без необходимости включать дебаг-режим или просматривать трассировки сети.

При этом важно избежать избыточного логирования, чтобы не засорять логи ненужной информацией. Поэтому мы ограничимся следующими записями:

  • Логирование запроса: указываем HTTP-метод и URL.

  • Логирование ответа: указываем статус-код, текстовое описание (reason phrase) и URL.

Некоторые QA Automation также добавляют cURL-команды в логи для воспроизведения запросов, но это может излишне увеличивать объем логов. Вместо этого мы можем прикреплять cURL-команду в Allure-отчет, где она будет более полезной в контексте тестов.

Первым шагом создадим функцию-билдер для конфигурации логгера с пользовательскими настройками.

/tools/logger.py

import logging


def get_logger(name: str) -> logging.Logger:
    """
    Создает и настраивает логгер с заданным именем.

    :param name: Имя логгера.
    :return: Объект логгера.
    """
    logger = logging.getLogger(name)
    logger.setLevel(logging.DEBUG)  # Устанавливаем уровень логирования

    handler = logging.StreamHandler()  # Создаем обработчик вывода в консоль
    handler.setLevel(logging.DEBUG)  # Устанавливаем уровень для обработчика

    # Формат логов: время | имя логгера | уровень логирования | сообщение
    formatter = logging.Formatter('%(asctime)s | %(name)s | %(levelname)s | %(message)s')
    handler.setFormatter(formatter)

    logger.addHandler(handler)  # Добавляем обработчик к логгеру

    return logger
  • get_logger(name: str) -> logging.Logger — создает логгер с кастомными настройками.

  • Уровень DEBUG — используется, чтобы видеть все события, включая информационные и отладочные.

  • Обработчик StreamHandler() — выводит логи в консоль.

  • Формат сообщений включает:

    • Время события

    • Имя логгера

    • Уровень логирования

    • Сообщение

  • Логгер можно переиспользовать в любом файле, вызывая get_logger("Имя").

Библиотека HTTPX предоставляет механизм event hooks, который позволяет выполнять кастомные действия до отправки запроса и после получения ответа. Мы используем этот механизм для логирования.

/clients/event_hooks.py

from httpx import Request, Response

from tools.logger import get_logger

# Создаем логгер для HTTP-клиента
logger = get_logger("HTTP_CLIENT")


def log_request_event_hook(request: Request):
    """
    Логирует информацию перед отправкой HTTP-запроса.

    :param request: Объект запроса HTTPX.
    """
    logger.info(f'Make {request.method} request to {request.url}')


def log_response_event_hook(response: Response):
    """
    Логирует информацию после получения HTTP-ответа.

    :param response: Объект ответа HTTPX.
    """
    logger.info(
        f"Got response {response.status_code} {response.reason_phrase} from {response.url}"
    )
  • log_request_event_hook(request: Request)

    • Логирует HTTP-метод (GET, POST и т. д.) и URL перед отправкой запроса.

  • log_response_event_hook(response: Response)

    • Логирует HTTP-статус-код, причину ответа (reason_phrase) и URL после получения ответа.

  • Оба хука подключаются к клиенту HTTPX и автоматически выполняются при каждом запросе.

Теперь добавим хук-функции к HTTP-клиенту, чтобы логи автоматически писались при каждом запросе.

/clients/base_client.py

from typing import Any

import allure
from httpx import Client, URL, Response, QueryParams
from httpx._types import RequestData, RequestFiles

from config import HTTPClientConfig


class BaseClient:
    """
    Базовый клиент для выполнения HTTP-запросов.

    Этот класс предоставляет основные методы для выполнения HTTP-запросов 
    (GET, POST, PATCH, DELETE) и использует httpx.Client для выполнения 
    запросов. Каждый метод добавлен с использованием allure для генерации 
    отчетов о тестах.
    """
  
    def __init__(self, client: Client):
        """
        Инициализация клиента.
        :param client: Экземпляр httpx.Client
        """
        self.client = client

    @allure.step("Make GET request to {url}")
    def get(self, url: URL | str, params: QueryParams | None = None) -> Response:
        """
        Выполняет GET-запрос.

        :param url: URL эндпоинта
        :param params: Query параметры запроса
        :return: HTTP-ответ
        """
        return self.client.get(url, params=params)

    @allure.step("Make POST request to {url}")
    def post(
            self,
            url: URL | str,
            json: Any | None = None,
            data: RequestData | None = None,
            files: RequestFiles | None = None
    ) -> Response:
        """
        Выполняет POST-запрос.

        :param url: URL эндпоинта
        :param json: JSON тело запроса
        :param data: Данные формы
        :param files: Файлы для загрузки
        :return: HTTP-ответ
        """
        return self.client.post(url, json=json, data=data, files=files)

    @allure.step("Make PATCH request to {url}")
    def patch(self, url: URL | str, json: Any | None = None) -> Response:
        """
        Выполняет PATCH-запрос.

        :param url: URL эндпоинта
        :param json: JSON тело запроса
        :return: HTTP-ответ
        """
        return self.client.patch(url, json=json)

    @allure.step("Make DELETE request to {url}")
    def delete(self, url: URL | str) -> Response:
        """
        Выполняет DELETE-запрос.

        :param url: URL эндпоинта
        :return: HTTP-ответ
        """
        return self.client.delete(url)


def get_http_client(config: HTTPClientConfig) -> Client:
    """
    Функция для инициализации HTTP-клиента.

    :param config: Конфигурация HTTP-клиента
    :return: Экземпляр httpx.Client
    """
    return Client(
        timeout=config.timeout,
        base_url=config.client_url,
        event_hooks={
            "request": [log_request_event_hook],  # Логирование перед запросом
            "response": [log_response_event_hook]  # Логирование после ответа
        }
    )
  • В BaseClient не изменялось поведение запросов – добавились только event hooks.

  • В get_http_client(config: HTTPClientConfig):

    • event_hooks["request"] = [log_request_event_hook] → логируем запрос перед отправкой.

    • event_hooks["response"] = [log_response_event_hook] → логируем ответ после получения.

  • Теперь при каждом запросе и ответе автоматически создаются записи в логах.

2025-03-30 13:38:06,646 | HTTP_CLIENT | INFO | Make GET request to https://api.sampleapis.com/fakebank/accounts/194
2025-03-30 13:38:07,023 | HTTP_CLIENT | INFO | Got response 200 OK from https://api.sampleapis.com/fakebank/accounts/194

Фикстуры

Реализуем фикстуры, необходимые для корректной и изолированной работы тестов. Нам понадобятся следующие фикстуры:

  • function_operation – создаёт новую операцию для каждого теста, чтобы её можно было удалить, обновить или получить в тесте.

  • operations_client – инициализирует и возвращает API-клиент для работы с операциями. Запускается перед каждым тестом.

  • settings – инициализирует настройки один раз на всю тестовую сессию.

Почему Pytest плагины, а не conftest.py?

Для объявления и управления фикстурами будем использовать Pytest плагины. Плагины удобнее и гибче, чем объявления фикстур в conftest.py, потому что:

  • Нет необходимости заботиться о расположении conftest.py.

  • Фикстуры из плагинов доступны глобально во всём проекте, вне зависимости от структуры тестов.

  • Использование conftest.py оправдано только для специфических групп тестов. В противном случае файлы conftest.py разрастаются до 1000+ строк, что затрудняет поддержку.

/fixtures/settings.py

import pytest

from config import Settings


@pytest.fixture(scope="session")
def settings() -> Settings:
    """
    Фикстура создаёт объект с настройками один раз на всю тестовую сессию.
    
    :return: Экземпляр класса Settings с загруженными конфигурациями.
    """
    return Settings()
  • @pytest.fixture(scope="session") – фиксирует, что настройка создаётся один раз за всю тестовую сессию.

  • settings() возвращает объект Settings, который можно переиспользовать в других фикстурах и тестах.

  • Использование этой фикстуры позволяет избежать повторной инициализации настроек в каждом тесте.

/fixtures/operations.py

import pytest

from clients.operations_client import OperationsClient, get_operations_client
from config import Settings
from schema.operations import OperationSchema


@pytest.fixture
def operations_client(settings: Settings) -> OperationsClient:
    """
    Фикстура создаёт экземпляр API-клиента для работы с операциями.
    
    :param settings: Объект с настройками тестовой сессии.
    :return: Экземпляр OperationsClient.
    """
    return get_operations_client(settings)


@pytest.fixture
def function_operation(operations_client: OperationsClient) -> OperationSchema:
    """
    Фикстура создаёт тестовую операцию перед тестом и удаляет её после выполнения теста.
    
    :param operations_client: API-клиент для работы с операциями.
    :return: Созданная тестовая операция.
    """
    operation = operations_client.create_operation()
    yield operation

    operations_client.delete_operation_api(operation.id)
  • operations_client(settings: Settings)

    • Создаёт экземпляр API-клиента для работы с операциями.

    • Использует настройки settings, передавая их в get_operations_client().

  • function_operation(operations_client: OperationsClient)

    • Создаёт операцию перед тестом (operations_client.create_operation()).

    • Передаёт её в тест через yield.

    • Удаляет операцию после завершения теста (operations_client.delete_operation_api(operation.id)).

Почему function_operation, а не просто operation?

Рекомендую использовать принцип именования {scope}_{entity}, где:

  • function_ – указывает, что операция создаётся на уровне отдельного теста.

  • _operation – указывает, что это объект операции.

Если потребуется аналогичная фикстура с уровнем class, её можно назвать class_operation, без необходимости придумывать сложные названия.

Нужно ли удалять тестовые данные?

Удаление созданных данных оправдано, если тестовые данные не нужны в будущем. Однако, если:

  • Данные могут быть полезны для ручных проверок.

  • Их можно использовать для отладки ошибок.

то удалять их не стоит.

Добавляем файлы с фикстурами в корневой conftest.py, чтобы они подключались автоматически:

pytest_plugins = (
    "fixtures.settings",
    "fixtures.operations"
)

Теперь фикстуры settings, operations_client и function_operation будут доступны глобально во всех тестах.

Валидация JSON схемы

Зачем нужна валидация JSON-схемы?

При работе с API важно проверять, соответствует ли возвращаемый JSON-объект заранее определённому контракту. Это позволяет:

  • Глобально контролировать структуру данных.

  • Быстро выявлять изменения в API, которые могут сломать автотесты.

  • Убедиться, что запланированные изменения затрагивают нужные тесты.

  • Защититься от случайных изменений, когда разработчик допустил ошибку и API стало возвращать невалидные данные.

Чтобы реализовать такую проверку, будем использовать библиотеку jsonschema, которая позволяет валидировать JSON-объекты согласно заданным схемам.

Реализации функции валидации JSON-схемы

Создадим функцию, которая будет выполнять валидацию JSON-объекта по переданной JSON-схеме.

/tools/assertions/schema.py

from typing import Any

import allure
from jsonschema import validate
from jsonschema.validators import Draft202012Validator

from tools.logger import get_logger

# Логгер для записи информации о процессе валидации
logger = get_logger("SCHEMA_ASSERTIONS")


@allure.step("Validating JSON schema")
def validate_json_schema(instance: Any, schema: dict) -> None:
    """"
    Проверяет, соответствует ли JSON-объект (instance) заданной JSON-схеме (schema).

    :param: instance (Any): JSON-объект, который необходимо проверить.
    :param: schema (dict): JSON-схема, согласно которой производится валидация.
    :raises:
        jsonschema.exceptions.ValidationError: В случае несоответствия JSON-объекта схеме.
        jsonschema.exceptions.SchemaError: Если переданная схема некорректна.
    """
    logger.info("Validating JSON schema")

    # Выполняем валидацию JSON-объекта по заданной схеме
    validate(
        schema=schema,  # JSON-схема, задающая структуру данных
        instance=instance,  # Проверяемый JSON-объект
        format_checker=Draft202012Validator.FORMAT_CHECKER,  # Проверка форматов (например, email, дата и т.д.)
    )
  1. Импорт необходимых модулей

  2. Определение функции validate_json_schema

    • Принимает JSON-объект (instance) и JSON-схему (schema).

    • Логирует начало процесса валидации.

    • Вызывает validate(), передавая объект и схему.

  3. Обработка ошибок

    • Если объект не соответствует схеме, jsonschema выбросит ValidationError.

    • Если сама схема содержит ошибки, будет вызван SchemaError.

Добавляя такую валидацию в автотесты, мы можем быстро находить проблемы с контрактами API и защищаться от неожиданных изменений. Это помогает поддерживать стабильность тестов и предотвращать регрессии.

Проверки

Зачем нужны проверки?

При тестировании API важно не только вызывать конечные точки, но и проверять, соответствуют ли полученные результаты ожидаемым. Для этого мы реализуем систему проверок.

Прежде чем добавлять проверки для конкретных операций, создадим базовые проверки, которые выполняют низкоуровневые операции, такие как сравнение значений и проверку статус-кода.

Зачем нужны базовые проверки?

  1. Отображение в Allure-отчётах

    • Все проверки будут видны как отдельные шаги в отчёте, что упростит анализ тестов.

  2. Логирование проверок

    • Каждая проверка будет логироваться, что поможет в отладке тестов как локально, так и на CI.

  3. Снижение бойлерплейт-кода

    • Вместо того чтобы каждый раз писать assert actual == expected, "Ошибка ...", мы будем использовать универсальные функции с уже готовыми сообщениями об ошибках.

Реализация базовых проверок

Создадим модуль содержащий две базовые проверки:

  • assert_status_code — проверяет, что статус-код ответа соответствует ожидаемому.

  • assert_equal — проверяет, что два значения равны.

/tools/assertions/base.py

from typing import Any

import allure

from tools.logger import get_logger

logger = get_logger("BASE_ASSERTIONS")


@allure.step("Check that response status code equals to {expected}")
def assert_status_code(actual: int, expected: int):
    """
    Проверяет, что HTTP-статус ответа соответствует ожидаемому.

    :param: actual (int): Фактический статус-код.
    :param: expected (int): Ожидаемый статус-код.
    :raises: AssertionError: Если статус-коды не совпадают.
    """
    logger.info(f"Check that response status code equals to {expected}")

    assert actual == expected, (
        f'Incorrect response status code. '
        f'Expected status code: {expected}. '
        f'Actual status code: {actual}'
    )


@allure.step("Check that {name} equals to {expected}")
def assert_equal(actual: Any, expected: Any, name: str):
    """
    Проверяет, что два значения равны.

    :param: actual (Any): Фактическое значение.
    :param: expected (Any): Ожидаемое значение.
    :param: name (str): Имя проверяемого параметра (для логирования).
    :raises: AssertionError: Если значения не равны.
    """
    logger.info(f'Check that "{name}" equals to {expected}')

    assert actual == expected, (
        f'Incorrect value: "{name}". '
        f'Expected value: {expected}. '
        f'Actual value: {actual}'
    )
  • Функция assert_status_code

    • Сравнивает фактический и ожидаемый HTTP-статус ответа.

    • Если статус-коды не совпадают — выдаёт AssertionError с подробным сообщением.

  • Функция assert_equal

    • Проверяет, что два значения равны.

    • Использует name для логирования, чтобы было понятно, что именно сравнивается.

    • При несовпадении выдаёт AssertionError с детальным описанием ошибки.

Реализация проверок операций

Теперь реализуем проверки для операций. Они позволят убедиться, что API возвращает корректные данные при создании, обновлении и получении операций.

  1. assert_create_operation — проверяет, что API вернуло корректные данные после создания или обновления операции.

  2. assert_operation полностью проверяет модель операции при её получении.

/tools/assertions/operations.py

import allure

from schema.operations import CreateOperationSchema, OperationSchema, UpdateOperationSchema
from tools.assertions.base import assert_equal
from tools.logger import get_logger

logger = get_logger("OPERATIONS_ASSERTIONS")


@allure.step("Check create operation")
def assert_create_operation(
        actual: OperationSchema,
        expected: CreateOperationSchema | UpdateOperationSchema
):
    """
    Проверяет, что данные, возвращённые API после создания/обновления операции, соответствуют ожидаемым.

    :param: actual (OperationSchema): Фактические данные операции.
    :param: expected (CreateOperationSchema | UpdateOperationSchema): Ожидаемые данные.
    :raises: AssertionError: Если значения полей не совпадают.
    """
    logger.info("Check create operation")

    assert_equal(actual.debit, expected.debit, "debit")
    assert_equal(actual.credit, expected.credit, "credit")
    assert_equal(actual.category, expected.category, "category")
    assert_equal(actual.description, expected.description, "description")
    assert_equal(actual.transaction_date, expected.transaction_date, "transaction_date")


@allure.step("Check operation")
def assert_operation(actual: OperationSchema, expected: OperationSchema):
    
    logger.info("Check operation")

    assert_equal(actual.id, expected.id, "id")
    assert_equal(actual.debit, expected.debit, "debit")
    assert_equal(actual.credit, expected.credit, "credit")
    assert_equal(actual.category, expected.category, "category")
    assert_equal(actual.description, expected.description, "description")
    assert_equal(actual.transaction_date, expected.transaction_date, "transaction_date")
  • Функция assert_create_operation

    • Проверяет корректность данных после создания или обновления операции.

    • Сравнивает debit, credit, category, description и transaction_date.

  • Функция assert_operation

    • Проверяет все поля операции, включая её id.

    • Используется при тестировании получения операции.

API тесты

Теперь напишем API автотесты, используя:

  • API клиенты для отправки запросов.

  • Фикстуры для подготовки тестовых данных.

  • Проверки, реализованные ранее.

  • Валидацию JSON-схемы, чтобы убедиться, что ответы соответствуют ожидаемой структуре.

Всего у нас будет несколько тестов на базовые CRUD-операции.

/tests/test_operations.py

from http import HTTPStatus  # Используем enum HTTPStatus вместо магических чисел

import allure
import pytest

from clients.operations_client import OperationsClient
from schema.operations import OperationsSchema, OperationSchema, CreateOperationSchema, UpdateOperationSchema
from tools.assertions.base import assert_status_code
from tools.assertions.operations import assert_operation, assert_create_operation
from tools.assertions.schema import validate_json_schema


@pytest.mark.operations
@pytest.mark.regression
class TestOperations:
    @allure.title("Get operations")
    def test_get_operations(self, operations_client: OperationsClient):
        response = operations_client.get_operations_api()

        assert_status_code(response.status_code, HTTPStatus.OK)
        validate_json_schema(response.json(), OperationsSchema.model_json_schema())

    @allure.title("Get operation")
    def test_get_operation(
            self,
            operations_client: OperationsClient,
            function_operation: OperationSchema
    ):
        response = operations_client.get_operation_api(function_operation.id)
        operation = OperationSchema.model_validate_json(response.text)

        assert_status_code(response.status_code, HTTPStatus.OK)
        assert_operation(operation, function_operation)

        validate_json_schema(response.json(), operation.model_json_schema())

    @allure.title("Create operation")
    def test_create_operation(self, operations_client: OperationsClient):
        request = CreateOperationSchema()
        response = operations_client.create_operation_api(request)
        operation = OperationSchema.model_validate_json(response.text)

        assert_status_code(response.status_code, HTTPStatus.CREATED)
        assert_create_operation(operation, request)

        validate_json_schema(response.json(), operation.model_json_schema())

    @allure.title("Update operation")
    def test_update_operation(
            self,
            operations_client: OperationsClient,
            function_operation: OperationSchema
    ):
        request = UpdateOperationSchema()
        response = operations_client.update_operation_api(function_operation.id, request)
        operation = OperationSchema.model_validate_json(response.text)

        assert_status_code(response.status_code, HTTPStatus.OK)
        assert_create_operation(operation, request)

        validate_json_schema(response.json(), operation.model_json_schema())

    @allure.title("Delete operation")
    def test_delete_operation(
            self,
            operations_client: OperationsClient,
            function_operation: OperationSchema
    ):
        delete_response = operations_client.delete_operation_api(function_operation.id)
        assert_status_code(delete_response.status_code, HTTPStatus.OK)

        # Дополнительная проверка: убеждаемся, что операция действительно удалена
        get_response = operations_client.get_operation_api(function_operation.id)
        assert_status_code(get_response.status_code, HTTPStatus.NOT_FOUND)
  • Используем HTTPStatus вместо магических чисел
    Вместо 200, 201, 404 и других кодов используем HTTPStatus.OK, HTTPStatus.CREATED, HTTPStatus.NOT_FOUND. Это делает код читаемым и исключает случайные ошибки.

  • Дополнительная проверка после удаления
    После удаления операции выполняем запрос GET /fakebank/accounts/{id} и проверяем, что сервер вернул 404 Not Found. Это позволяет убедиться, что операция действительно удалена.

  • Читаемые заголовки тестов в Allure-отчете
    Используем @allure.title(), чтобы тесты имели понятные названия в Allure.

  • Добавлены pytest маркировки для удобного запуска

Добавим pytest-маркировки в pytest.ini, чтобы избежать предупреждений при запуске.

[pytest]
addopts = -s -v
python_files = *_tests.py test_*.py
python_classes = Test*
python_functions = test_*
markers =
    regression: Маркировка для регрессионных тестов.
    operations:  Маркировка для тестов, связанных с операциями.

Благодаря правильно выбранной стратегии написания API автотестов, тесты сфокусированы на проверке бизнес-логики, а не на технических деталях.

Вместо того чтобы загромождать тесты шагами Allure, обработкой данных, валидацией JSON-схем и встроенными проверками, мы выносим эти задачи на другие уровни тестового фреймворка. Такой подход делает тесты:

  • Читаемыми – код остается лаконичным и интуитивно понятным.

  • Простыми в написании – добавление нового теста требует минимальных усилий.

  • Поддерживаемыми – изменения в API или проверках вносятся централизованно, а не в каждом тесте.

В результате тесты сфокусированы исключительно на бизнес-логике, а все вспомогательные процессы (логирование, шаги Allure, взаимодействие с API, выполнение проверок и их сообщения) скрыты на других уровнях фреймворка. Это делает API автотесты эффективными и удобными в работе.

Запуск на CI/CD

Настроим workflow-файл для автоматического запуска API-тестов в GitHub Actions, генерации Allure-отчета с сохранением истории и публикации его на GitHub Pages.

/.github/workflows/test.yml

name: API tests  # Название workflow

on:
  push:
    branches:
      - main  # Запускать workflow при пуше в main
  pull_request:
    branches:
      - main  # Запускать workflow при открытии PR в main

jobs:
  run-tests:  # Джоба для запуска тестов
    runs-on: ubuntu-latest  # Используем последнюю версию Ubuntu

    steps:
      - name: Check out repository  # Клонирование кода репозитория в среду CI/CD
        uses: actions/checkout@v4

      - name: Set up Python  # Установка Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'  # Используем Python версии 3.12

      - name: Install dependencies  # Установка зависимостей проекта
        run: |
          python -m pip install --upgrade pip  # Обновляем pip до последней версии
          pip install -r requirements.txt  # Устанавливаем зависимости, указанные в requirements.txt

      - name: Run API tests with pytest and generate Allure results  # Запуск тестов
        run: |
          pytest -m regression --alluredir=allure-results --numprocesses 2
          # Запускаем тесты с меткой "regression"
          # --alluredir=allure-results сохраняет результаты в папку allure-results
          # --numprocesses 2 - выполняем тесты в 2 потока (ускоряет выполнение)

      - name: Upload Allure results  # Загружаем результаты тестов в GitHub Actions
        if: always()  # Загружаем файлы независимо от успеха/неуспеха тестов
        uses: actions/upload-artifact@v4
        with:
          name: allure-results  # Название артефакта
          path: allure-results  # Путь к файлам отчета

  publish-report:  # Джоба для публикации Allure-отчета на GitHub Pages
    needs: [ run-tests ]  # Выполняется только после успешного выполнения run-tests
    runs-on: ubuntu-latest  # Используем последнюю версию Ubuntu

    steps:
      - name: Check out repository  # Клонируем репозиторий, включая ветку gh-pages
        uses: actions/checkout@v4
        with:
          ref: gh-pages  # Операции будем выполнять в ветке gh-pages
          path: gh-pages  # Клонируем файлы в папку gh-pages

      - name: Download Allure results  # Загружаем ранее сохраненные результаты тестов
        uses: actions/download-artifact@v4
        with:
          name: allure-results  # Название артефакта
          path: allure-results  # Путь для скачивания

      - name: Allure Report action from marketplace  # Генерация отчета Allure
        uses: simple-elf/allure-report-action@v1.12
        if: always()
        with:
          allure_results: allure-results  # Папка с результатами тестов
          allure_history: allure-history  # Папка для хранения истории отчетов

      - name: Deploy report to Github Pages  # Публикация отчета на GitHub Pages
        if: always()
        uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}  # Токен для доступа к репозиторию
          publish_branch: gh-pages  # Публикуем отчет в ветку gh-pages
          publish_dir: allure-history  # Папка, где находится сгенерированный отчет

Ссылки на документацию для всех использованных actions можно найти ниже:

Разрешения для Workflow

Если сейчас запустить тесты на GitHub Actions то, будет ошибка, говорящая о том, что у github token из workflow по умолчанию нет прав на записть в репзоиторий

Для исправления этой ошибки необходимо вручную изменить настройки прав workflow:

  1. Откройте вкладку Settings в репозитории GitHub.

  2. Перейдите в раздел ActionsGeneral.

  3. Прокрутите страницу вниз до блока Workflow permissions.

  4. Выберите опцию Read and write permissions.

  5. Нажмите кнопку Save для сохранения изменений.

После выполнения этих шагов можно отправить код с API-тестами в удалённый репозиторий.

Запуск тестов и генерация Allure-отчёта

После коммита изменений во вкладке Actions появится новый workflow, в котором автоматически запустятся тесты.

Если тесты пройдут успешно, Allure-отчёт будет сгенерирован и загружен в ветку gh-pages, после чего автоматически запустится workflow pages build and deployment. Этот процесс публикует Allure-отчёт на GitHub Pages, делая его доступным для просмотра в браузере.

Важно! Перед запуском workflow необходимо убедиться, что в репозитории существует ветка gh-pages. Если ветка отсутствует, её необходимо создать в удалённом репозитории, иначе публикация Allure-отчёта на GitHub Pages не будет работать.

Проверка настроек GitHub Pages

Если workflow pages build and deployment не запустился, необходимо проверить настройки GitHub Pages:

  1. Откройте вкладку Settings в репозитории.

  2. Перейдите в раздел PagesBuild and deployment.

  3. Убедитесь, что параметры соответствуют настройкам на скриншоте ниже.

На этой же странице будет отображаться виджет со ссылкой на опубликованный Allure-отчёт.

Доступ к Allure-отчётам

  • Каждый отчёт публикуется на GitHub Pages с уникальным идентификатором workflow, в котором он был сгенерирован.

  • Все сгенерированные Allure-отчёты также можно найти в ветке gh-pages.

  • Перейдя по ссылке на GitHub Pages, можно открыть сгенерированный Allure-отчёт с историей результатов тестирования.

По итогу, после корректной настройки, при каждом новом запуске тестов Allure-отчёт будет автоматически обновляться и сохранять историю предыдущих прогонов.

Заключение

Все ссылки на код, отчеты и запуски тестов в CI/CD можно найти на моем GitHub: