Нагрузочное тестирование на Python и Locust с запуском на CI/CD
Разбираемся, как организовать нагрузочное тестирование на Python с Locust — с сидинговыми сценариями, кастомными API-клиентами на HTTPX, конфигурацией через Pydantic и автоматическим запуском в GitHub Actions. Всё — на практике, с архитектурой, фреймворком и публикацией отчётов в GitHub Pages.
Введение
В этой статье я наглядно покажу, как организовать нагрузочное тестирование с использованием Python и фреймворка Locust, опираясь на инженерные практики и удобную архитектуру. Цель статьи — дать вам готовый шаблон, с которым вы сможете начать писать свои нагрузочные тесты уже завтра в рамках реального проекта.
Нагрузочное тестирование становится всё более востребованным. Если раньше оно ассоциировалось в основном с тяжеловесным интерфейсом Apache JMeter, то сегодня в арсенале инженеров появились гораздо более удобные и гибкие инструменты: Locust, k6, Gatling, Artillery и другие. Они позволяют за несколько часов поднять полноценные тесты и встроить их в CI/CD.
В этой статье мы:
- посмотрим, как реализовать полноценный нагрузочный фреймворк на Python и Locust;
- разберём структуру сценариев, а также архитектуру TaskSet и User-классов;
- научимся централизованно задавать конфигурацию через .conf-файлы;
- обсудим, что такое сидинг, зачем он нужен и как он влияет на метрики;
- кратко пройдёмся по видам тестирования производительности: нагрузочное, стресс, пиковое, тестирование стабильности и др.;
- и, наконец, запустим тесты в CI/CD через GitHub Actions, а HTML-отчёт опубликуем на GitHub Pages.
В качестве тестируемого сервиса мы будем использовать FakeBank API — учебный REST API, позволяющий работать с фейковыми банковскими операциями. Все запросы будут отправляться на: https://api.sampleapis.com/fakebank/accounts.
Важно! Это тестовый API с ограничением по количеству запросов, поэтому мы сознательно не будем создавать избыточную нагрузку. Задача статьи — не "завалить" сервис, а показать, как правильно организовать инфраструктуру нагрузочного тестирования.
Вы получите не просто рабочий пример, а цельный фреймворк, с которым можно работать, масштабировать, адаптировать под свои сценарии и запускать из CI/CD. По ходу мы разберём множество архитектурных и практических деталей — от построения сценариев до публикации результатов.
Тестирование производительности: что это и зачем?
Прежде чем перейти к практике, давайте разберёмся в базовых терминах. Очень часто тестирование производительности путают с «нагрузкой ради нагрузки»: запускают 1000 пользователей на один endpoint и с гордостью называют это "нагрузочным тестом". Такой подход редко даёт реальную пользу — чаще всего он ведёт к искаженному восприятию метрик и неправильным выводам.
Что такое тестирование производительности?
Тестирование производительности — это систематическая проверка, как приложение ведёт себя под разной нагрузкой, с целью выявить узкие места, понять поведение системы и спрогнозировать её устойчивость.
Оно включает в себя несколько подвидов:
- Нагрузочное тестирование (Load Testing) — проверка, как система работает при ожидаемой (или слегка повышенной) нагрузке.
- Стресс-тестирование (Stress Testing) — проверка пределов системы: при каком уровне нагрузки она начинает деградировать.
- Тестирование стабильности (Stability / Endurance / Soak Testing) — запуск системы под умеренной нагрузкой в течение длительного времени (например, ночь или сутки), чтобы выявить утечки памяти и деградацию.
- Тестирование масштабируемости (Scalability Testing) — анализ, как производительность меняется при увеличении ресурсов (CPU, количество реплик и т.д.).
В рамках этой статьи мы сфокусируемся на классическом нагрузочном тестировании, когда мы хотим понять:
- Как быстро отвечает сервис под определённым количеством одновременных пользователей?
- Насколько стабильно ведёт себя API при увеличении RPS?
- Где потенциальные точки отказа?
Что именно мы тестируем?
Важно понимать: нагрузочное тестирование — это не гонка за цифрами. Оно не сводится к запуску 10 000 пользователей на один endpoint вроде /ping и последующей гордости за полученные 150 RPS. Такой подход может красиво смотреться на графике, но не говорит ровным счётом ничего о реальной устойчивости системы.
Наша цель — смоделировать поведение живых пользователей. Не synthetic load, а то, как пользователи реально двигаются по системе, с какими данными работают, в каком порядке совершают действия. Иначе мы просто тестируем абстрактный HTTP-сервер, а не продукт.
Что это значит на практике? Вместо "битья" по одному endpoint, мы собираем конкретные пользовательские флоу — например:
- пользователь заходит в систему, авторизуется, открывает список счетов и делает перевод;
- другой пользователь получает уведомление, проверяет историю операций, загружает документ;
- администратор просматривает отчёты и выгружает данные за квартал.
Такие цепочки — это сценарии использования (use cases). Именно они нагружают систему как единое целое: затрагивают авторизацию, базу данных, кэш, бизнес-логику, генерацию PDF, внешние сервисы.
Именно такие сценарии:
- помогают выявить реальные узкие места: медленные JOIN'ы, перегретые очереди, ошибки сериализации;
- дают ценную информацию бизнесу: где пользователь может столкнуться с фрустрацией;
- позволяют заранее увидеть деградацию при росте аудитории — ещё до того, как это произойдёт в продакшене.
Даже если у вас мало времени — лучше один осмысленный сценарий, чем тысяча безликих запросов. Хорошо составленный сценарий — это основа эффективного нагрузочного теста.
Какие метрики важны?
Чтобы нагрузочное тестирование приносило пользу, важно понимать, что именно нужно измерять. В любом тесте под нагрузкой есть два слоя наблюдения: клиентский и инфраструктурный.
На клиентской стороне — то, что мы видим из Locust: как быстро API отвечает, сколько запросов проходит в секунду, сколько из них завершаются с ошибками. Один из ключевых показателей здесь — время отклика (latency). Если, к примеру, пользователь видит, что страница с операциями загружается не за 300 мс, а за 3 секунды, это становится проблемой — даже если сервер формально не падает.
Другой важный параметр — RPS (requests per second). Он показывает, сколько запросов система реально обрабатывает при заданной нагрузке. Если при 100 пользователях мы получаем только 20 RPS, это сигнал задуматься: не слишком ли много времени уходит на внутренние операции?
Кроме того, нужно следить за уровнем ошибок. Допустим, при 500 RPS появляются ответы 5xx или 429 — значит, сервис уже не справляется, и нужно искать бутылочное горлышко.
Не менее важно понимать дисперсию отклика. Бывает, что среднее время — 300 мс, но 90-й процентиль — 2 секунды. Это означает, что часть пользователей стабильно получает очень медленный отклик. Такие перекосы крайне опасны в пользовательских системах: один клиент счастлив, а другой негодует.
И наконец, полезно отслеживать успешность сценариев: как часто они доходят до конца, где рвутся, какие шаги «проваливаются» — всё это помогает понять устойчивость не отдельных endpoint'ов, а именно бизнес-флоу.
С другой стороны, есть инфраструктурные метрики — они дают понимание того, что происходит "внутри" системы. Это, прежде всего, нагрузка на CPU и потребление оперативной памяти. Если при росте RPS CPU сразу уходит в 100%, значит, дальше масштабироваться не получится без оптимизации кода или увеличения ресурсов.
Также важны количество открытых соединений, активность диска (IO), состояние баз данных и кэшей. Например, при пиковых значениях может «задохнуться» Redis, очередь сообщений или файловое хранилище. Бывает, что кэш забит, очередь растёт — а ошибки на клиенте появляются через 2 минуты, когда всё уже упало.
Такие метрики обычно собираются через внешние системы мониторинга: Grafana, Prometheus, Datadog, NewRelic. Они позволяют строить дашборды, отслеживать тренды и мгновенно реагировать на отклонения.
А как с этим быть у нас?
В рамках этой статьи мы работаем с учебным API, который для нас — по сути, black-box. Мы не можем влезть внутрь и посмотреть системные метрики, так как это не наш сервис.
Поэтому в данной статье мы ограничимся клиентскими метриками — теми, которые нам даст HTML-отчёт Locust.
Цель этой статьи — не полноценный анализ архитектуры, а техническая сторона написания хорошо организованных нагрузочных тестов: как структурировать код, как задавать сценарии, как подключать сидинг, как запускать в CI/CD и т.д.
Если у вас есть доступ к системным метрикам (через Grafana, Prometheus, Datadog, NewRelic и т.д.), обязательно подключайте их в связке с Locust. Только так можно получить полную картину производительности.
Технологии
Вот стек инструментов, которые мы будем использовать:
- Python 3.12 — для написания нагрузочных тестов
- Locust — инструмент симуляции нагрузки
- HTTPX — для отправки запросов к REST API
- Pydantic — для сериализации, десериализации и валидации данных
- Pydantic Settings — для удобной работы с конфигурацией проекта
- Faker — для генерации случайных данных
Почему именно Locust?
- Нативный Python. Сценарии пишутся на чистом Python, без XML, YAML или UI-конфигов. Это позволяет использовать привычные конструкции языка, логировать, отлаживать, импортировать свои модули и писать тесты с максимальной гибкостью.
- Хорошая документация и активное сообщество. Locust активно развивается, регулярно обновляется и имеет большое сообщество разработчиков. На GitHub и StackOverflow легко найти ответы на вопросы, примеры расширений и best practices.
- Гибкость и кастомизация. Locust позволяет точно настраивать поведение пользователей: задавать веса задач, использовать SequentialTaskSet, комбинировать сценарии, задавать события на старте и завершении, переопределять поведение на любом уровне.
- Поддержка хуков, событий и расширений. Можно использовать event hooks для логирования, метрик, интеграции с внешними системами. Это особенно полезно в связке с CI/CD или системами мониторинга.
Кастомные API-клиенты на базе HTTPX
Хотя в Locust есть встроенный HTTP-клиент, мы сознательно отказываемся от него в пользу собственного решения. Причина проста: нам нужен универсальный API-клиент, который будет использоваться не только внутри сценариев Locust, но и при генерации тестовых данных (сидинге), а также — при необходимости — в интеграционных автотестах. Это позволяет не дублировать логику и работать с API единообразно в разных контекстах.
Встроенный клиент Locust хорош для простых примеров, но он не даёт необходимой гибкости. В нём нет строгой типизации, он жёстко завязан на структуру Locust, его сложно конфигурировать централизованно и практически невозможно удобно переиспользовать вне контекста нагрузочного теста. Поэтому мы создадим собственный HTTP-клиент, основанный на связке HTTPX и Pydantic.
Наш кастомный клиент будет изолированным и модульным. Он будет валидировать входящие и исходящие данные с помощью моделей Pydantic, централизованно управляться через конфигурацию и при необходимости — логировать запросы и ответы. Такой подход позволяет отделить инфраструктурную часть (нагрузку, сценарии, конфигурацию) от логики общения с API.
В результате у нас получится чистая и масштабируемая архитектура, в которой тестовые сценарии остаются простыми и читаемыми, а вся логика взаимодействия с API сосредоточена в одном, легко поддерживаемом месте.
Почему HTTPX, а не Requests?
Библиотека Requests хоть и популярна, но уже давно перестала активно развиваться. У неё до сих пор нет встроенной аннотации типов, хотя эта возможность появилась в Python ещё в версии 3.5 (а на момент написания статьи скоро выйдет уже 3.14). Прошло более 10 лет, но в Requests так и не добавили полноценную поддержку аннотаций типов, что говорит о слабой эволюции библиотеки.
Что даёт HTTPX по сравнению с Requests:
- Встроенные аннотации типов
- Удобный объект Client для повторного использования соединений
- Event hooks (хуки для обработки событий)
- Полноценная поддержка асинхронных запросов
- Поддержка HTTP/2
- Современная и понятная документация
- Множество других полезных возможностей
Всё это делает HTTPX более гибким, производительным и удобным инструментом. Requests, скорее всего, так и останется на уровне синхронных запросов без поддержки современных возможностей.
Если вы не пишете legacy-код и создаёте новый проект нагрузочных тестов на Python, то HTTPX — очевидный выбор.
Почему Pydantic?
Тут всё просто: Pydantic — это стандарт де-факто для работы с данными в Python.
Он позволяет:
- Валидировать данные на основе аннотаций типов
- Удобно сериализовать/десериализовать JSON
- Гарантировать строгую типизацию на уровне данных
- Работать с конфигурацией проекта через Pydantic Settings
У Pydantic почти нет достойных альтернатив. Стандартные dataclasses в Python даже близко не дают такой же функциональности, особенно валидации и строгой типизации данных.
Если вам нужны чистые, предсказуемые и надёжные данные — Pydantic обязателен в любом современном проекте.
Модели для описания структур данных
Прежде чем работать с API https://api.sampleapis.com/fakebank/accounts, необходимо определить структуру данных, которые мы будем отправлять и получать.
Для этого используем Pydantic, так как он:
Мы определим:
- CreateOperationSchema – модель для создания новой операции
- UpdateOperationSchema – модель для обновления данных (используется для PATCH запросов)
- OperationSchema – расширенная модель с id, представляющая конечную структуру операции
- OperationsSchema – контейнер для списка операций
- 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 будет предоставлять удобный интерфейс для генерации нужных значений.
Класс Fake инкапсулирует логику библиотеки Faker и предоставляет удобный API для работы. Теперь вместо множества вызовов Faker().some_method() в коде можно просто использовать fake.some_method().
Теперь добавим фейковую генерацию прямо в модели Pydantic, используя параметр default_factory. Это позволит автоматически заполнять поля случайными значениями при создании модели.
Применение default_factory. Каждое поле получает случайное значение при создании экземпляра модели. Пример работы:
Вывод (данные случайные):
Настройки нагрузочных тестов
Реализуем централизованный подход к управлению настройками для нагрузочных тестов. Это позволит легко изменять параметры без необходимости редактировать код.
В данном примере нам нужно хранить только URL API и таймаут запросов, но в будущем можно расширять этот механизм.
Для управления настройками будем использовать Pydantic Settings — удобную библиотеку, которая позволяет загружать переменные окружения в виде Pydantic-моделей.
- Класс 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-клиента.
- FAKE_BANK_HTTP_CLIENT.URL — адрес API, который будет использовать HTTP-клиент.
- FAKE_BANK_HTTP_CLIENT.TIMEOUT — таймаут запросов (в секундах).
- Благодаря env_nested_delimiter='.', переменные в файле .env автоматически конвертируются в вложенные структуры внутри Settings.
Теперь можно просто инициализировать Settings и использовать его:
API клиенты
Для работы с API https://api.sampleapis.com/fakebank/accounts мы используем библиотеку HTTPX. Она современная, производительная и отлично подходит для создания универсального API-клиента. Но одной отправки запросов недостаточно — в рамках нагрузочного тестирования важно, чтобы каждый вызов API автоматически попадал в систему метрик Locust. Нам нужно отслеживать время ответа, ошибки, размер тела и при этом поддерживать понятную, агрегируемую статистику по маршрутам.
Locust, по умолчанию, умеет собирать метрики только со встроенных клиентов. Если мы используем стороннюю библиотеку — такую как HTTPX — то должны вручную передавать информацию о каждом запросе в движок Locust. Именно для этого используются event hooks.
Как работают event hooks в HTTPX
HTTPX позволяет внедрить свою логику в жизненный цикл запроса с помощью так называемых хуков. Они бывают двух типов: request — вызывается перед отправкой запроса, и response — вызывается после получения ответа. Это удобный механизм, который позволяет реализовать всё: от логирования и трассировки до сбора метрик и интеграции с внешними инструментами.
В нашем случае мы реализуем два хука. Первый срабатывает перед отправкой запроса и сохраняет метку времени начала — это позволит затем вычислить длительность запроса. Второй срабатывает после получения ответа. Он извлекает метку старта, считает время выполнения в миллисекундах, определяет длину тела ответа и отправляет всё это в Locust через environment.events.request.fire().
Важно, что в хуке на стороне ответа мы также ловим исключения (HTTPError, HTTPStatusError) — это позволяет нам регистрировать ошибки не как "просто 500", а как полноценные события в отчёте.
Реализация хуков
Почему используется замыкание (closure)
Мы могли бы просто передавать environment как аргумент в каждый вызов, но это неудобно: HTTPX ждёт, что event_hooks["response"] будет списком функций с сигнатурой Callable[[Response], None]. То есть она должна принимать только response. Никакие дополнительные параметры, такие как environment, туда передать нельзя напрямую.
Чтобы обойти это ограничение, мы создаём вложенную функцию inner(), которая «захватывает» переменную environment из внешней области видимости. Это и есть замыкание — стандартный приём в Python, когда вложенная функция запоминает значения переменных из своей внешней среды, даже если она вызывается позже и в другом контексте.
Таким образом, locust_response_event_hook(environment) возвращает готовую для HTTPX функцию-хук, которая при каждом срабатывании знает, куда и как отправлять метрику.
Зачем нужен extensions["route"]
Когда мы тестируем реальные API, URL часто содержит динамические параметры: ID пользователя, номер операции, дату. Например, /accounts/123, /accounts/456 и т.п. Если отправлять метрики в Locust с такими значениями как есть, то отчёт превратится в список из сотен уникальных строк, по сути — мусор. Чтобы этого избежать, мы передаём через extensions шаблон маршрута: /accounts/{id}. Этот шаблон подставляется в метрике вместо реального пути и позволяет агрегировать статистику правильно.
Базовый API-клиент и подключение к Locust
На этом этапе у нас есть хуки для интеграции с Locust, и теперь пора построить над ними полноценный фундамент — абстракцию HTTP-клиента, через которую будут идти все запросы. Задача — отделить инфраструктурные детали вроде хуков, маршрутов, конфигурации и логирования от конкретных бизнес-клиентов, таких как OperationsClient. Эта изоляция позволит переиспользовать код, упростит тестирование и повысит читаемость.
Вместо того чтобы работать напрямую с httpx.Client, мы создаём класс BaseClient, который становится универсальным слоем между HTTPX и любым конкретным API-клиентом. Он инкапсулирует вызовы get и post, принимает дополнительные параметры, включая extensions, и работает с уже сконфигурированным клиентом — либо обычным, либо с подключёнными хуками Locust.
Ключевая особенность реализации — использование поля extensions. Это специальный словарь, который HTTPX позволяет передавать в каждый запрос. Он остаётся незаметным для внешнего API, но может использоваться внутри хуков, чтобы передавать вспомогательные данные. И вот здесь появляется важный инженерный момент: extensions["route"].
Проблема в том, что API часто содержит динамические маршруты: /accounts/123, /accounts/456, и так далее. Если передавать их как есть в Locust, то в метриках будет по 1000 разных URL, каждый со своей строкой. Это создаёт хаос в статистике, мешает анализу и делает HTML-отчёты практически бесполезными. Чтобы этого избежать, мы вручную передаём шаблон маршрута — например, /accounts/{id} — через extensions["route"], и уже в хуке на стороне response используем именно его в качестве имени метрики. Это простой, но мощный приём, позволяющий агрегировать данные и сохранить чистую статистику.
Но сам по себе BaseClient не создаёт клиент. Этим занимаются два вспомогательных конструктора — get_http_client и get_locust_http_client. Первый используется, когда нам не нужно собирать метрики (например, в сидинге или обычных автотестах). Второй — когда мы запускаем тест в среде Locust и хотим, чтобы все метрики автоматически отправлялись в HTML-отчёт.
И в том, и в другом случае мы явно задаём base_url и timeout из конфигурации, а также устанавливаем уровень логгера для HTTPX, чтобы не засорять stdout при выполнении нагрузочных тестов или сидинга.
Во втором варианте — get_locust_http_client — мы подключаем хуки, определённые ранее. Один срабатывает до запроса (сохраняет время старта), второй — после получения ответа (считает время, длину и отправляет событие в Locust).
Этот слой завершает низкоуровневую часть инфраструктуры. Он изолирует httpx, предоставляет единый API, делает клиент совместимым с Locust и позволяет бизнес-клиентам фокусироваться на логике, а не на конфигурации и метриках.
Реализация клиента для операций: OperationsClient
Этот клиент строится по принципу двух уровней:
- Низкоуровневые методы, которые просто отправляют HTTP-запросы и возвращают Response. Они удобны в отладке, автотестах, и при необходимости точечного контроля над телом ответа.
- Высокоуровневые методы, которые инкапсулируют всю логику — сериализацию запроса, десериализацию ответа и генерацию данных. Эти методы мы будем использовать в сидинге и нагрузочных тестах.
Важно! Чтобы избежать ошибок и дублирования адресов эндпоинтов в проекте, рекомендуется вынести все URI в отдельный Enum. Это позволит централизованно управлять URL-адресами и избежать опечаток.
- APIRoutes — перечисление всех возможных эндпоинтов, с которыми будет работать приложение. Это позволяет централизовать и стандартизировать использование адресов.
- В реальных проектах вам возможно придется добавлять новые маршруты, и это будет намного удобнее, если они будут прописаны в одном месте.
Структура клиента
OperationsClient наследуется от BaseClient, получая в своё распоряжение стандартные методы get() и post() с поддержкой extensions, конфигурации и хуков Locust.
Низкоуровневые методы API
Метод отправляет GET-запрос на /accounts и возвращает "сырое" тело ответа. Такой формат пригоден для API-тестов или отладочной работы с данными.
Этот метод тоже работает на низком уровне, но с одной важной особенностью: он передаёт в extensions шаблон маршрута. Это необходимо, чтобы в метриках Locust не появлялись сотни уникальных строк вроде /accounts/1, /accounts/2, а вместо этого всё агрегировалось под ключом /accounts/{operation_id}.
Метод create_operation_api() принимает модель Pydantic и сериализует её в JSON, используя алиасы (например, transaction_date → transactionDate). Возвращается опять же Response.
Высокоуровневые методы: использование в нагрузке и сидинге
Метод get_operations() вызывает низкоуровневый API, затем валидирует и преобразует тело ответа в Pydantic-модель OperationsSchema. Это упрощает работу с ответом — теперь мы получаем не JSON или строку, а полноценный Python-объект.
Аналогично: сначала вызываем API, затем парсим ответ. Важно, что OperationSchema — это строго типизированная структура. Ошибки при несоответствии типов, форматов или отсутствующих полей будут видны сразу.
Это наиболее «высокоуровневый» метод. Он полностью инкапсулирует всю цепочку: генерацию фейковых данных, сериализацию, отправку запроса и десериализацию ответа. Именно его мы будем вызывать в сценариях Locust и в сидинге, потому что он даёт результат «из коробки» без ручной работы.
Билдеры клиента
Как и в случае с BaseClient, мы создаём два конструктора клиента — один для обычного использования, другой — для интеграции с Locust.
get_operations_client() создаёт клиента без хуков. Это используется, например, в сидинге, когда нам не нужно собирать метрики — а просто сгенерировать или проверить данные.
А get_operations_locust_client() используется внутри сценариев Locust. Он создаёт httpx.Client с подключёнными хуками, которые автоматически будут отправлять все метрики и ошибки в HTML-отчёт Locust.
Заключение
Такой клиент легко расширять, он изолирован от логики нагрузочного теста и может использоваться как в ручных тестах, так и в CI. При этом мы сохраняем строгую типизацию, поддержку роутинга, сериализацию, совместимость с хуками и централизованную конфигурацию. Именно так и должен выглядеть клиент в инженерной нагрузочной инфраструктуре.
Сидинг
Один из ключевых этапов подготовки к нагрузочному тестированию — это сидинг (от англ. seeding), то есть предварительное наполнение системы нужными данными. Без сидинга любые метрики производительности будут искажены: вы будете тестировать не реальную нагрузку, а начальные этапы использования системы, вроде регистрации и настройки.
Например, если вы хотите проверить, как система обрабатывает массовые операции по уже существующим пользователям — сначала этих пользователей нужно создать. Причём не просто «записать в базу», а подготовить полные, осмысленные данные: активные счета, историю операций, документы и всё, что положено по бизнес-логике.
По сути, сидинг — это создание реалистичного состояния системы до запуска тестов, чтобы эмулировать поведение настоящих пользователей, а не вновь зарегистрированных «болванок».
Почему сидинг нужно делать через API, а не напрямую в базу
Когда речь заходит о создании данных, возникает логичный вопрос: а как именно наполнять систему — через базу или через API?
Простой путь — напрямую вставить данные в базу SQL-скриптами или ORM. Но на практике такой подход опасен, хрупок и практически всегда ведёт к скрытым багам. Поэтому в рамках этого проекта мы используем сидинг через API — и вот почему.
1. API сохраняет бизнес-логику
Любой сервис содержит важные правила: валидации, зависимости, триггеры, расчётные поля. При вставке напрямую в базу вы обходите всю эту логику, и в результате получаете «битые» или неполные данные, которые не проходят через те же проверки, что и в реальной системе.
При работе через API все операции проходят через тот же код, что используется в продакшене — это гарантирует валидность и консистентность.
2. Минимум дублирования и упрощённая поддержка
Сидинг через базу требует от вас вручную воссоздавать все связи и зависимости: сначала пользователя, потом счёт, потом операции и документы. Любое изменение в бизнес-логике потребует переписывать сидер. Через API — это уже реализовано. Вы просто вызываете соответствующие методы.
3. Защита от ошибок и интеграция с логами
Сидинг через API автоматически попадает в логи, метрики и трассировку сервиса. Вы можете отследить ошибки, посмотреть, какие объекты были созданы и какова их структура. А значит — сидинг становится предсказуемым и безопасным.
4. Масштабируемость
API-сидинг проще масштабировать. Вы можете распараллелить генерацию данных, повторно использовать существующие клиенты и сценарии, не заботясь о внутреннем устройстве базы. Это особенно важно в микросервисной архитектуре, где за одним объектом могут стоять десятки сервисов и процессов.
Почему мы делаем сидинг именно так
Мы хотим, чтобы все данные, участвующие в нагрузочном тесте, были:
- консистентны (например, у операции есть привязанный пользователь и счёт),
- валидны (соответствуют всем правилам API),
- понятны (их можно проследить в логах и при отладке),
- реалистичны (как на боевом окружении).
Поэтому мы будем использовать специальные API-клиенты, которые вызывают реальные методы создания объектов (например, POST /operations), и строим поверх них сидинг-скрипты. Это даёт нам:
- безопасность и контроль,
- гибкость в создании сценариев,
- и гарантию, что нагрузочное тестирование идёт на честных, настоящих данных.
Схема сидинга: входной план и результат
Для организации сидинга мы разделяем его на две части: входной план (что нужно создать) и результат (что было создано). Это позволяет явно контролировать, какие объекты мы хотим получить, и затем использовать их повторно в тестах или других сидерах.
План сидинга (SeedsPlan)
Объяснение:
- SeedOperationsPlan — описывает, сколько операций нужно сгенерировать. В будущем сюда могут быть добавлены дополнительные параметры: тип операции, сумма, статус и т.д.
- SeedsPlan — корневая схема, которая может включать в себя планы генерации разных сущностей (не только операций, но и пользователей, счетов и т.д.).
Этот план подаётся на вход сидеру и определяет его поведение: сколько данных и какого типа нужно создать.
Результат сидинга (SeedsResult)
Объяснение:
- SeedOperationResult — результат генерации одной операции. Хранит её ID, который можно использовать при дальнейшем взаимодействии (например, для запроса по ID).
- SeedsResult — агрегирует все созданные данные, сгруппированные по типу. Здесь у нас только operations, но можно добавить users, accounts и т.п.
- Методы get_next_operation() и get_random_operation() позволяют гибко выбирать данные:по порядку (для синхронных сценариев),случайным образом (для имитации разнообразия в нагрузке).
Почему это удобно
- Явное разделение логики — отдельно описываем, что хотим, и отдельно — что получили.
- Гибкость и масштабируемость — при добавлении новых сущностей (пользователи, счета) план и результат просто расширяются.
- Переиспользуемость — результат можно сериализовать в файл и переиспользовать в других тестах, сидерах или даже в автотестах.
- Типизация и валидация — с помощью pydantic гарантируется корректность данных на входе и выходе.
Реализация сидинг-билдера
Теперь, когда мы определили, что сидинг нужно делать именно через API, давайте реализуем простой, но расширяемый сидинг-билдер. Его задача — получить на вход план сидинга и вернуть результат, пригодный для использования в нагрузочных сценариях.
В нашей системе мы хотим, чтобы сидер:
- создавал заданное количество операций через API,
- возвращал ссылки на созданные сущности (например, ID операций),
- работал через универсальные Pydantic-схемы SeedsPlan и SeedsResult,
- мог быть расширен в будущем — например, для создания пользователей, счетов, документов и так далее.
Весь сидинг построен вокруг одной сущности — operation. Мы используем уже готовый OperationsClient, в котором реализован вызов create_operation() через HTTP API. Каждая созданная операция добавляется в результат (SeedsResult) в виде объекта SeedOperationResult, содержащего ID операции.
На вход подаётся SeedsPlan, в котором указано, сколько операций нужно создать. Это удобно для конфигурирования сидинга извне — например, из JSON-файла или командной строки.
Такая архитектура позволяет легко масштабировать сидинг. Если завтра потребуется сидировать не только операции, но и пользователей, счета, документы — достаточно будет:
- добавить соответствующий блок в SeedsPlan и SeedsResult,
- реализовать метод build_user_result() (или аналогичный),
- обновить метод build() для поддержки новой сущности.
Таким образом, сидинг остаётся прозрачным, расширяемым и строго типизированным.
Сохраняем результат сидинга
После выполнения сидинга у нас на руках оказывается структура SeedsResult, в которой собраны все созданные объекты (например, operation_id операций). Эти данные могут понадобиться позже:
- для запуска нагрузочного сценария,
- для анализа проблем,
- для отладки,
- для повторного использования,
- или для CI/CD пайплайна.
Чтобы не терять эти данные, мы сохраняем результат в файл.
Такой подход даёт сразу несколько преимуществ:
- Повторное использование. Сидинг — это затратная операция. Если вы уже сгенерировали данные, нет смысла делать это повторно. Вместо этого можно загрузить файл и использовать готовые ID.
- Отладка и воспроизводимость. При падении сценария вы можете проверить, какие именно данные использовались. Это повышает прозрачность и снижает вероятность скрытых багов.
- Поддержка CI/CD. В автоматических пайплайнах удобно сначала запустить сидинг, сохранить результат, а затем использовать его в нагрузочном тесте. Особенно, если вы хотите добиться повторяемости тестов.
- Масштабирование. Позже можно реализовать загрузку, кеширование, дифференциацию сценариев, фильтрацию сидированных данных и т.д.
Базовая архитектура нагрузочных сценариев: User, TaskSet, SequentialTaskSet
Теперь, когда у нас есть подготовленные тестовые данные (сидинг), мы можем перейти к следующему важному этапу — изучению архитектуры нагрузочных сценариев. В этом блоке мы не будем реализовывать полноценные сценарии, а сосредоточимся на базовых строительных блоках, из которых они в дальнейшем будут собираться:
- виртуальные пользователи (User),
- наборы задач (TaskSet),
- последовательные сценарии (SequentialTaskSet).
Эти компоненты определяют, какие действия выполняются, с какой частотой и задержкой, какие API вызываются и в какой последовательности. Понимание их работы необходимо для построения более сложных и реалистичных сценариев, к которым мы перейдём позже.
Базовый виртуальный пользователь
Чтобы не повторять одни и те же настройки в каждом сценарии, мы создадим базовый класс пользователя, от которого будут наследоваться все остальные. Он задаёт:
- базовый host (адрес сервиса по умолчанию),
- стратегию ожидания между задачами (wait_time),
- и помечается как абстрактный, чтобы не запускался напрямую.
Зачем нужен абстрактный пользователь?
Locust ищет все классы, унаследованные от User, и считает их «реальными пользователями», которых нужно запускать. Но в нашем случае мы хотим использовать BaseLocustUser только как базу для других классов, чтобы переиспользовать общее поведение и настройки.
Поэтому мы выставляем:
Это позволяет избежать случайного запуска базового класса и делает архитектуру чище.
Почему host = "localhost" — это заглушка?
Locust требует, чтобы у каждого User было указано поле host. Обычно это базовый URL, к которому будут отправляться запросы через встроенный HTTP-клиент (например, HttpUser). Но в нашем случае:
- Мы не используем HttpUser, а базируемся на обычном User
- Все запросы отправляются через кастомные httpx-клиенты, у которых base_url задаётся отдельно
- Поэтому значение host никак не влияет на выполнение сценария, но его необходимо задать, чтобы избежать ошибки запуска
Почему between(1, 3)?
wait_time определяет, сколько времени должен ждать виртуальный пользователь между двумя задачами. Это необходимо для моделирования реального поведения, где пользователь не делает запросы каждую миллисекунду.
- between(1, 3) означает, что между задачами будет случайная пауза от 1 до 3 секунд.
- Этот диапазон можно настроить в каждом конкретном сценарии — например, сделать паузы короче или длиннее.
Базовые TaskSet и SequentialTaskSet
После того как мы определили абстрактного пользователя (BaseLocustUser), следующим шагом будет реализация базовых TaskSet-классов, в которых мы будем описывать сценарии поведения пользователей.
В Locust существуют два базовых типа TaskSet:
Тип Особенность
TaskSet Задачи (tasks) выполняются в произвольном порядке, с учётом веса задач
SequentialTaskSet Задачи выполняются строго последовательно, сверху вниз
- Если вы хотите смоделировать пользователя, который выполняет конкретную бизнес-последовательность (например: логин → покупка → выход), используйте SequentialTaskSet.
- Если поведение состоит из повторяющихся или случайных действий (например: пользователь запрашивает выписку, делает покупку, открывает новый счёт в случайном порядке) — подойдёт обычный TaskSet.
Чтобы не дублировать код в каждом сценарии, мы выносим базовую логику в два абстрактных класса:
Как это использовать в реальных сценариях?
- Если вы хотите написать последовательный сценарий из нескольких шагов, создайте класс, унаследованный от BaseLocustSequentialTaskSet, и опишите задачи в нужном порядке с помощью @task.
- Если сценарий более свободный и пользователь выполняет действия случайно, создайте класс от BaseLocustTaskSet, добавьте задачи с разным weight — и Locust сам будет выбирать порядок выполнения.
Нагрузочные сценарии
В этом разделе мы реализуем два типовых сценария нагрузки:
- один — с созданием данных на лету,
- второй — с использованием заранее подготовленных данных через сидинг.
Первый сценарий имитирует поведение нового пользователя, который только что зашёл в систему, создал операцию (например, покупку), а затем запросил список операций и открыл детальную информацию по одной из них. Такой сценарий хорош для тестирования "холодного старта", но он увеличивает время разгона нагрузки и может искажать метрики из-за первичной инициализации.
Второй сценарий работает с уже сидированными (заранее созданными) данными. Мы заранее создаём операции через API и сохраняем их. Затем, во время нагрузки, пользователь просто делает запросы: получает список, выбирает случайную операцию и запрашивает её детали. Это приближает тест к реальному поведению пользователей и даёт более стабильные и репрезентативные метрики.
Оба подхода имеют право на существование — мы реализуем оба, чтобы вы могли выбрать нужный под свою задачу.
Сценарий без сидинга: создание операции, просмотр списка и деталей
Начнём с реализации самого простого сценария — без предварительно подготовленных данных.
Представим типичную пользовательскую активность: клиент заходит в приложение, совершает какую-либо финансовую операцию (например, покупку), затем переходит в раздел "история операций", просматривает список всех операций и выбирает одну для просмотра деталей. Это последовательный и логически связанный пользовательский поток, который мы и хотим нагрузить.
- Мы используем SequentialTaskSet, чтобы задачи выполнялись в строгом порядке: сначала создаётся операция, затем выполняются чтения.
- Объект operation сохраняется между задачами, чтобы можно было запросить детали по конкретному ID.
- Это логика одного виртуального пользователя, который каждый раз заново создаёт операцию.
Почему используется SequentialTaskSet, а не TaskSet
Мы намеренно используем SequentialTaskSet, чтобы гарантировать порядок выполнения задач:
- сначала создаётся операция,
- затем вызывается метод получения списка операций,
- затем — получение конкретной операции.
Если бы мы использовали обычный TaskSet, Locust бы выбирал задачи в случайном порядке, основываясь на их "весах", и мы бы столкнулись с ситуацией, когда запрос get_operation выполняется до создания операции — что привело бы к ошибкам.
Почему мы не задаём веса задач (@task(n))
Во многих сценариях веса задач указываются для моделирования вероятностей — например, операция "открыть документ" может встречаться чаще, чем "удалить аккаунт".
В данном случае мы используем SequentialTaskSet, а значит порядок жёстко зафиксирован и вес не играет никакой роли. Все задачи выполняются ровно один раз в том порядке, в котором они описаны. Поэтому декораторы @task без аргументов здесь абсолютно корректны.
Зачем проверка if not self.operation
Операция создаётся через внешний API. Это значит, что:
- сервис может временно недоступен (5xx),
- включена защита от нагрузки (429),
- произошла ошибка сериализации, валидации или другая ошибка бизнес-логики.
Если операция не создалась, нет смысла продолжать сценарий. Поэтому мы делаем проверку на None, и мягко выходим, не вызывая get_operation.
Такой подход:
- предотвращает шум в логах и отчётах;
- исключает бессмысленные ошибки;
- повышает устойчивость сценариев.
Конфигурация запуска
Для каждого сценария мы создаём отдельный конфигурационный файл с расширением .conf, в котором зафиксированы все параметры нагрузки: количество пользователей, скорость разгона, длительность теста, путь к файлу сценария и путь к отчёту. Это не просто удобство, а основа инженерного подхода к воспроизводимым нагрузочным тестам.
В отличие от ручного запуска с флагами в командной строке, .conf-файлы:
- позволяют запускать сценарии с одинаковыми параметрами из CI/CD;
- исключают ошибки при повторных прогонах;
- сохраняются в Git и участвуют в истории изменений.
Кроме того, конфигурации мы версионируем — например, v1.0.conf, v2.0.conf. Это даёт нам возможность:
- фиксировать конкретные профили нагрузки, под которые получены результаты;
- отслеживать изменения нагрузки во времени;
- точно сравнивать отчёты только внутри одной и той же версии профиля.
Таким образом, .conf-файл — это не просто способ запускать тесты, а часть архитектуры нагрузочного тестирования, обеспечивающая прозрачность, стабильность и масштабируемость.
Почему нагрузка скромная?
Обратите внимание: в конфигурации указан умеренный профиль нагрузки — всего 20 пользователей, скорость запуска 2 пользователя в секунду, продолжительность 30 секунд. Это не случайность.
Мы работаем с тестовым API https://api.sampleapis.com/fakebank/accounts, у которого установлен рейт-лимит. При избыточной нагрузке сервис начинает возвращать ответы с кодом 429 Too Many Requests. Такие ошибки искажают результат тестирования и мешают сосредоточиться на логике сценария.
Поэтому:
- мы сознательно ограничиваем количество пользователей;
- не стремимся "положить" сервис;
- и соблюдаем культуру инженерного тестирования — тестируем не ради нагрузки, а ради понимания поведения системы под управляемым профилем.
Да и давайте честно — наглеть не будем, особенно в рамках стенда, который создан для обучения и отладки, а не для боевых боёв с миллионами запросов.
Сценарий с использованием сидинга
В отличие от предыдущего сценария, где пользователь сначала создавал данные сам, здесь мы заранее подготовим всё необходимое — операции будут созданы ещё до запуска теста, и виртуальные пользователи будут использовать уже готовые данные.
Зачем это нужно?
- Это ускоряет разгон нагрузки, так как пользователям не нужно тратить время на создание данных.
- Это исключает дополнительную нагрузку на систему от действий, которые не являются основной целью теста (например, регистрация или генерация операций).
- Это позволяет протестировать сценарии "существующих пользователей", что ближе к продакшен-реальности.
Почему используется TaskSet, а не SequentialTaskSet
В этом сценарии порядок выполнения действий не важен:
- пользователь может сначала открыть список операций,
- а потом выбрать одну из них (или наоборот).
Мы намеренно используем TaskSet, чтобы позволить Locust перемешивать задачи и запускать их в случайном порядке — согласно указанным весам. Это даёт более естественное, стохастическое поведение, ближе к реальному использованию.
Как работают веса задач: @task(n)
В TaskSet веса задач управляют частотой вызовов:
- @task(1) означает: задача должна выполняться один раз из всех доступных вызовов.
- @task(3) означает: задача должна выполняться в три раза чаще, чем задача с весом 1.
То есть в данном случае распределение будет:
- get_operations — 25% (1 из 4),
- get_operation — 75% (3 из 4).
Это поведение задаёт соотношение реальных сценариев: пользователь чаще открывает конкретные детали операции, чем весь список.
Использование событий Locust (event hooks)
Ключевой особенностью этого сценария является использование событийной системы Locust, а именно хука @events.init.add_listener:
Этот хук срабатывает один раз перед запуском теста, сразу после инициализации окружения Locust. Именно в нём мы и выполняем сидинг:
- Получаем сидинг-билдер через get_seeds_builder().
- Создаём 20 операций с помощью builder.build(...).
- Сохраняем результат сидинга в файл get_operations_with_seeds_seeds.json.
- Передаём результат в environment.seeds, чтобы он стал доступен всем пользователям.
Почему именно здесь?
- Логика сидинга отделена от сценариев — сценарий остаётся чистым и сфокусированным на бизнес-действиях.
- Сидинг выполняется ровно один раз, независимо от числа пользователей.
- Подготовленные данные попадают в shared state (environment), и могут быть переиспользованы всеми пользователями без дублирования.
Сам сценарий
В методе on_start каждый виртуальный пользователь получает одну случайную операцию из общего результата сидинга. Далее он будет с ней работать.
Пользователь:
- сначала получает список всех операций (не обязательно, но имитирует поведение UI),
- затем делает детальный запрос по своей конкретной операции.
Задание get_operation весит в 3 раза больше, чем get_operations, что приближает поведение к реалистичному сценарию: пользователь может просмотреть список операций один раз, но конкретную операцию — несколько раз (или UI может отправлять такой запрос повторно).
Зачем мы сохраняем результат сидинга?
Мы сохраняем результат в файл, потому что:
- это позволяет повторно использовать данные без повторного сидинга;
- это даёт возможность проанализировать сидированные данные;
- это удобно для отладки: можно посмотреть, какие ID операций были созданы, если что-то пошло не так.
Конфигурация запуска
Эта конфигурация повторяет структуру первого сценария. Используется умеренный профиль нагрузки, так как:
- стенд учебный, и у него есть ограничение на количество запросов (rate limit),
- мы хотим избежать 429 Too Many Requests,
- нам важно сохранить чистоту метрик, не перегружая систему искусственно.
Запуск на CI/CD
Настроим workflow-файл для автоматического запуска нагрузочных тестов в GitHub Actions, генерации Locust HTML-отчета с сохранением истории и публикации его на GitHub Pages.
Ссылки на документацию для всех использованных actions можно найти ниже:
Почему workflow_dispatch?
Даёт возможность запускать тесты вручную с выбором конфигурации. Удобно для запуска под конкретную версию нагрузки или перед релизом.
Зачем сохраняем в reports/${{ github.run_id }}?
Чтобы каждый запуск имел свою уникальную папку с отчётом. Это позволяет:
- сохранять историю запусков,
- публиковать и сравнивать отчёты между собой,
- исключить перезапись.
Почему два job-а?
Мы разделяем логику:
- один отвечает за запуск и генерацию отчёта,
- другой — за публикацию. Это делает пайплайн более управляемым и удобным для отладки.
Почему gh-pages?
Это стандартная ветка GitHub Pages. Любой HTML-файл, добавленный туда, станет доступен по адресу https://<user>.github.io/<repo>/<путь>.
Почему keep_files: true?
Мы сохраняем отчёты за все предыдущие прогоны. Это важно для анализа динамики производительности во времени, особенно при регрессии.
Разрешения для Workflow
Если сейчас запустить нагрузочные тесты на GitHub Actions то, будет ошибка, говорящая о том, что у github token из workflow по умолчанию нет прав на записть в репзоиторий
Для исправления этой ошибки необходимо вручную изменить настройки прав workflow:
- Откройте вкладку Settings в репозитории GitHub.
2. Перейдите в раздел Actions → General.
3. Прокрутите страницу вниз до блока Workflow permissions
4. Выберите опцию Read and write permissions
5. Нажмите кнопку Save для сохранения изменений
После выполнения этих шагов можно отправить код с нагрузочными тестами в удалённый репозиторий.
Запуск нагрузочного теста вручную через GitHub Actions
После того как вы настроили workflow-файл и закоммитили его в репозиторий, вы можете запускать нагрузочные тесты в один клик прямо из интерфейса GitHub.
- Перейдите на вкладку Actions вашего репозитория — она находится в верхнем меню.
- В списке workflow-ов слева выберите Load Tests.
- Нажмите на кнопку Run workflow в правом верхнем углу.
- В выпадающем списке Locust config file выберите нужный .conf-файл — например, ./scenarios/get_operation_with_seeds/v1.0.conf. Это определит, какой именно сценарий нагрузки будет выполнен.
- Нажмите зелёную кнопку Run workflow.
После этого GitHub Actions запустит пайплайн: прогонит тест, сгенерирует HTML-отчёт Locust и (если всё настроено) опубликует его на GitHub Pages.
Если нагрузочные тесты пройдут успешно, Locust HTML-отчёт будет сгенерирован и загружен в ветку gh-pages, после чего автоматически запустится workflow pages build and deployment. Этот процесс публикует Locust HTML-отчёт на GitHub Pages, делая его доступным для просмотра в браузере.
Важно! Перед запуском workflow необходимо убедиться, что в репозитории существует ветка gh-pages. Если ветка отсутствует, её необходимо создать в удалённом репозитории, иначе публикация Locust HTML-отчёта на GitHub Pages не будет работать.
Проверка настроек GitHub Pages
Если workflow pages build and deployment не запустился, необходимо проверить настройки GitHub Pages:
- Откройте вкладку Settings в репозитории.
- Перейдите в раздел Pages → Build and deployment.
- Убедитесь, что параметры соответствуют настройкам на скриншоте ниже.
На этой же странице будет отображаться виджет со ссылкой на опубликованный Allure-отчёт.
Доступ к Locust HTML-отчётам
- Каждый отчёт публикуется на GitHub Pages с уникальным идентификатором run id, в котором он был сгенерирован.
- Все сгенерированные Locust HTML-отчёты также можно найти в ветке gh-pages.
- Перейдя по ссылке на GitHub Pages, можно открыть сгенерированный Locust HTML-отчёт с результатами нагрузочного тестирования.
По итогу, после корректной настройки, при каждом новом запуске нагрузочных тестов Locust HTML-отчёт будет автоматически сохраняться в ветку gh-pages и публиковаться на GitHub Pages.
Заключение
Все ссылки на код, отчеты и запуски нагрузочных тестов в CI/CD можно найти на моем GitHub: