← Все статьи

Нагрузочное тестирование

Нагрузочное тестирование: как моделировать поведение пользователей и писать реалистичные сценарии

Почему большинство нагрузочных тестов (load testing) не имеют смысла: разберём, как моделировать реальное поведение пользователей, а не просто генерировать запросы.

нагрузочное тестированиеload testingperformance testinglocustтестирование производительностиqaqa automationqa performanceнагрузочные тестыrps

Нагрузочное тестирование: как моделировать поведение пользователей и писать реалистичные сценарии

Вступление

Когда говорят про нагрузочное тестирование, чаще всего представляют одно и то же: подняли пользователей, начали «долбить» API, получили графики — на этом всё. Проблема в том, что такие тесты почти никогда не имеют отношения к реальности.

Система вроде бы «нагружена», метрики есть, отчёт построен — но поведение, которое вы проверяете, не имеет ничего общего с тем, как работают реальные пользователи. А значит, и выводы из такого теста — сомнительные.

Нагрузочный тест имеет смысл только в одном случае: если он моделирует реальное поведение пользователей. Не просто генерирует запросы, а воспроизводит сценарии, последовательность действий и характер нагрузки, которые действительно происходят в системе.

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

Важно сразу зафиксировать контекст. Речь пойдёт именно про load testing — проверку системы под ожидаемой реальной нагрузкой. Не про стресс-тестирование, не про хаос и не про spike-сценарии.

Почему «долбить один эндпоинт» — это не нагрузочное тестирование

Одна из самых частых ошибок при написании нагрузочных сценариев — подмена понятий.

Берут один эндпоинт, поднимают пользователей и начинают отправлять в него запросы с максимальной частотой. Чем больше — тем лучше. Чем быстрее «сломается» — тем показательнее.

На первый взгляд это выглядит как нагрузочное тестирование. На практике — это уже ближе к стресс-тестированию, причём в очень упрощённой форме. Проблема даже не в типе теста. Проблема в том, что такое поведение не имеет ничего общего с реальностью. Реальные пользователи не «долбят» один эндпоинт. Они работают со сценарием.

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

И в каждый момент времени пользователь создаёт разную нагрузку на систему:

  • сначала один набор эндпоинтов,
  • потом другой,
  • затем третий.

То есть нагрузка распределена по системе, а не сосредоточена в одной точке.

Когда в тесте вы «забиваете» один эндпоинт до предела, вы проверяете не поведение системы под реальной нагрузкой, а её реакцию на искусственный сценарий, который в продакшене может никогда не произойти.

Из этого следуют искажённые выводы:

  • можно «сломать» систему там, где в реальности всё стабильно
  • или наоборот — не увидеть проблемы, которые возникают в реальных пользовательских сценариях

Think Time: пользователь не работает мгновенно

Есть ещё один важный момент, который часто игнорируют — пользователь не выполняет действия непрерывно. Он не может за одну секунду открыть три экрана и отправить несколько запросов одновременно. Между действиями всегда есть пауза:

  • пользователь ждёт загрузку,
  • читает данные,
  • принимает решение,
  • переходит на следующий экран.

Эти паузы и есть think time — время, в течение которого пользователь «ничего не делает» с точки зрения системы.

Если think time не учитывать, нагрузка становится синтетической:

  • пользователи начинают выполнять действия без остановки
  • RPS искусственно растёт
  • система получает нагрузку, которой в реальности не существует

И дальше начинается классическая ошибка: система «не выдерживает нагрузку», хотя на самом деле она не выдерживает нереалистичный сценарий.

Pacing: пользователи не синхронны

Даже если у вас в системе одновременно находится 500 пользователей, это не означает, что они действуют синхронно.

Они:

  • заходят в разное время
  • по-разному проходят сценарий
  • делают паузы разной длины

В результате нагрузка распределяется во времени.

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

Для решения этой проблемы используется pacing — контроль частоты выполнения сценария одним пользователем.

Например, можно задать правило: один пользователь проходит сценарий не чаще одного раза в 10 секунд. Даже если он завершил его быстрее, он подождёт перед следующим запуском.

Это даёт две важные вещи:

  • предсказуемую нагрузку
  • более реалистичное распределение запросов

Что это меняет

Как только вы начинаете учитывать сценарии, think time и pacing, нагрузочный тест перестаёт быть «генератором запросов» и превращается в модель поведения пользователей.

И только после этого имеет смысл говорить:

  • о выборе инструмента
  • о настройке нагрузки
  • и о каких-либо выводах по результатам теста

Без этого нагрузочное тестирование остаётся формальностью — с графиками, цифрами и очень сомнительной пользой.

Инструменты: почему сценарии важнее самого инструмента

После того как появляется понимание, что нагрузка — это не набор запросов, а модель поведения пользователей, меняется и критерий выбора инструмента. Вопрос уже не в том, «сколько RPS он может сгенерировать». Вопрос — насколько удобно в нём описывать сценарии.

И здесь важно сразу зафиксировать: инструмент — это вторично. Если сценарий нереалистичный, никакой инструмент это не исправит. Он просто быстрее или медленнее воспроизведёт ту же ошибку. Тем не менее, есть инструменты, которые изначально лучше подходят под сценарный подход. Один из таких — Locust.

Его ключевое преимущество в том, что он из коробки ориентирован не на «стрельбу по эндпоинтам», а на моделирование поведения пользователей.

Что это даёт на практике:

  • TaskSet’ы и задачи позволяют описывать сценарии, а не отдельные запросы. Пользователь — это не один вызов API, а последовательность действий.
  • Веса задач позволяют задать, какие действия происходят чаще, а какие — реже. Это напрямую влияет на распределение нагрузки по системе.
  • wait_time (think time) позволяет встроить паузы между действиями и сделать поведение пользователей реалистичным.
  • spawn rate даёт контроль над тем, как пользователи «приходят» в систему — плавно или резко, что важно для моделирования реального трафика.

В итоге вы описываете не нагрузку как таковую, а поведение пользователя: что он делает, в каком порядке и с какой частотой. И это принципиально другой уровень моделирования.

Важно при этом понимать, что Locust — не единственный инструмент. Тот же k6, JMeter или Gatling тоже позволяют реализовывать сценарии и управлять нагрузкой.

Но на практике именно Locust часто оказывается удобным, когда стоит задача воспроизвести пользовательские сценарии с контролем логики, данных и последовательности действий.

Поэтому выбор инструмента — это не про «что быстрее стреляет», а про то, где проще и прозрачнее описать реальное поведение пользователей.

Ниже — несколько примеров сценариев на Python + Locust, которые моделируют реальное поведение пользователей.

Пример 1: пользователь заходит и получает документы

В этом сценарии моделируется поведение уже существующего пользователя. Он заходит в систему, смотрит счета и открывает связанные документы — это типичный read-heavy сценарий, который хорошо отражает реальную пользовательскую нагрузку.

from locust import task, events
from locust.env import Environment

from clients.http.gateway.locust import GatewayHTTPTaskSet
from seeds.scenarios.existing_user_get_documents import ExistingUserGetDocumentsSeedsScenario
from seeds.schema.result import SeedUserResult
from tools.locust.user import LocustBaseUser


# Хук инициализации Locust.
# Выполняется ОДИН раз перед стартом нагрузки.
# Здесь мы подготавливаем тестовые данные (сидинг),
# чтобы во время теста не тратить ресурсы на их генерацию.
@events.init.add_listener
def init(environment: Environment, **kwargs):
    # Создаём сценарий генерации данных
    seeds_scenario = ExistingUserGetDocumentsSeedsScenario()

    # Генерируем пользователей, счета, документы и т.д.
    # Это имитирует реальное состояние системы (как в проде)
    seeds_scenario.build()

    # Загружаем результат (обычно из JSON / хранилища)
    # и кладём в environment, чтобы все пользователи могли его использовать
    environment.seeds = seeds_scenario.load()


# TaskSet — это описание поведения пользователя.
# Не одного запроса, а набора действий.
class GetDocumentsTaskSet(GatewayHTTPTaskSet):

    # Здесь мы будем хранить конкретного пользователя из сидинга
    seed_user: SeedUserResult

    def on_start(self) -> None:
        super().on_start()

        # Каждый виртуальный пользователь получает СВОЕГО пользователя
        # Важно: мы не создаём его в рантайме, а берём из подготовленных данных
        # Это убирает лишнюю нагрузку и делает сценарий детерминированным
        self.seed_user = self.user.environment.seeds.get_next_user()

    @task(1)
    def get_accounts(self):
        # Первый шаг сценария — пользователь открывает список счетов
        # Это типичное действие при входе в приложение
        self.accounts_gateway_client.get_accounts(
            user_id=self.seed_user.user_id
        )

    @task(2)
    def get_tariff_document(self):
        # Пользователь открывает документ по сберегательному счёту
        # Вес задачи = 2 → выполняется чаще, чем get_accounts
        self.documents_gateway_client.get_tariff_document(
            account_id=self.seed_user.savings_accounts[0].account_id
        )

    @task(2)
    def get_contract_document(self):
        # Пользователь открывает договор по карте
        # Это альтернативное действие внутри сценария
        self.documents_gateway_client.get_contract_document(
            account_id=self.seed_user.debit_card_accounts[0].account_id
        )


# Класс пользователя — точка входа для Locust.
# Именно такие "пользователи" будут создаваться при запуске теста.
class GetDocumentsScenarioUser(LocustBaseUser):
    tasks = [GetDocumentsTaskSet]

Пример 2: пользователь совершает операцию и проверяет результат

Здесь сценарий становится ближе к реальному бизнес-флоу: пользователь не просто читает данные, а изменяет состояние системы и сразу проверяет результат. Такие сценарии важны, потому что позволяют увидеть поведение системы под смешанной нагрузкой — запись + чтение.

from locust import task, events
from locust.env import Environment

from clients.http.gateway.locust import GatewayHTTPTaskSet
from seeds.scenarios.existing_user_make_purchase_operation import ExistingUserMakePurchaseOperationSeedsScenario
from seeds.schema.result import SeedUserResult
from tools.locust.user import LocustBaseUser


# Хук инициализации.
# Выполняется один раз перед стартом теста.
# Подготавливаем "реальный" набор пользователей и данных.
@events.init.add_listener
def init(environment: Environment, **kwargs):
    # Сценарий сидинга: создаёт пользователей, счета, карты и операции
    seeds_scenario = ExistingUserMakePurchaseOperationSeedsScenario()

    # Генерируем данные заранее
    # Важно: это убирает влияние генерации данных на метрики теста
    seeds_scenario.build()

    # Загружаем результат (обычно из файла)
    # Все виртуальные пользователи будут использовать этот пул
    environment.seeds = seeds_scenario.load()


# TaskSet — описание поведения пользователя.
# Здесь мы моделируем не один запрос, а последовательность действий.
class MakePurchaseOperationSequentialTaskSet(GatewayHTTPTaskSet):

    # Ссылка на пользователя из сидинга
    seed_user: SeedUserResult

    def on_start(self) -> None:
        super().on_start()

        # В отличие от предыдущего примера, здесь берём СЛУЧАЙНОГО пользователя
        # Это даёт более равномерное распределение нагрузки по данным
        self.seed_user = self.user.environment.seeds.get_random_user()

    @task(1)
    def make_purchase_operation(self):
        # Ключевое действие сценария — пользователь совершает покупку
        # Это не "чтение", а изменение состояния системы
        self.operations_gateway_client.make_purchase_operation(
            card_id=self.seed_user.credit_card_accounts[0].physical_cards[0].card_id,
            account_id=self.seed_user.credit_card_accounts[0].account_id
        )

    @task(2)
    def get_accounts(self):
        # После операции пользователь проверяет свои счета
        # Например, чтобы увидеть обновлённый баланс
        self.accounts_gateway_client.get_accounts(
            user_id=self.seed_user.user_id
        )

    @task(2)
    def get_operations(self):
        # Пользователь открывает список операций
        # Это типичное поведение после совершения платежа
        self.operations_gateway_client.get_operations(
            account_id=self.seed_user.credit_card_accounts[0].account_id
        )

    @task(2)
    def get_operations_summary(self):
        # Дополнительно пользователь может посмотреть агрегированную статистику
        # (например, траты за период)
        self.operations_gateway_client.get_operations_summary(
            account_id=self.seed_user.credit_card_accounts[0].account_id
        )


# Класс пользователя — точка входа для Locust
class MakePurchaseOperationScenarioUser(LocustBaseUser):
    tasks = [MakePurchaseOperationSequentialTaskSet]

Пример 3: пользователь создаёт данные и работает с ними

В этом случае пользователь приходит в систему «с нуля»: регистрируется, создаёт сущности и начинает ими пользоваться. Это отдельный класс сценариев с зависимостями между шагами, который важно учитывать при моделировании, но использовать аккуратно из-за генерации новых данных под нагрузкой.

from locust import task

from clients.http.gateway.locust import GatewayHTTPTaskSet
from clients.http.gateway.users.schema import CreateUserResponseSchema
from tools.locust.user import LocustBaseUser


# TaskSet — сценарий нового пользователя
class GetAccountsTaskSet(GatewayHTTPTaskSet):

    # Здесь мы будем хранить результат создания пользователя
    # Он нужен для последующих шагов сценария
    create_user_response: CreateUserResponseSchema | None = None

    @task(2)
    def create_user(self):
        # Первый шаг — пользователь регистрируется
        # Это "точка входа" в сценарий
        self.create_user_response = self.users_gateway_client.create_user()

    @task(2)
    def open_deposit_account(self):
        # Этот шаг зависит от предыдущего
        # Без пользователя мы не можем открыть счёт
        if not self.create_user_response:
            return

        # Пользователь открывает депозитный счёт
        self.accounts_gateway_client.open_deposit_account(
            user_id=self.create_user_response.user.id
        )

    @task(6)
    def get_accounts(self):
        # Основной сценарий — пользователь регулярно смотрит свои счета
        # Поэтому вес выше (выполняется чаще)
        if not self.create_user_response:
            return

        self.accounts_gateway_client.get_accounts(
            user_id=self.create_user_response.user.id
        )


# Класс пользователя
class GetAccountsScenarioUser(LocustBaseUser):
    tasks = [GetAccountsTaskSet]

Заключение

Нагрузочное тестирование — это не про количество запросов, а про поведение пользователей. Если сценарий нереалистичный — все метрики теряют смысл, независимо от инструмента и объёма нагрузки.