← Все статьи

API-автотесты

API автотесты на Python с запуском на CI/CD и Allure отчетом

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

Время чтения: 19 мин
pythonqa automationtestinggithub actionsgithub pagesallurepytestapi testingpydanticcicd

Вступление

В этой статье мы разберём процесс написания 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
    if: always()  # Джоба будет выполнена вне зависимости от успеха или неуспеха run-tests
    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/[email protected]
        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. Перейдите в раздел Actions → General.

  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. Перейдите в раздел Pages → Build and deployment.
  3. Убедитесь, что параметры соответствуют настройкам на скриншоте ниже.

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

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

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

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

Заключение

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