✻ Урок 9.3 · Тема 9: Locust
Тестовые данные и проверки ответов
Содержание урока
Зачем это нужно
До сих пор твой тест считал успехом любой ответ с кодом меньше 400. Допущение опасное. Сервис может вернуть 200 OK и при этом пустой список товаров, чужую корзину или JSON с текстом «внутренняя ошибка». Для Locust это зелёный запрос, для покупателя катастрофа. (Кэш это быстрая память, где магазин держит недавние ответы, чтобы не ходить в базу.) Я много раз видел такие тесты: ошибок ноль, задержки крошечные (пустой ответ строится быстро), а магазин на самом деле сломан. Такой тест меряет скорость ответа «что-нибудь», а не «правильное».
Вторая проблема в данных. Если все пользователи ходят за одним товаром, весь тест читает одну строку из кэша, и цифры выходят фантастические. Если пользователей в тесте больше, чем аккаунтов в базе, они делят аккаунты и мешают друг другу. Данные должны быть разнообразными и согласованными: реальные учётные записи, реальные id.
Шаг проекта: ты генерируешь ~/perf-lab/09-locust/users.csv и подключаешь его, добавляешь в сценарий проверку ответа и разделяешь ошибки на три вида: «магазин сломан», «сценарий сломан» и «штатный отказ» (сервис правильно сказал «нет»).
Что нужно знать
- Сценарий,
SequentialTaskSet, корреляция, токен: урок 9.2. - Коды ответов HTTP (200, 201, 400, 401, 404, 409, 5xx): урок 2.1.
- Проверки в
pytest,assert: урок 6.1. Тут та же идея, только проверка не останавливает тест, а помечает запрос. - Чтение CSV-файла в Python: модуль
csv, словари: урок 4.4.
Картина целиком
Контролёр на конвейере проверяет не только «изделие выехало», но и «изделие правильное». Если конвейер быстро выдаёт пустые коробки, показатель «штук в час» отличный, а качество нулевое. Проверки в сценарии это контролёр: после каждого запроса он решает «годится или брак» и записывает причину. Аналогия ломается тем, что у контролёра есть время подумать, а проверки теста должны быть быстрыми: они работают на генераторе и отнимают у него силы.
flowchart TD
A["Ответ получен"] --> B{"Код ожидаемый?"}
B -->|нет| F["Ошибка: код"]
B -->|да| C{"Тело разобрано<br>и поля на месте?"}
C -->|нет| G["Ошибка: содержимое"]
C -->|да| D{"Значения<br>правильные?"}
D -->|нет| H["Ошибка: данные"]
D -->|да| T{"Быстрее<br>порога?"}
T -->|нет| I["Ошибка: медленно"]
T -->|да| E["Успех"]
Что здесь видно: успехом запрос становится только после четырёх проверок подряд: код, форма ответа, значения и время (последняя нужна не везде). Каждая ловит свой класс поломок и пишет своё сообщение, по которому на вкладке Failures видно, что именно сломалось.
Теория
Как Locust решает, что запрос успешный
По умолчанию Locust действует как requests: если код ответа 4xx или 5xx, запрос неудачный, всё остальное успешное. Правило простое и ловит типичные поломки, но о содержимом молчит. Это как сторож на входе, который смотрит только «есть пропуск или нет», а не «чей он». Чужой пропуск с правильным бланком он пропустит.
Чтобы вмешаться в решение, добавляют catch_response=True и используют ответ как контекстный менеджер. Это блок with: Python входит в него, выполняет твой код и на выходе сам доделывает нужное (для файла закрывает его, здесь записывает итог запроса). Ты видел такой блок в уроке 4.4 при работе с файлами:
with self.client.get("/api/products", catch_response=True) as response:
if not response.json()["items"]:
response.failure("каталог пуст")
catch_response=True говорит Locust: «не записывай результат сам, дай мне решить». Внутри блока ответ доступен как response, а когда блок кончится, Locust запишет итог. response.failure("текст") помечает запрос как неудачный, и этот текст попадёт на вкладку Failures. response.success() явно помечает успех: он нужен, когда код 4xx на самом деле штатный. Если внутри ничего не вызвать, действует правило по умолчанию. Время запроса записывается в любом случае, а «успех или неудача» определяется тобой.
Возьмём ответ GET /api/products?page=2 с кодом 200 и телом:
{"items": [], "page": 2, "size": 20, "total": 0}
По умолчанию это успех. Проверка if not response.json()["items"] замечает пустой список и вызывает failure("каталог пуст"). Теперь запрос красный, а в Failures строка GET /api/products: каталог пуст. Без проверки ты увидел бы идеальные 3 мс и ноль ошибок на сломанной базе.
Прикинь сам: ответ пришёл с кодом 200, но в теле
{"detail": "internal error"}. Что видит Locust по умолчанию и что нужно добавить?
По умолчанию это успех: код ниже 400. Нужны catch_response=True и проверка тела: if "detail" in response.json(): response.failure(...). Так ошибка под маской 200 попадёт в Failures.
Осторожно: catch_response=True сам ничего не проверяет, он лишь отдаёт решение тебе. И если внутри блока код упал исключением (скажем, KeyError на отсутствующем поле или JSONDecodeError на теле не в JSON), оно вылетает из with наружу: запрос не попадает в Failures, а ошибка появляется на вкладке Exceptions (и в логе), задача обрывается, и понятное сообщение теряется. Поэтому в проверках пользуйся .get() или in, а не прямым обращением к ключу.
Главное: по умолчанию «успех» это код ниже 400, а чтобы проверить содержимое, забирай решение у Locust через
catch_response=True.
Мы знаем, как сообщить о браке. Что именно проверять?
Что проверять: четыре уровня
Чем строже проверка, тем больше ошибок поймано, но тем больше работы генератору. Нужен разумный набор, который ловит всё, что значит «пользователь получил не то», и не превращает генератор в узкое место. Проверки идут от дешёвых к дорогим.
Первая: код ответа. Это дёшево и нужно всегда: для создания ждём 201, для чтения 200. «Код меньше 400» слишком мягкая проверка, 202 или 304 тоже пройдут. Вторая: форма. Разбирается ли тело как JSON (текст с полями вроде {"id": 5}, в таком виде магазин отвечает) и есть ли нужные поля (items, token, id); цена одна, разбор JSON. Третья: значения. Список не пуст, id в ответе равен запрошенному, в заказе то, что положили. Четвёртая: время. Бизнес-порог вида «каталог медленнее 500 мс считается неудачей» (это цель надёжности, SLO, из урока 8.4, только на уровне запроса). Время доступно как response.elapsed.total_seconds().
Правило: на каждый маршрут проверяй код и форму, а значения только те, которые доказывают смысл. Сверять каждое поле дорого и хрупко: переименовали поле, и тест краснеет. Для GET /api/products/{id} хватит трёх проверок: код 200, в JSON есть id, и id равен тому, что просили. Если магазин по ошибке вернёт товар с другим id (кэш на это способен, разберём в уроке 11.5), поймает это только третья.
Прикинь сам: на какой уровень попадётся баг, при котором
/api/products/5возвращает товар 7?
Только на третий, значения: нужно сверить response.json()["id"] с запрошенным числом. Код 200 и поле id пройдут, а ответ чужой.
Осторожно: сверку каждого поля ответа с полным описанием (схемой) в нагрузочном тесте не делают, это работа функциональных API-тестов (тема 6). Здесь минимум, который доказывает «пользователь получил то, ради чего пришёл».
Главное: проверяй код и форму всегда, значения выборочно, и не превращай генератор в проверяльщика каждого поля.
Допустим, проверки есть, и в конце теста на вкладке Failures 300 строк. Что с ними делать?
Три вида ошибок
Ответ зависит от того, какая это ошибка. Без разделения отчёт получается «ошибок 3%» без смысла. Представь три причины, по которым посылка не дошла: курьер её сломал, на ней неверный адрес, адресат отказался принимать. Результат один, а виновники и действия разные. Для теста это три класса.
Первый: сервис сломан. Коды 500, 502, 503 (database pool timeout), 504 (payment timeout), обрыв соединения и таймаут клиента. Магазин не справился, и это результат теста: ищем узкое место. Второй: сценарий сломан. 401 (токен не передан), 404 на товар, которого нет, 422 (неверное тело запроса), KeyError в коде. Ты неправильно написал тест, чинить надо его, а не магазин. Третий: штатный отказ. 400 cart is empty при заказе, 409 not enough stock, 401 при неверном пароле. Сервис правильно сказал «нет»: ошибкой это не считают, но долю отказов отслеживают. Все коды и тексты взяты из project/shop/shop/app: например, 422 магазин (на FastAPI) отдаёт сам, когда тело запроса неверной формы.
flowchart TD
A["Запрос неуспешен"] --> B{"Код ответа?"}
B -->|"5xx, таймаут,<br>обрыв"| C["Сервис сломан<br>результат теста"]
B -->|"401, 404, 422"| D["Сценарий сломан<br>чини тест"]
B -->|"400, 409<br>с ожидаемым текстом"| E["Штатный отказ<br>success + следить за долей"]
Что здесь видно: сначала код решает, чья проблема. Только первая ветка попадает в отчёт как дефект магазина.
Штатные отказы обрабатывают явно, но с условием: и код, и текст должны совпасть точно. Любой другой 400 остаётся ошибкой, так ты не замажешь настоящую проблему:
if response.status_code == 400 and response.json().get("detail") == "cart is empty":
response.success()
И правило, которое экономит часы: сообщение в failure() должно быть постоянным. Вкладка Failures группирует ошибки по тексту. Если написать failure(f"заказ {order_id} без товаров"), каждая ошибка получит свою строку, и вместо одной группы «заказ без товаров: 300 раз» ты увидишь сотню единичных записей. Подробности печатай в консоль Locust (print(...) или logging.warning(...)), а в сообщение пиши только класс проблемы. Допустима переменная часть из малого набора (несколько вариантов, не сотни), например код ответа.
Прикинь сам: на вкладке Failures 120 ошибок
POST /api/orders: 409, текст «not enough stock». Баг магазина, баг теста или норма?
Допустим, это магазин с настоящими остатками (на нашем стенде у каждого товара миллион штук, такой ошибки у нас не будет). Он ведёт себя правильно: отказывает, когда товара не хватает. Но 120 штук значит, что остатки популярных товаров кончились: тест слишком долго бил в одни и те же. Это штатный отказ, а лечится он данными: чаще менять товары.
Осторожно: таймаут клиента не ошибка сервера. Если Locust оборвал запрос по timeout=, сервер мог просто долго работать. В статистике это «ошибка 0» без кода ответа, и в отчёте надо писать «клиент потерял терпение через 30 с», а не «сервер ответил ошибкой».
Главное: прежде чем считать ошибку дефектом магазина, определи, чья она: сервиса, сценария или это штатный отказ.
Проверки есть, ошибки разложены. Осталось дать тесту хорошие данные.
Тестовые данные: откуда брать пользователей и товары
Данные решают три задачи. Запросы должны быть разными (чтобы не попасть в один кэш), валидными (существующие id, верные пароли) и независимыми (пользователи не мешают друг другу). Нарушишь любую, и цифры недостоверны. Это как репетиция, где все статисты берут один реквизит: толкаются у одного стола, а остальная сцена пуста.
Источников четыре, и все нам пригодятся. Случайные значения в коде, random.randint(1, 10000) для id товара: просто и разнообразно, но два прогона получат разные id, а для GET обычно достаточно. Чтобы прогон можно было повторить, напиши в начале файла random.seed(42) (любое фиксированное число): тогда последовательность «случайных» id повторяется от запуска к запуску (какому пользователю какой id достанется, может чуть отличаться: пользователи берут числа в разном порядке). CSV-файл с учётными записями: воспроизводимо и под контролем. Подготовка перед тестом: скрипт создаёт данные, когда в базе их нет. И данные из ответов, корреляция из урока 9.2.
В уроке 9.2 номер аккаунта выдавал счётчик, потому что пока хватало формулы user0007@shop.lab. CSV заменяет эту формулу: теперь аккаунты берутся из файла, и их можно менять, не трогая код. Как раздать пользователей из CSV так, чтобы они не делили аккаунты? Читаем файл один раз на весь процесс, при импорте locustfile, и раздаём по кругу:
import csv
import itertools
from pathlib import Path
def load_users():
path = Path(__file__).with_name("users.csv")
with path.open(newline="", encoding="utf-8") as f:
return list(csv.DictReader(f))
ACCOUNTS = load_users()
USERS = itertools.cycle(ACCOUNTS)
Path(__file__).with_name("users.csv") даёт путь к файлу рядом с locustfile (__file__ это путь к самому сценарию), и неважно, откуда ты запустил Locust. csv.DictReader читает CSV как список словарей: каждая строка это {"email": "user0001@shop.lab", "password": "password"}, ключи берутся из первой строки файла. itertools.cycle(ACCOUNTS) делает итератор: объект, который на каждый next(...) выдаёт следующий элемент, и после тысячного снова первый. Файл читается один раз, а не при создании каждого пользователя: иначе 500 пользователей прочитали бы его 500 раз.
Прикинь сам: в CSV 100 аккаунтов, а тест запускает 300 пользователей, которые берут аккаунт из CSV (например, 300 покупателей). Что случится и как это заметить?
Каждую запись будут использовать три виртуальных пользователя одновременно. Корзина привязана к аккаунту, поэтому один положит товар, а другой оформит его заказ. Появятся 400 «cart is empty» там, где ждали 201, и лишние ожидания на одних и тех же строках в базе. Заметишь по росту 400 и 409 на /api/orders. Решение: данных не меньше, чем пользователей. Магазин даёт тысячу, этого хватает на все тесты курса.
Осторожно: CSV часто читают внутри on_start. Это работает, но создаёт лишнюю нагрузку на диск при запуске и риск, что два пользователя получат одну строку. А общий итератор USERS на весь файл безопасен: Locust переключает пользователей только пока они ждут сеть, а next(...) не ждёт, поэтому два пользователя не схватят одну запись.
Главное: данные теста должны быть разными, существующими и по одной записи на пользователя, а читать файл надо один раз на процесс.
Вернёмся к распродаже: теперь тест не зелёный «по умолчанию». Он отличает сломанный магазин от сломанного сценария, и менеджер получит ошибки, которым можно верить.
Практика
1. Сгенерируй CSV с пользователями
В базе стенда есть user0001@shop.lab … user1000@shop.lab, пароль password (урок 2.1). Создай файл с ними. Скрипт ~/perf-lab/09-locust/make_users.py:
"""Создаёт users.csv для Locust: 1000 учётных записей стенда «Магазин»."""
import csv
with open("users.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["email", "password"])
for n in range(1, 1001):
writer.writerow([f"user{n:04d}@shop.lab", "password"])
print("записано 1000 строк")
cd ~/perf-lab/09-locust && python make_users.py && head -3 users.csv && wc -l users.csv
записано 1000 строк
email,password
user0001@shop.lab,password
user0002@shop.lab,password
1001 users.csv
head -3 показывает первые три строки, wc -l считает строки: 1000 пользователей плюс заголовок дают 1001.
Как читать вывод: первая строка файла это заголовок, он нужен DictReader для имён ключей. Если строк не 1001, скрипт запускался в неправильном каталоге или оборвался.
Файл не секрет (пароль известен всему курсу), но в чужие проекты такие файлы с настоящими паролями класть нельзя: в реальной работе добавь users.csv в .gitignore и храни тестовые данные вне репозитория. Для учебного стенда мы коммитим его, и в README лаборатории пишем одной строкой, откуда файл берётся.
2. Подключи данные и проверки в locustfile
Замени в ~/perf-lab/09-locust/locustfile.py верх файла и классы покупателя на этот вариант (Visitor ты тоже улучшишь):
"""Сценарии «Магазина»: посетитель и покупатель с данными из CSV и проверками."""
import csv
import itertools
import random
from pathlib import Path
from locust import HttpUser, SequentialTaskSet, between, task
from locust.exception import StopUser
def load_users():
path = Path(__file__).with_name("users.csv")
with path.open(newline="", encoding="utf-8") as f:
return list(csv.DictReader(f))
ACCOUNTS = load_users()
USERS = itertools.cycle(ACCOUNTS)
def check(response, status, *fields):
"""Проверяет код, JSON и обязательные поля. Возвращает тело или None."""
if response.status_code != status:
response.failure(f"код {response.status_code}, ожидался {status}")
return None
try:
body = response.json()
except ValueError:
response.failure("ответ не JSON")
return None
for field in fields:
if field not in body:
response.failure(f"нет поля {field}")
return None
return body
class Visitor(HttpUser):
host = "http://localhost:8000"
wait_time = between(1, 3)
weight = 4
@task(5)
def catalog(self):
with self.client.get("/api/products", params={"page": random.randint(1, 10)},
catch_response=True, name="/api/products") as r:
body = check(r, 200, "items", "total")
if body is not None and not body["items"]:
r.failure("каталог пуст")
@task(3)
def product(self):
product_id = random.randint(1, 10000)
with self.client.get(f"/api/products/{product_id}", catch_response=True,
name="/api/products/[id]") as r:
body = check(r, 200, "id", "price")
if body is not None and body["id"] != product_id:
r.failure("вернулся другой товар")
@task(1)
def search(self):
query = f"Товар {random.randint(1, 99)}"
with self.client.get("/api/products", params={"q": query}, catch_response=True,
name="/api/products?q") as r:
check(r, 200, "items")
@task(1)
def categories(self):
with self.client.get("/api/categories", catch_response=True) as r:
body = check(r, 200)
if body is not None and len(body) != 20:
r.failure("число категорий не 20")
Разбор новых деталей. Функция check собирает все три первых проверки в одном месте: код, JSON, поля. Звёздочка в *fields значит «любое число дополнительных аргументов»: их можно передать сколько нужно (check(r, 200, "items", "total")), внутри они приходят как кортеж. Функция возвращает разобранное тело, чтобы не вызывать response.json() второй раз. В catalog проверка значения: список не пуст. В product проверка «вернулся тот же товар». В categories ответ это не словарь, а список, поэтому fields пусты, а длину проверяем отдельно: в базе ровно 20 категорий. В search параметр q ищет по названию (ILIKE), поэтому name="/api/products?q" собирает все поисковые запросы в одну строку статистики: параметр в имени мы записали сами.
Теперь покупатель:
class Purchase(SequentialTaskSet):
def on_start(self):
self.ids = []
self.product_id = None
self.order_id = None
self.cart_has_item = False
@task
def catalog(self):
with self.client.get("/api/products", params={"page": random.randint(1, 10)},
catch_response=True, name="/api/products") as r:
body = check(r, 200, "items")
if body is not None:
self.ids = [item["id"] for item in body["items"]]
if not self.ids:
r.failure("каталог пуст")
@task
def open_product(self):
self.product_id = random.choice(self.ids) if self.ids else random.randint(1, 10000)
with self.client.get(f"/api/products/{self.product_id}", catch_response=True,
name="/api/products/[id]") as r:
body = check(r, 200, "id")
if body is not None and body["id"] != self.product_id:
r.failure("вернулся другой товар")
@task
def add_to_cart(self):
with self.client.post("/api/cart/items", json={"product_id": self.product_id, "qty": 1},
catch_response=True) as r:
body = check(r, 201, "items", "total")
if body is not None and not any(i["product_id"] == self.product_id for i in body["items"]):
r.failure("товара нет в корзине")
elif body is not None:
self.cart_has_item = True
@task
def checkout(self):
self.order_id = None
with self.client.post("/api/orders", catch_response=True, timeout=60) as r:
if r.status_code == 400 and "cart is empty" in r.text:
if self.cart_has_item:
r.failure("корзина пуста сразу после добавления товара") # сценарий или данные сломаны
else:
r.success() # штатный отказ: товар не добавлялся, корзина и правда пуста
self.cart_has_item = False
return
body = check(r, 201, "id", "items")
self.cart_has_item = False
if body is not None:
self.order_id = body["id"]
if not body["items"]:
r.failure("заказ без товаров")
elif not any(i["product_id"] == self.product_id for i in body["items"]):
r.failure("в заказе нет добавленного товара")
@task
def check_order(self):
if not self.order_id:
return
with self.client.get(f"/api/orders/{self.order_id}", catch_response=True,
name="/api/orders/[id]") as r:
body = check(r, 200, "id", "items")
if body is not None and body["id"] != self.order_id:
r.failure("вернулся другой заказ")
class Buyer(HttpUser):
host = "http://localhost:8000"
wait_time = between(1, 3)
weight = 1
tasks = [Purchase]
def on_start(self):
account = next(USERS)
with self.client.post("/api/login", json=account, name="/api/login",
catch_response=True, timeout=60) as r:
body = check(r, 200, "token")
if body is None:
raise StopUser()
self.client.headers["Authorization"] = "Bearer " + body["token"]
Здесь учётная запись приходит из CSV (next(USERS) берёт следующую по кругу) (json=account отправляет словарь {"email": ..., "password": ...} как JSON), а все проверки идут через check. Заметь r.text в checkout: это тело ответа как текст, его удобно искать подстрокой. Флаг cart_has_item решает, чем считать 400 «cart is empty»: если товар только что добавлен (201), пустой корзины быть не должно, и это ошибка сценария или данных, а не штатный отказ.
Есть одна дыра. Buyer входит один раз в on_start, а потом работает с токеном, поэтому тест на 44 пользователей даёт около нуля логинов в секунду. Самая дорогая операция стенда (bcrypt, урок 9.2) остаётся без нагрузки, а профиль из урока 8.4 требует 2,0 входа в секунду. Добавь пользователя, который только входит, снова и снова:
from locust import constant_pacing
class Auth(HttpUser):
host = "http://localhost:8000"
fixed_count = 4
wait_time = constant_pacing(2)
@task
def login(self):
with self.client.post("/api/login", json=next(USERS), name="/api/login",
catch_response=True, timeout=60) as r:
check(r, 200, "token")
fixed_count = 4 значит «создай ровно четыре таких пользователя»: их запускают первыми, а остальные делятся между Visitor и Buyer по весам, как раньше. constant_pacing(2) это пауза, которая подстраивается так, чтобы задача стартовала раз в 2 секунды независимо от длины ответа (если вход занял 0,3 с, пауза будет 1,7 с). Четыре пользователя по одному входу в 2 секунды дают 4 ÷ 2 = 2,0 входа в секунду, ровно цель профиля. Каждый вход идёт под своим аккаунтом из USERS, а имя /api/login то же, что у Buyer, поэтому все входы попадают в одну строку статистики. Импорт constant_pacing добавь к строке from locust import .... raise StopUser() в конце задачи покупателя здесь не подойдёт: он только останавливает этого пользователя, а нового Locust на его место не создаёт, и число пользователей будет падать.
Проверь синтаксис и запусти:
python -m py_compile locustfile.py && locust
Поставь 30 пользователей со скоростью запуска 2. Пусть тест поработает пару минут.
Типичные ошибки:
FileNotFoundError: users.csv: ты запустил Locust не в каталоге файла иmake_users.pyне отработал. Запусти его в~/perf-lab/09-locust.KeyError: 'email': в CSV нет заголовка или он называется иначе.- Вкладка Exceptions не пуста: тебя поймало необработанное исключение. Прочитай строку, там будет номер строки сценария.
3. Посмотри на вкладку Failures
На здоровом стенде таблица Failures должна быть пустой. Если там что-то есть, разбери по таблице классов: чья это проблема? Нажми Reset Stats после разгона и прогони ещё минуту.
Тест «зелёный», а ты подозреваешь скрытую ошибку (200, но пустой каталог)? Сначала придумай, какой признак поломки есть в теле ответа, потом спроси нейросеть, как его проверить. Проверь, заведомо ломая стенд: проверка обязана покраснеть.
4. Научи тест замечать скрытую поломку
Сломаем намеренно, чтобы убедиться, что проверки работают. Запроси несуществующие товары: временно поменяй в Visitor.product диапазон на random.randint(9000, 12000) (товаров с номерами выше 10000 нет). Запусти 20 посетителей.
На вкладке Failures появятся строки GET /api/products/[id]: код 404, ожидался 200: примерно две трети запросов карточки (номера 10001-12000 не существуют, это 2000 из 3001 возможных). Эти запросы ответили быстро (404 это дёшево), и среднее время выглядит лучше, чем должно быть.
Как читать вывод: по классификации это «сценарий сломан»: товаров с такими номерами нет. Исправь диапазон обратно. Урок: ошибку поймали проверки, а не твой глаз.
5. Зафиксируй
cd ~/perf-lab && git add 09-locust && git commit -m "9.3: данные из CSV и проверки ответов" && git push
Сломай и почини
Поломка A. Файл данных короче, чем нужно. Оставь в users.csv первые 10 пользователей (head -11 users.csv > u.csv && mv u.csv users.csv) и запусти 100 пользователей (проверь, что Buyer составляет 20% от них: 20 покупателей на 10 учётных записей).
Что ожидать. Каждая запись достаётся двоим: одна корзина на двоих. Покупатели мешают друг другу: появляются 400 «cart is empty» (чужой заказ уже забрал корзину; сценарий считает их ошибкой, потому что товар только что добавлен) и редкие 409, а проверка «товара нет в корзине» срабатывает, потому что чужие операции вклиниваются между шагами. На вкладке Failures появятся строки «товара нет в корзине» и «корзина пуста сразу после добавления товара».
Ошибки 4xx на заказах говорят не о слабости магазина, а о нарушенной независимости тестовых данных. Восстанови полный файл: python make_users.py.
Поломка B. Код 200, но каталог пуст. Временно поменяй в Visitor.catalog страницу на random.randint(400, 600). Товаров всего 10 000, по 20 на страницу это 500 страниц, поэтому страницы 501-600 лежат за пределом.
Что ожидать. Магазин отвечает 200 и телом {"items": [], "page": 550, "size": 20, "total": 10000}. Страницы 400-500 нормальные, а страницы 501-600 пустые: около половины запросов каталога (100 страниц из 201). Проверка «каталог пуст» отмечает их в Failures. А теперь для сравнения закомментируй обе проверки и оставь простой self.client.get(...): ошибок ноль, задержки у пустых страниц даже меньше. Именно так красивый отчёт может лгать.
Починка. Верни randint(1, 10) и проверки. Вывод: проверка кода пропускает пустой каталог, а проверка содержимого нет.
ИИ в помощь
Нейросеть хорошо пишет проверки ответа и читает CSV, но схему JSON твоего стенда она не знает и её придумывает. Общие правила: ИИ-помощник.
Задача: написать проверку ответа с catch_response.
Locust 2.46. GET /api/products возвращает JSON. Вот реальный ответ: <вставь тело ответа curl>.
Напиши проверку с catch_response=True: код 200, в JSON есть непустой список товаров
и у первого товара есть поля из ответа выше. Если не так, response.failure с понятным текстом.
Используй .get() вместо прямого доступа к ключу. Объясни, что произойдёт без failure.
Проверь ответ: заведомо сломай ожидание (например, потребуй несуществующее поле) и убедись, что вкладка Failures показывает твой текст. Типичные ошибки: имена полей, которых нет в ответе стенда, и проверка без response.failure(), из-за которой при исключении в проверке теряется понятное сообщение.
Задача: подключить пользователей из CSV.
Locust 2.46. Файл users.csv с колонками email,password: <вставь первые 3 строки>.
Сделай так, чтобы каждый запущенный пользователь брал свою строку по кругу,
а не всех под одним логином. Объясни, почему один логин для всех искажает тест
и что делает itertools.cycle.
Проверь ответ: запусти на 5 пользователях и убедись по логам стенда или request_id, что логины разные. Типичная ошибка: чтение файла в каждом запросе вместо одного раза при старте.
Реальные логины и пароли в чат не отправляй: достаточно пользователей user0001@shop.lab стенда, на работе подставь user@example.com.
Словарик урока
| Термин | Простыми словами |
|---|---|
catch_response=True |
Флаг, который передаёт тебе решение «успех или ошибка» вместо автоматического |
response.failure("...") |
Пометить запрос как неудачный и записать причину |
response.success() |
Явно пометить запрос успешным (например, штатный отказ) |
Контекстный менеджер (with) |
Блок, по окончании которого Python сам выполняет завершающее действие |
| Проверка содержимого | Сверка не только кода, но и тела ответа: поля, значения |
| Штатный отказ | Ожидаемый 4xx, который не означает поломки (пустая корзина, нет товара) |
| Ошибка сценария | Ошибка в самом тесте: неверный токен, несуществующий id |
| Параметризация | Подстановка данных из внешнего набора: CSV, список |
csv.DictReader |
Читает CSV-файл как список словарей с именами колонок |
itertools.cycle |
Выдаёт элементы списка по кругу бесконечно |
| Независимость данных | Каждый виртуальный пользователь работает со своими данными |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ. Вопросы [на скорость] тренируй на время.
1. [junior] [часто] Что такое catch_response и когда он нужен?
Ответ
Флаг, который забирает у Locust решение, считать ли ответ успешным. Нужен, когда критерий успеха сложнее «код меньше 400»: проверить тело, поля, значения, время, признать штатный отказ. Внутри блока with вызываешь response.failure("причина") или response.success().
Что хотят услышать: флаг передаёт решение вам; примеры: пустой список при 200, штатный 400.
Красный флаг: «он нужен, чтобы ловить исключения».
2. [junior] [часто] Почему нельзя считать успехом любой ответ 200?
Ответ
Код 200 говорит только «сервер ответил». Тело может быть пустым, чужим или содержать сообщение об ошибке. Такие запросы быстрые, и тест выглядит лучше, чем есть: ошибок нет, задержки малы. Поэтому проверяют форму и ключевые значения ответа.
Что хотят услышать: примеры замаскированных ошибок и понимание, что быстрый неверный ответ искажает метрики.
Красный флаг: «проверки замедляют генератор, поэтому их нет».
3. [junior] Как классифицируешь ошибки по итогам теста?
Ответ
Три группы, но по коду и тексту их разделяют только после проверки, что запрос был верным и какой ответ ожидался. Сервис сломан: 5xx, таймауты, обрывы (результат теста). Сценарий сломан: 401, 404, 422, исключения в коде (чинить тест). Штатный отказ: ожидаемые 400 или 409 с точным текстом (не ошибка, но следить за долей). В отчёт как дефект идёт только первая группа.
Что хотят услышать: по коду и тексту, разные действия для разных групп.
Красный флаг: «все ошибки одинаковые, считаю процент».
4. [junior] Откуда брать тестовых пользователей и сколько их нужно?
Ответ
Из CSV или другого набора, подготовленного заранее, не меньше одной записи на виртуального пользователя, чтобы они не делили корзины и сессии. Файл читают один раз на процесс и раздают по кругу или по номеру пользователя.
Что хотят услышать: независимость данных, чтение один раз, минимум одна запись на пользователя.
Красный флаг: один логин на всех.
5. [middle] [часто] Почему сообщение в failure() не должно содержать переменных значений?
Ответ
Locust группирует ошибки на вкладке Failures по тексту сообщения. Если вставить в него id заказа или время, каждая ошибка станет отдельной строкой, и вместо «заказ без товаров: 300 раз» ты увидишь 300 строк по одному. Сообщение должно описывать класс проблемы, детали уходят в лог.
Что хотят услышать: группировка по тексту, постоянные сообщения.
Красный флаг: «чем подробнее текст, тем лучше».
6. [middle] Как отличить таймаут клиента от ошибки сервера?
Ответ
У таймаута клиента нет кода ответа: Locust оборвал запрос сам по timeout=, в статистике он выглядит как ошибка без кода (текст вроде ReadTimeout). У ошибки сервера есть код 5xx и тело. Сравни с Grafana: сервер мог ещё работать и позже закончить запрос. В отчёте это разные строки: «клиент не дождался» и «сервер ответил ошибкой».
Что хотят услышать: наличие кода, связь с порогом ожидания, сверка с серверной стороной.
Красный флаг: «таймаут и 504 это одно и то же».
7. [middle] Что делает штатный отказ ошибкой, и как не «замазать» настоящую проблему?
Ответ
Штатный отказ признают успехом только при точном совпадении кода и текста (400 и «cart is empty»). Любой другой 400 остаётся ошибкой, а пустая корзина считается штатной только если по состоянию сценария товар не клали. Дополнительно следят за долей штатных отказов: если она растёт (скажем, пустых корзин половина), сценарий или данные перекошены, даже если отдельно эти запросы не ошибка.
Что хотят услышать: точное условие, мониторинг доли.
Красный флаг: «все 4xx помечаю как успешные».
8. [middle] Почему не стоит читать CSV внутри on_start?
Ответ
Файл будет читаться на каждого пользователя: сотни чтений в момент запуска, лишняя нагрузка на генератор, и риск, что два пользователя получат одну строку. Читают один раз при импорте сценария и раздают через общий итератор. В распределённом режиме ещё делят данные между воркерами (урок 9.5).
Что хотят услышать: один раз на процесс, общий итератор, независимость.
Красный флаг: «CSV в on_start: так проще, и этого достаточно».
9. [middle] Когда нужна проверка времени ответа в самом запросе?
Ответ
Когда у маршрута есть жёсткий порог для пользователя (каталог не медленнее 500 мс). Тогда медленный ответ помечается неудачей, и в Failures видно долю нарушений. Это дополнение к перцентилям, а не замена: перцентили показывают распределение, порог показывает нарушение цели.
Что хотят услышать: связь с SLO и отличие от перцентилей.
Красный флаг: «ставлю порог на каждый запрос: так точнее».
10. [junior] [на скорость] Каким вызовом пометить запрос неудачным в блоке with?
Ответ
response.failure("причина").
11. [junior] [на скорость] Какой код у «корзина пуста» в «Магазине»?
Ответ
400, текст cart is empty, маршрут POST /api/orders.
12. [junior] [на скорость] Что такое csv.DictReader одной фразой?
Ответ
Читатель CSV, который превращает каждую строку в словарь, где ключи это имена колонок из первой строки.
Проверено на версиях
Ubuntu 24.04, Python 3.12, Locust 2.46.6, стенд «Магазин» из project/shop (10 000 товаров по 1 000 000 штук, 1000 пользователей), Prometheus 3.15, Grafana 13.2. Октябрь 2026.
Итог урока: ты умеешь
- Объяснить, почему «код меньше 400» не гарантирует правильный ответ.
- Использовать
catch_response=True,failure()иsuccess(). - Написать функцию проверки: код, JSON, поля, значения.
- Разделить ошибки на «сервис сломан», «сценарий сломан» и «штатный отказ».
- Писать постоянные сообщения об ошибках, чтобы Failures группировались.
- Сгенерировать CSV с учётными записями и раздать их по пользователям без пересечений.
- Заметить по Failures, что данных не хватает на всех виртуальных пользователей.
Дальше: урок 9.4. Запуск без интерфейса, профиль нагрузки и отчёты, где тест запускается одной командой на сервере, а нагрузка идёт по ступенчатой форме.
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.