Вступление
В этой статье мы разберём процесс написания 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, для проверки корректности данных
-
Обеспечивает удобную типизацию
Мы определим:
-
CreateOperationSchema– модель для создания новой операции -
UpdateOperationSchema– модель для обновления данных (используется для PATCH запросов) -
OperationSchema– расширенная модель с id, представляющая конечную структуру операции -
OperationsSchema– контейнер для списка операций
{ "id": 25, "debit": 6.99, "credit": null, "category": "Merchandise", "description": "Benderbräu", "transactionDate": "2016-02-25" }
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 будет предоставлять удобный интерфейс для генерации нужных значений.
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. Это позволит автоматически заполнять поля случайными значениями при создании модели.
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-моделей.
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 -
Класс
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.
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-адресами и избежать опечаток.
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
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.
-
Шаги для BaseClient: На уровне
BaseClientшаги содержат подробную техническую информацию о том, какой HTTP-метод использовался (например, GET, POST, PATCH, DELETE), куда был отправлен запрос, с каким телом и параметрами. Это позволяет нам точно видеть все детали запроса, которые были отправлены на сервер. Например, мы можем узнать:-
Используемый HTTP-метод.
-
URL, по которому был отправлен запрос.
-
Тело запроса (если оно было).
-
-
Шаги для OperationsClient: На уровне
OperationsClientшаги отражают описание выполняемых действий с точки зрения бизнес-логики. Здесь не отображаются технические детали (например, сам HTTP-запрос), а скорее бизнесовые действия, такие как «Получение списка операций», «Создание новой операции» и т.д. Это делает отчет Allure более понятным и ориентированным на бизнес-логику, не перегружая его техническими деталями.В случае, если нужно получить более детальную информацию (например, какие параметры были переданы в запросе или какие методы использовались), можно раскрыть шаги с детальным описанием, где будут представлены все технические детали.
-
Декоратор @allure.step: Важно отметить, что мы используем декоратор @allure.step специально, чтобы в отчет автоматически прикреплялись все параметры, передаваемые в методы и функции. Это позволяет нам в отчете видеть не только, какие шаги были выполнены, но и какие данные были переданы в каждом запросе, обеспечивая полную прозрачность всех действий.
Таким образом, с помощью такого подхода мы достигаем:
-
Гибкости в отчете, где можно раскрывать нужные детали on-demand.
-
Чистоты и понятности отчета, где бизнес-логику и технические детали можно разделить.

Логирование взаимодействий с API
Для удобного анализа логов при запуске на CI/CD, а также при локальной отладке, добавим логирование HTTP-запросов и ответов. Это позволит:
-
Видеть, какие запросы отправляются (метод, URL).
-
Получать информацию о статусе ответа и причине, если запрос завершился неудачно.
-
Анализировать взаимодействие с API без необходимости включать дебаг-режим или просматривать трассировки сети.
При этом важно избежать избыточного логирования, чтобы не засорять логи ненужной информацией. Поэтому мы ограничимся следующими записями:
-
Логирование запроса: указываем HTTP-метод и URL.
-
Логирование ответа: указываем статус-код, текстовое описание (reason phrase) и URL.
Некоторые QA Automation также добавляют cURL-команды в логи для воспроизведения запросов, но это может излишне увеличивать объем логов. Вместо этого мы можем прикреплять cURL-команду в Allure-отчет, где она будет более полезной в контексте тестов.
Первым шагом создадим функцию-билдер для конфигурации логгера с пользовательскими настройками.
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, который позволяет выполнять кастомные действия до отправки запроса и после получения ответа. Мы используем этот механизм для логирования.
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-клиенту, чтобы логи автоматически писались при каждом запросе.
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+ строк, что затрудняет поддержку.
import pytest from config import Settings @pytest.fixture(scope="session") def settings() -> Settings: """ Фикстура создаёт объект с настройками один раз на всю тестовую сессию. :return: Экземпляр класса Settings с загруженными конфигурациями. """ return Settings()
-
@pytest.fixture(scope="session")– фиксирует, что настройка создаётся один раз за всю тестовую сессию. -
settings()возвращает объектSettings, который можно переиспользовать в других фикстурах и тестах. -
Использование этой фикстуры позволяет избежать повторной инициализации настроек в каждом тесте.
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-схеме.
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, дата и т.д.) )
-
Импорт необходимых модулей
-
jsonschema.validate — функция для валидации JSON-объекта.
-
Draft202012Validator.FORMAT_CHECKER — используется для проверки форматов (например, email, дата и т. д.).
-
-
Определение функции
validate_json_schema-
Принимает JSON-объект (instance) и JSON-схему (schema).
-
Логирует начало процесса валидации.
-
Вызывает
validate(), передавая объект и схему.
-
-
Обработка ошибок
-
Если объект не соответствует схеме, jsonschema выбросит ValidationError.
-
Если сама схема содержит ошибки, будет вызван SchemaError.
-
Добавляя такую валидацию в автотесты, мы можем быстро находить проблемы с контрактами API и защищаться от неожиданных изменений. Это помогает поддерживать стабильность тестов и предотвращать регрессии.
Проверки
Зачем нужны проверки?
При тестировании API важно не только вызывать конечные точки, но и проверять, соответствуют ли полученные результаты ожидаемым. Для этого мы реализуем систему проверок.
Прежде чем добавлять проверки для конкретных операций, создадим базовые проверки, которые выполняют низкоуровневые операции, такие как сравнение значений и проверку статус-кода.
Зачем нужны базовые проверки?
-
Отображение в Allure-отчётах
-
Все проверки будут видны как отдельные шаги в отчёте, что упростит анализ тестов.
-
-
Логирование проверок
-
Каждая проверка будет логироваться, что поможет в отладке тестов как локально, так и на CI.
-
-
Снижение бойлерплейт-кода
-
Вместо того чтобы каждый раз писать
assert actual == expected, "Ошибка ...", мы будем использовать универсальные функции с уже готовыми сообщениями об ошибках.
-
Реализация базовых проверок
Создадим модуль содержащий две базовые проверки:
-
assert_status_code— проверяет, что статус-код ответа соответствует ожидаемому. -
assert_equal— проверяет, что два значения равны.
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 возвращает корректные данные при создании, обновлении и получении операций.
-
assert_create_operation— проверяет, что API вернуло корректные данные после создания или обновления операции. -
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-операции.
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.
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:
-
Откройте вкладку Settings в репозитории GitHub.

-
Перейдите в раздел Actions → General.

-
Прокрутите страницу вниз до блока Workflow permissions.
-
Выберите опцию Read and write permissions.
-
Нажмите кнопку 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:
-
Откройте вкладку Settings в репозитории.
-
Перейдите в раздел Pages → Build and deployment.
-
Убедитесь, что параметры соответствуют настройкам на скриншоте ниже.
На этой же странице будет отображаться виджет со ссылкой на опубликованный Allure-отчёт.

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

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

Заключение
Все ссылки на код, отчеты и запуски тестов в CI/CD можно найти на моем GitHub:
ссылка на оригинал статьи https://habr.com/ru/articles/895452/



Добавить комментарий