Skip to content

Commit fb8f2a4

Browse files
committed
feat: добавить locust-сценарии и команды нагрузочного теста
Что сделано: - добавлен load/locustfile.py с двумя потоками: нормальная авторизованная нагрузка и security-abuse (401/429) - реализован общий bootstrap логин, чтобы не спамить auth-лимиты для каждого виртуального пользователя - добавлены команды just load и just load-headless для UI и headless режимов - в just urls добавлена ссылка на Locust UI - добавлен пакет locust в dev-зависимости и обновлен lock-файл
1 parent debce21 commit fb8f2a4

4 files changed

Lines changed: 715 additions & 0 deletions

File tree

Justfile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ urls:
3535
@echo "Prometheus: http://localhost:9090"
3636
@echo "Grafana: http://localhost:3000"
3737
@echo "Jaeger: http://localhost:16686"
38+
@echo "Locust UI: http://localhost:8089"
3839

3940
# Смотреть логи (все сервисы или один сервис: `just logs api`).
4041
logs service="":
@@ -56,6 +57,14 @@ db-shell:
5657
redis-cli:
5758
docker compose exec redis redis-cli
5859

60+
# Запустить Locust в UI режиме для ручного управления нагрузкой.
61+
load:
62+
uv run locust -f load/locustfile.py --host "${LOCUST_HOST:-http://localhost:8000}" --web-port "${LOCUST_WEB_PORT:-8089}"
63+
64+
# Запустить Locust в headless режиме.
65+
load-headless users="20" spawn="2" run_time="5m":
66+
uv run locust -f load/locustfile.py --host "${LOCUST_HOST:-http://localhost:8000}" --headless -u {{users}} -r {{spawn}} --run-time {{run_time}} --only-summary
67+
5968
# Установить git pre-commit hook в локальный репозиторий.
6069
pre-commit-install:
6170
uv run pre-commit install

load/locustfile.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import random
5+
from datetime import date, timedelta
6+
from uuid import uuid4
7+
8+
from gevent.lock import Semaphore
9+
from locust import HttpUser, between, task
10+
11+
API_PREFIX = "/api/v1"
12+
REGISTER_PATH = f"{API_PREFIX}/auth/register"
13+
LOGIN_PATH = f"{API_PREFIX}/auth/login"
14+
TASKS_PATH = f"{API_PREFIX}/tasks"
15+
16+
LOADTEST_EMAIL = os.getenv("LOCUST_EMAIL", "loadtest@example.com")
17+
LOADTEST_PASSWORD = os.getenv("LOCUST_PASSWORD", "StrongPass123!")
18+
ENABLE_AUTH_ABUSE = os.getenv("LOCUST_ENABLE_AUTH_ABUSE", "true").lower() in {
19+
"1",
20+
"true",
21+
"yes",
22+
}
23+
24+
25+
class SharedAuthState:
26+
"""Общее состояние токена для всех виртуальных пользователей процесса Locust."""
27+
28+
lock = Semaphore()
29+
access_token: str | None = None
30+
31+
32+
class ApiUser(HttpUser):
33+
"""Основной пользовательский поток: задачи под авторизованным токеном."""
34+
35+
wait_time = between(0.3, 1.2)
36+
weight = 5
37+
38+
access_token: str | None
39+
my_task_ids: list[str]
40+
41+
def on_start(self) -> None:
42+
self.my_task_ids = []
43+
self.access_token = self._get_shared_access_token()
44+
45+
def _auth_headers(self) -> dict[str, str]:
46+
if not self.access_token:
47+
self.access_token = self._get_shared_access_token()
48+
return {"Authorization": f"Bearer {self.access_token}"}
49+
50+
def _register_loadtest_user(self) -> None:
51+
payload = {
52+
"email": LOADTEST_EMAIL,
53+
"password": LOADTEST_PASSWORD,
54+
}
55+
# 201 - зарегистрировали, 409 - уже есть. Оба состояния для теста валидны.
56+
self.client.post(
57+
REGISTER_PATH,
58+
json=payload,
59+
name="POST /api/v1/auth/register (bootstrap)",
60+
)
61+
62+
def _login_loadtest_user(self) -> str | None:
63+
response = self.client.post(
64+
LOGIN_PATH,
65+
data={"username": LOADTEST_EMAIL, "password": LOADTEST_PASSWORD},
66+
headers={"Content-Type": "application/x-www-form-urlencoded"},
67+
name="POST /api/v1/auth/login (bootstrap)",
68+
)
69+
if response.status_code != 200:
70+
return None
71+
body = response.json()
72+
token = body.get("access_token")
73+
return token if isinstance(token, str) and token else None
74+
75+
def _get_shared_access_token(self) -> str | None:
76+
if SharedAuthState.access_token:
77+
return SharedAuthState.access_token
78+
79+
with SharedAuthState.lock:
80+
if SharedAuthState.access_token:
81+
return SharedAuthState.access_token
82+
83+
self._register_loadtest_user()
84+
SharedAuthState.access_token = self._login_loadtest_user()
85+
return SharedAuthState.access_token
86+
87+
def _pick_task_id(self) -> str | None:
88+
if self.my_task_ids:
89+
return random.choice(self.my_task_ids)
90+
91+
response = self.client.get(
92+
TASKS_PATH,
93+
headers=self._auth_headers(),
94+
name="GET /api/v1/tasks",
95+
)
96+
if response.status_code != 200:
97+
return None
98+
99+
tasks = response.json()
100+
if not isinstance(tasks, list) or not tasks:
101+
return None
102+
103+
ids = [item.get("id") for item in tasks if isinstance(item, dict)]
104+
ids = [task_id for task_id in ids if isinstance(task_id, str)]
105+
if ids:
106+
self.my_task_ids.extend(ids)
107+
return random.choice(ids)
108+
return None
109+
110+
@task(6)
111+
def list_tasks(self) -> None:
112+
self.client.get(
113+
TASKS_PATH,
114+
headers=self._auth_headers(),
115+
name="GET /api/v1/tasks",
116+
)
117+
118+
@task(3)
119+
def create_task(self) -> None:
120+
payload = {
121+
"title": f"load-task-{uuid4().hex[:8]}",
122+
"description": "Created by locust",
123+
"status": "todo",
124+
"priority": "medium",
125+
"due_date": str(date.today() + timedelta(days=2)),
126+
}
127+
128+
with self.client.post(
129+
TASKS_PATH,
130+
json=payload,
131+
headers=self._auth_headers(),
132+
name="POST /api/v1/tasks",
133+
catch_response=True,
134+
) as response:
135+
if response.status_code == 201:
136+
task_id = response.json().get("id")
137+
if isinstance(task_id, str):
138+
self.my_task_ids.append(task_id)
139+
response.success()
140+
elif response.status_code == 401:
141+
# Токен истёк: логинимся один раз глобально и продолжаем нагрузку.
142+
SharedAuthState.access_token = None
143+
self.access_token = self._get_shared_access_token()
144+
response.failure("401 on create_task: token refresh needed")
145+
else:
146+
response.failure(f"Unexpected status: {response.status_code}")
147+
148+
@task(2)
149+
def update_task(self) -> None:
150+
task_id = self._pick_task_id()
151+
if not task_id:
152+
return
153+
154+
payload = {
155+
"status": random.choice(["todo", "in_progress", "done"]),
156+
"priority": random.choice(["low", "medium", "high"]),
157+
}
158+
self.client.patch(
159+
f"{TASKS_PATH}/{task_id}",
160+
json=payload,
161+
headers=self._auth_headers(),
162+
name="PATCH /api/v1/tasks/{task_id}",
163+
)
164+
165+
@task(1)
166+
def delete_task(self) -> None:
167+
if not self.my_task_ids:
168+
return
169+
170+
task_id = random.choice(self.my_task_ids)
171+
with self.client.delete(
172+
f"{TASKS_PATH}/{task_id}",
173+
headers=self._auth_headers(),
174+
name="DELETE /api/v1/tasks/{task_id}",
175+
catch_response=True,
176+
) as response:
177+
if response.status_code == 200:
178+
self.my_task_ids = [
179+
item for item in self.my_task_ids if item != task_id
180+
]
181+
response.success()
182+
elif response.status_code in {403, 404}:
183+
# Параллельные пользователи могли уже удалить/изменить задачу.
184+
self.my_task_ids = [
185+
item for item in self.my_task_ids if item != task_id
186+
]
187+
response.success()
188+
else:
189+
response.failure(f"Unexpected status: {response.status_code}")
190+
191+
192+
class AuthAbuseUser(HttpUser):
193+
"""Негативный поток для проверки защиты auth (401/429)."""
194+
195+
wait_time = between(0.5, 1.5)
196+
weight = 1 if ENABLE_AUTH_ABUSE else 0
197+
198+
@task
199+
def invalid_login(self) -> None:
200+
with self.client.post(
201+
LOGIN_PATH,
202+
data={"username": LOADTEST_EMAIL, "password": "wrong-password"},
203+
headers={"Content-Type": "application/x-www-form-urlencoded"},
204+
name="POST /api/v1/auth/login (invalid)",
205+
catch_response=True,
206+
) as response:
207+
if response.status_code in {401, 429}:
208+
response.success()
209+
else:
210+
response.failure(f"Unexpected status: {response.status_code}")

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies = [
2929
[dependency-groups]
3030
dev = [
3131
"httpx>=0.28.1",
32+
"locust>=2.43.3",
3233
"pre-commit>=4.5.1",
3334
"pytest>=9.0.2",
3435
"pytest-asyncio>=1.3.0",

0 commit comments

Comments
 (0)