Введение
В этой статье я разберу несколько типичных ошибок, которые встречаются при написании автотестов на Python. Цель не в том, чтобы высмеять конкретных людей или проекты. Главное — показать абсурдность некоторых подходов, объяснить, как не стоит строить тестовую инфраструктуру и почему это приводит к проблемам.
Задача простая: сэкономить вам время и силы. Чтобы не пришлось потом «переучиваться», избавляться от костылей и проходить болезненный детокс от самодельных «велосипедов». Гораздо продуктивнее с самого начала писать тесты так, чтобы код был качественным, понятным и поддерживаемым.
Дисклеймер. Примеры в статье обобщены и синтетически изменены; цель — разбирать решения, а не авторов. Любые совпадения с реальными проектами случайны. Все рекомендации — про архитектуру и практики, а не про людей.
История находки
Эта статья появилась не случайно. Недавно ко мне пришёл студент с курса и задал вопрос: «Я нашёл фреймворк для автотестов. Это вообще нормальная практика? Так делают?»
Когда я открыл ссылку и посмотрел код, увидел монолитный файл с перемешанными зонами ответственности. Передо мной оказалась «библиотека», которая позиционировала себя как универсальный фреймворк для автотестов «на все случаи жизни». Внутри — один-единственный файл на 3500 строк, в который было запихнуто всё подряд: UI-тесты, API-тесты, обёртки, тулзы, хелперы, нагрузочные тесты и даже системные утилиты. Получился не фреймворк, а монолит без архитектуры.

И самое удивительное: со слов студента, этот «фреймворк» преподносится как «лёгкий способ писать автотесты». В этой статье мы разберём, почему это совсем не лёгкий путь, а скорее быстрый путь к нестабильным тестам и техническому долгу.
Скажу сразу: я не буду давать ссылок и называть авторов. Цель статьи не в том, чтобы кого-то высмеивать или унизить. Цель — разобрать архитектурные ошибки, подсветить костыли, велосипеды и антипаттерны. Подобный код, увы, встречается не только здесь: он реально используется на проектах, да ещё и подаётся новичкам как «правильный подход».
Поэтому давайте вместе проведём небольшой «детокс» от подобных решений.
Антипаттерн 1. «Танцы со стрелочками вниз»
Симптом. В коде десятки функций вида «нажми стрелку вниз N раз, вдруг элемент окажется в видимой области». Часто ещё с time.sleep(0.1)
в цикле и попыткой кликнуть «когда повезёт».
Плохой пример (сокращённо)
import time
from selenium.webdriver import ActionChains
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def make_displayed_with_arrow_down_and_click(driver, xpath, waiting_time):
end = time.time() + waiting_time
while time.time() < end:
try:
el = WebDriverWait(driver, 0.2).until(
EC.visibility_of_element_located((By.XPATH, xpath))
)
if el.is_displayed():
el.click()
return True
except:
pass
ActionChains(driver).send_keys(Keys.ARROW_DOWN).perform()
time.sleep(0.1)
return False
Что здесь не так?
Flaky и гонки.
time.sleep()
маскирует проблему синхронизации, а не решает её. На CI такие тесты «мигают».Зависимость от фокуса. Клавиши работают только если нужный контейнер в фокусе. Любой поп-ап/модал — и всё сломалось.
Дублирование/раздувание. Вариации «стрелка вниз/вверх/ENTER/SPACE» плодят десятки однотипных функций.
Обход DOM-модели. Вместо явного скролла к элементу — «надеемся», что страница сама промотается.
Смешение ожиданий. Параллельно могут быть неявные ожидания — итогом становятся непредсказуемые тайм-ауты.

Как правильно (коротко и надёжно)
Вариант по умолчанию — Playwright
Почему: автоожидания «из коробки», стабильные локаторы, нормальный скролл, перехват сети/консоли, меньше кода — меньше flaky.
# pip install playwright pytest-playwright
# playwright install
from playwright.sync_api import Page
def click(page: Page, locator: str):
# Playwright сам дождётся видимости/кликабельности и доскроллит
page.locator(locator).click()
def type_text(page: Page, locator: str, text: str):
page.locator(locator).fill(text)
def get_text(page: Page, locator: str) -> str:
return page.locator(locator).inner_text()
def click_in_scroll_container(page: Page, container: str):
container_locator = page.locator(container)
container_locator.scroll_into_view_if_needed()
container_locator.click()
Никаких «стрелок вниз»,
sleep(0.1)
и шаманства с ActionChains.Локаторы лучше писать не XPath-«простынями», а через
data-test-id
:
page.get_by_test_id(locator).click()
.
Когда всё-таки Selenium?
Если проект уже на Selenium и переписать нельзя, сводим утилиты к минимуму и не используем клавиши как костыли:
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import ElementClickInterceptedException
def click(driver, xpath: str, timeout: int = 10) -> None:
locator = (By.XPATH, xpath)
el = WebDriverWait(driver, timeout).until(EC.element_to_be_clickable(locator))
driver.execute_script("arguments[0].scrollIntoView({block:'center', inline:'center'})", el)
try:
el.click()
except ElementClickInterceptedException:
driver.execute_script("arguments[0].click()", el) # редкий резерв
def type_text(driver, xpath: str, text: str, timeout: int = 10) -> None:
el = WebDriverWait(driver, timeout).until(EC.visibility_of_element_located((By.XPATH, xpath)))
el.clear()
el.send_keys(text)
Когда уместны клавиши?
Только если вы намеренно тестируете доступность/навигацию клавиатурой (Tab flow, меню-стрелки, хоткеи). Для «проскроллить и кликнуть» — это антипаттерн.
Мини-чеклист вместо «танцев»
Playwright по умолчанию (автоожидания, стабильные локаторы).
Если Selenium — только явные ожидания +
scrollIntoView
, безsleep
.Один-два универсальных хелпера вместо десятков «стрелка вниз N раз».
JS-клик — как исключение, а не как стратегия.
Антипаттерн 2. «exec в API» и прочая небезопасная магия
Симптом. Функция отправки HTTP-запроса выполняет произвольный код перед запросом, смешивает ответственность и не контролирует ошибки.
Плохой пример (сокращённо)
def post_request(requests_url: str, requests_body: dict, requests_headers: dict,
pre_script: str = None, auth: list = None):
# запускаем произвольный код «для подготовки» ?
if pre_script is not None:
exec(pre_script)
body = json.dumps(requests_body)
response = requests.post(requests_url, auth=auth, data=body, headers=requests_headers)
if response.status_code in (200, 201):
print('POST request successful')
return response.json()
else:
print('POST request failed')
return response.json()
Что здесь не так?
exec(pre_script)
— выполнение произвольного кода из строки. Это уязвимость класса RCE. Доверие к данным ≠ повод их исполнять.Смешение ответственности. В одном методе «бизнес-логика препроцессинга», сериализация, сетевой вызов и «молчаливое» игнорирование ошибок.
data=body
вместоjson=...
— рискуете невернымContent-Type
и кодировкой (и ручной сериализацией там, где она не нужна).Отсутствие таймаутов/ретраев — подвисания и flaky на CI.
Нет возврата контракта. Неясно, что возвращает метод, как обрабатывать 4xx/5xx.

Как правильно?
Вариант 1. Небольшой «чистый» синхронный клиент на httpx
import httpx
class HTTPClient:
def __init__(self, client: httpx.Client):
self.client = client
def post(self, url: str, json: dict, headers: dict | None = None) -> httpx.Response:
return self.client.post(url, json=json, headers=headers)
def close(self):
self.client.close()
# пример
client = HTTPClient(httpx.Client(timeout=5))
resp = client.post("https://api.example.com/login", json={"user": "foo"})
print(resp.status_code, resp.json())
Вариант 2. Асинхронный клиент + ретраи (коротко)
import httpx
from backoff import on_exception, expo # pip install backoff
class HTTPClient:
def __init__(self, client: httpx.AsyncClient):
self.client = client
@on_exception(expo, (httpx.TimeoutException, httpx.ConnectError), max_tries=3)
async def post(self, url: str, json: dict, headers: dict | None = None) -> httpx.Response:
return await self.client.post(url, json=json, headers=headers)
async def aclose(self):
await self.client.aclose()
(Опционально) Валидация данных через Pydantic
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "Bearer"
# пример использования
payload = LoginRequest(username="foo", password="bar").model_dump()
resp = client.post("https://api.example.com/login", json=payload)
parsed = LoginResponse.model_validate_json(resp.text)
Мини-чеклист безопасности и здравого смысла
Сериализация — через
json=
; заголовки задаём явно, если нужно.Всегда таймауты; для нестабильных сетей — ретраи с экспонентой.
Единый и предсказуемый контракт возврата (или исключения).
Валидация входа/выхода (Pydantic) — меньше сюрпризов в тестах.
Логи: метод, URL, статус, latency (без утечек чувствительных данных).
Не используем bare except: ловите конкретные исключения httpx/requests
Антипаттерн 3. Глобальные connection/cursor
Симптом. Подключение к БД и курсор создаются один раз «где-то сверху», кладутся в глобальные переменные и дальше используются из любой функции.
Плохой пример (сокращённо)
# где-то в модуле
global connection, cursor
connection = psycopg2.connect(host=..., user=..., password=..., dbname=...)
cursor = connection.cursor()
def export_table(...):
cursor.execute(sql) # используем глобальный курсор
rows = cursor.fetchall()
...
# в finally где-нибудь ниже: cursor.close(); connection.close()
Что здесь не так?
Утечки и «висящие» транзакции. Глобальный коннект легко забыть закрыть; автокоммит / неявные транзакции висят между тестами.
Не потокобезопасно. Параллельный запуск (pytest-xdist) или просто несколько тестов одновременно — и вы ловите гонки/«курсор уже закрыт».
Неизолированные тесты. Один тест меняет состояние БД — другой видит мусор.
Нельзя конфигурировать точечно. Хотите иной таймаут/роль/схему — увы, «у нас один на всех».
Непрозрачные ошибки. Ошибка «где-то» в общем курсоре → падать начинает «всё» и отлаживать больно.

Как правильно?
Вариант A. Чистая функция + контекстные менеджеры (psycopg2)
import psycopg2
from typing import Any, Iterable
ConnParams = dict[str, Any]
def fetch_all(params: ConnParams, sql: str, args: Iterable[Any] | None = None):
"""Открывает соединение/курсор, выполняет запрос, гарантированно закрывает ресурсы."""
with psycopg2.connect(**params) as conn:
with conn.cursor() as cur:
cur.execute(sql, args)
return cur.fetchall() # автокоммит зависит от настроек; для SELECT это ок
def execute(params: ConnParams, sql: str, args: Iterable[Any] | None = None) -> int:
"""Для INSERT/UPDATE/DELETE — возвращает число затронутых строк, коммитит транзакцию."""
with psycopg2.connect(**params) as conn:
with conn.cursor() as cur:
cur.execute(sql, args)
return cur.rowcount
Каждый вызов сам управляет ресурсами — нет глобального состояния.
Параметризация через
args
— защита от SQL-инъекций (никакихf"... {user_id} ..."
).Конфигурация (
host
,dbname
,options
/search_path
) — на уровне вызова.
Вариант B. Пул соединений (если запросов много)
from psycopg2.pool import ThreadedConnectionPool
pool = ThreadedConnectionPool(minconn=1, maxconn=10, **conn_params)
def run_in_pool(sql: str, args=None):
conn = pool.getconn()
try:
with conn, conn.cursor() as cur:
cur.execute(sql, args)
return cur.fetchall() if cur.description else cur.rowcount
finally:
pool.putconn(conn)
Подойдёт для тестовых раннеров, которые часто ходят в БД.
Всё ещё без глобального курсора и с аккуратным возвратом соединения.
Вариант C. SQLAlchemy (индустриальный стандарт)
from sqlalchemy import create_engine, text
engine = create_engine("postgresql+psycopg2://user:pass@host:5432/dbname", future=True)
def fetch_sa(sql: str, **params):
with engine.connect() as conn:
res = conn.execute(text(sql), params)
return res.mappings().all() # список dict’ов
Менеджер соединений, параметризация, кросс-СУБД, удобные маппинги.
Для сложных проектов — ORM/модели, миграции Alembic.
Тестовая изоляция (очень важно)
Чтобы тесты не пачкали БД и не зависели друг от друга — оборачиваем каждый тест в транзакцию и откатываем её.
Pytest-фикстуры на psycopg2
import psycopg2, pytest
@pytest.fixture(scope="session")
def db_conn(params):
conn = psycopg2.connect(**params)
conn.autocommit = False
yield conn
conn.close()
@pytest.fixture
def db_cur(db_conn):
cur = db_conn.cursor()
db_conn.begin() # новая транзакция
try:
yield cur # тест выполняет SQL здесь
db_conn.rollback() # откатить изменения после каждого теста
finally:
cur.close()
Каждый тест получает чистое состояние, изменения не «протекают».
Можно дополнить
SAVEPOINT
/begin_nested
для более тонкой грануляции.
Мини-чеклист
Никаких
global connection, cursor
.Всегда
with connect() as conn, conn.cursor() as cur:
.Параметризованные запросы (
cur.execute(sql, args)
), не f-строки с данными.Для массовых вызовов — пул соединений.
Для реальных проектов — SQLAlchemy (Core/ORM) + миграции.
В тестах — транзакция на тест и обязательный rollback.
Разные СУБД — разные модули/клиенты, не «всё в одном классе».
Разделяйте креды/DSN через переменные окружения (не хардкодим в коде).
Антипаттерн 4. «Тестовая библиотека сама ставит Node.js/Newman через sudo»
Симптом. Внутри «фреймворка автотестов» есть функция, которая лезет в ОС и устанавливает системные пакеты — Node.js, npm и Newman — причём разными путями для Windows/macOS/Linux, местами через sudo
, местами скачивая MSI.
Плохой пример (сокращённо)
def install_newman():
def is_tool_installed(tool):
subprocess.run([tool, "--version"], check=True)
def install_nodejs():
if sys.platform.startswith("win"):
subprocess.run(["curl", "-o", "node.msi", NODE_URL], check=True)
subprocess.run(["msiexec", "/i", "node.msi", "/quiet", "/norestart"], check=True)
elif sys.platform.startswith("darwin"):
subprocess.run(["brew", "install", "node"], check=True)
elif sys.platform.startswith("linux"):
distro = subprocess.run(["lsb_release", "-is"], stdout=PIPE).stdout.decode().strip().lower()
if distro in ["ubuntu", "debian"]:
subprocess.run(["sudo", "apt", "update"], check=True)
subprocess.run(["sudo", "apt", "install", "-y", "nodejs"], check=True)
# ... и т.д.
if not is_tool_installed("node"):
install_nodejs()
if not is_tool_installed("npm"):
# ещё одна ветка с пакетными менеджерами и sudo
...
if not is_tool_installed("newman"):
subprocess.run(["npm", "install", "-g", "newman"], check=True)
Что здесь не так?
Нарушение границ ответственности. Тестовая библиотека не должна администрировать ОС. Это задача DevOps/окружения, а не кода в
framework_for_tests.py
.Безопасность.
sudo
, скачивание и установка бинарей «на лету» из тестов — это прямое приглашение к RCE/порче машины.Неповторяемость. Сегодня
apt install nodejs
поставил v18, завтра v22. Результаты «тестов» будут разными.Ломает CI/CD. Контейнеры собраны заранее. Любая попытка ставить системный софт во время теста — медленно, нестабильно и часто попросту запрещено.
Скрытые побочки. Глобальная установка
npm -g newman
меняет окружение разработчика/агента. Откаты нет.

Как правильно?
Вариант A. Контейнер с зафиксированными зависимостями (рекомендуется)
Dockerfile (фрагмент):
FROM python:3.12-slim
# 1) Python-зависимости
COPY requirements.txt .
RUN pip install -r requirements.txt
# 2) Node.js + Newman (фиксированные версии)
RUN apt-get update && apt-get install -y curl ca-certificates gnupg \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update && apt-get install -y nodejs \
&& npm install -g newman@5.3.2 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
Всё ставится на этапе сборки, версии зафиксированы.
В рантайме тесты не лезут в ОС.
Вариант B. Make/CI-оркестрация — не из тестовой либы
Makefile (фрагмент):
deps:
\tpip install -r requirements.txt
\tnpm install -g newman@5.3.2
test:
\tpytest -m "not e2e"
perf:
\tnewman run perf_collection.json -e perf_env.json
Инсталляция и запуск — отдельные цели.
Тестовая библиотека не знает про установку системных утилит.
Вариант C. Если Newman очень нужен — оборачивайте вызов с проверкой, но не устанавливайте
import shutil, subprocess
def run_newman(collection: str, env: str) -> int:
newman = shutil.which("newman")
if not newman:
raise RuntimeError("newman is not installed. Please install it via Docker image or CI step.")
return subprocess.run([newman, "run", collection, "-e", env], check=False).returncode
Явно падаем с понятной ошибкой, если зависимости нет.
Никаких
sudo
и «магии установки».
Альтернатива Newman: чистый Python или профильные инструменты
Для E2E — Playwright, у которого есть трейсинг/видео, сетевые HAR без плясок.
Мини-чеклист
Никогда не ставим системный софт из тестовой библиотеки.
Все системные зависимости — в Dockerfile или в CI шаге.
Версии фиксируем (lockfile/теги).
В тестовом коде — только проверка наличия инструмента и аккуратный вызов.
По возможности заменяем «внешние CLI» на библиотечные вызовы в Python/Playwright/Locust.
Антипаттерн 5. «Нагрузка» через httpx и pytest.mark.asyncio
Симптом. Фреймворк называет «нагрузочным тестом» просто пачку параллельных HTTP-запросов через asyncio.gather, помеченных @pytest.mark.asyncio. Где-то печатается текст «Count = N», и на этом «перфоманс» заканчивается.
Плохой пример (сокращённо)
class Load:
@staticmethod
@pytest.mark.asyncio
async def make_get_request(url):
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return url, response.text
@staticmethod
@pytest.mark.asyncio
async def concurrent_get_requests(urls):
tasks = [Load.make_get_request(u) for u in urls]
return await asyncio.gather(*tasks)
@staticmethod
async def run_load_method_of_get_requests(url, count):
urls = [url] * count
results = await Load.concurrent_get_requests(urls)
for u, text in results:
print(f"Count = {count}, URL: {u}, Response: {text}")
Что здесь не так?
Это не нагрузочное тестирование. Нет профиля нагрузки (RPS/Concurrency/Duration), нет прогрева, нет стабилизации, нет измерений latency/percentiles, нет ошибок/таймаутов в отчёте, нет корреляции с метриками сервера (CPU, память, сеть).
Смешение с pytest. Навешивание @pytest.mark.asyncio на утилиты ломает запуск вне pytest и «привязывает» код к раннеру тестов.
Отсутствие контроля времени. asyncio.gather максимизирует параллелизм «сколько успели», но не удерживает профиль (RPS/конкарренси/длительность), поэтому данные о p95/p99 и SLA нерепрезентативны.
Нереалистичный сценарий. Нет сессий, куков, заголовков, вариативности payload’ов, зависимостей между шагами.
Никакой отчётности. Печать в консоль — это не репорт. Нужны агрегаты: p50/p90/p99, ошибки по кодам, пер-запросная статистика, графики.

Как правильно?
Использовать профильные инструменты (рекомендовано)
Locust (Python, сценарный подход):
# pip install locust
from locust import HttpUser, task, between
class WebsiteUser(HttpUser):
wait_time = between(0.5, 2.0) # «дум-таймы» между шагами
@task(3)
def view_items(self):
self.client.get("/items", name="GET /items")
@task(1)
def create_item(self):
self.client.post("/items", json={"name": "foo"}, name="POST /items")
Запуск с профилем нагрузки:
locust -H https://api.example.com --headless -u 200 -r 20 -t 10m
-u 200
: одновременно 200 пользователей,-r 20
: разгон по 20 пользователей/сек,-t 10m
: длительность 10 минут.
Locust отдаёт p50/p90/p95/p99, RPS, ошибки, можно экспортировать CSV и интегрировать с Grafana/Prometheus.
k6 (альтернатива): декларативные сценарии, отличный вывод метрик и удобная интеграция с Grafana/InfluxDB.
Что ещё важно для «настоящей нагрузки»
Сеансы и данные. Реалистичные пользователи/куки/токены, вариативные payload’ы, подготовленные фикстуры/сидинг.
Наблюдаемость. Корреляция RPS/latency с CPU/Memory/GC/DB/Cache. Без этого вы «стреляете в темноту».
Профиль. Разгон, плато, спад; A/B сценарии; фоновые шумовые нагрузки.
Отчёт. p50/p90/p99, Throughput, ошибки по классам (4xx/5xx/таймауты), пер-эндпойнт агрегации, SLA/SLO.
Мини-чеклист
Не маскировать «пачку запросов» под «нагрузочное тестирование».
Использовать Locust/k6 или хотя бы честный раннер с целевым профилем и метриками.
Не вешать
@pytest.mark.asyncio
на утилиты — выносить раннер отдельно от тестов.Собирать метрики и строить отчёты; без этого выводы невалидны.
Закладывать реалистичность: сеансы, данные, «дум-таймы», вариативность.
Антипаттерн 6. «40 барабанов клавиатуры»
Симптом. Во «фреймворке» десятки однотипных методов: press_down_arrow_key
, press_up_arrow_key
, press_left_arrow_key
, press_right_arrow_key
, press_enter_key
, press_tab_key
, press_backspace_key
, press_delete_key
, press_space_key
, press_char_key
, press_character_by_character
… Все делают одно и то же: строят ActionChains
, жмут клавишу n
раз и ещё подсыпают time.sleep(.1)
между нажатиями.
Плохой пример (сокращённо)
def press_down_arrow_key(driver, n):
action = ActionChains(driver)
for _ in range(n):
action.send_keys(Keys.ARROW_DOWN)
time.sleep(.1)
action.perform()
def press_enter_key(driver, n):
action = ActionChains(driver)
for _ in range(n):
action.send_keys(Keys.RETURN)
time.sleep(.1)
action.perform()
def press_character_by_character(driver, my_string: str):
action = ActionChains(driver)
for ch in my_string:
action.send_keys(ch)
time.sleep(.1)
action.perform()
Что здесь не так?
Дребезг и flaky. Ручные
sleep(.1)
— это гадание на таймингах. На CI/других машинах поведение будет разным.Дублирование. Десятки почти одинаковых функций → тяжело поддерживать/менять.
Не по-пользовательски. В E2E мы проверяем сценарии пользователя. Он кликает по видимым элементам; «стрелочками вниз» скроллит редко.
Преждевременная низкоуровневость. Нажатия клавиш — последняя надежда, когда нет нормальных локаторов/методов.
Нарушение ожиданий. Нет явных ожиданий состояния (элемент появится/станет кликабельным), только «жми и надейся».

Как правильно (Selenium)
1) Убрать зоопарк — оставить один универсальный хелпер
from selenium.webdriver import ActionChains
def press(driver, key, times=1, post_delay=0.0):
ac = ActionChains(driver)
for _ in range(times):
ac.send_keys(key)
ac.perform()
if post_delay:
time.sleep(post_delay)
Использование:
press(driver, Keys.ENTER)
press(driver, Keys.ARROW_DOWN, times=3)
Но пользоваться им только когда без клавиатуры никак (например, нативный выпадающий список).
2) Предпочитать действия через локаторы и ожидания
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def click_when_ready(driver, locator, timeout=10):
el = WebDriverWait(driver, timeout).until(
EC.element_to_be_clickable(locator)
)
el.click()
Использование:
click_when_ready(driver, (By.CSS_SELECTOR, '[data-test="submit"]'))
3) Для «прокрутки» — скролл к элементу, а не «стрелки»
def scroll_into_view(driver, element):
driver.execute_script("arguments[0].scrollIntoView({block:'center', inline:'center'})", element)
И затем клик/взаимодействие по локатору с ожиданием.
Как правильно (Playwright — ещё короче и надёжнее)
Playwright сам делает auto-wait и умеет работать клавиатурой точечно, без ручных sleep
.
# клик по кнопке + автоожидания
page.get_by_test_id("submit").click()
# скролл к элементу не нужен — локатор сам дождётся и дотянется
page.locator("text=Next").click()
# если всё же нужна клавиатура
page.keyboard.press("ArrowDown")
page.keyboard.type("hello") # печать строки
Когда клавиатура уместна?
Нативные элементы/меню, которые реально управляются стрелками/Tab у живого пользователя.
Доступность (a11y): проверка навигации по
Tab
/Shift+Tab
.Ввод в поля с масками, где «вклейка» файлами/JS не катит.
Даже в этих случаях минимизируем ручные паузы — лучше дождаться состояния (фокус, видимость, enabled).
Мини-чеклист
Сначала локаторы + ожидания, потом клавиатура как исключение.
Один универсальный
press()
вместо десятка копий.Никаких «магических»
sleep(.1)
внутри хелпера — ждём состояния.По возможности — Playwright: меньше кода, больше стабильности.
Не эмулируем «скролл стрелками» для доставки элемента в вьюпорт; скроллим к элементу и кликаем.
Антипаттерн 7. Загрузка файла «через JS под капотом»
Симптом. Вместо нормальной загрузки файла «фреймворк» вручную «включает» скрытый <input type="file">
и пробует присвоить ему путь строкой через JS:
def upload_file_by_script(driver, input_xpath, file_path):
file_input = driver.find_element(By.XPATH, input_xpath)
driver.execute_script("arguments[0].style.display = 'block';", file_input)
driver.execute_script("arguments[0].removeAttribute('disabled');", file_input)
driver.execute_script(f"arguments[0].value = '{file_path}';", file_input)
return True
Что здесь не так?
Браузерная безопасность. Современные браузеры запрещают устанавливать
value
уinput[type=file]
через JS. Это сознательное ограничение безопасности. Такой «трюк» либо не сработает, либо сломается при первом же обновлении.Ломает приложение. Насильная правка
display/disabled
меняет DOM и состояние виджетов, из-за чего падают обработчики, валидации, стили. Тест больше не имитирует пользователя.Flaky/нестабильность. Чуть другой CSS/фреймворк — и магия перестаёт работать.
Отсутствие кросс-браузерности. То, что «завелось» в Chromium, часто не работает в Firefox/Safari.

Как правильно (Selenium)
1) Классика: send_keys() на input[type=file]
Selenium «умеет» загрузки — просто передайте абсолютный путь:
from selenium.webdriver.common.by import By
from pathlib import Path
def upload_file(driver, input_locator: tuple[str, str], path: str):
abs_path = str(Path(path).resolve())
elem = driver.find_element(*input_locator)
elem.send_keys(abs_path) # <-- вот и всё
Примечание: если инпут disabled или реально недоступен, надо кликнуть кнопку/label, которая открывает системный диалог — но сам файл всё равно передаём через
send_keys
по элементу input, а не через JS.
2) Скрытый (aria-виджеты)
Иногда <input type="file">
скрыт, а UI — это кастомная кнопка/лейбл. Делайте так:
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def upload_via_label_then_set(driver, button_locator, input_locator, path):
WebDriverWait(driver, 10).until(EC.element_to_be_clickable(button_locator)).click()
# После клика framework часто делает input «живым»
upload_file(driver, input_locator, path)
Не трогайте display/disabled
напрямую — дайте приложению само перевести input в «интерактив».
3) Selenium Grid / удалённый драйвер
На удалённом драйвере нужно включить FileDetector, иначе путь будет «на вашей машине», а не на ноде:
from selenium.webdriver.remote.file_detector import LocalFileDetector
driver.file_detector = LocalFileDetector()
elem = driver.find_element(By.CSS_SELECTOR, 'input[type="file"]')
elem.send_keys(str(Path("files/doc.pdf").resolve()))
4) Drag&Drop-виджеты (dropzone)
Если фронт принимает файл только через «перетаскивание», у вас два варианта:
Обойти UI и бить в API загрузки напрямую (предпочтительно для интеграционных тестов).
Или сымитировать drop-события. В Selenium это громоздко. Честнее здесь использовать Playwright.
Как правильно (Playwright — проще и стабильнее)
Playwright решает загрузки «из коробки» и не требует делать элемент видимым:
# Python
page.set_input_files('input[type="file"]', 'files/doc.pdf')
# если есть отдельная кнопка/лейбл
page.get_by_role("button", name="Upload").click()
page.set_input_files('input[type="file"]', 'files/doc.pdf')
# множественные файлы
page.set_input_files('input[type="file"]', ['a.png', 'b.png'])
Для dropzone-виджетов часто достаточно всё равно указать реальный input по селектору — фреймворки держат его в DOM. Если нет — Playwright поддерживает page.dispatch_event('selector', 'drop', data)
или используйте API-загрузку.
Частые грабли и как их обойти
Относительные пути. Всегда приводите путь к абсолютному.
Iframe. Если input внутри iframe — сначала
frame = page.frame(name="...")
/driver.switch_to.frame(...)
, потом — загрузка.Множественный input. Для нескольких файлов нужен атрибут
multiple
у input; иначе грузите по одному.Антивирус/сети. На CI путь должен существовать на агенте, а не на вашей машине. Подкладывайте файлы в репозиторий/артефакты job’а.
Валидации фронта. После загрузки проверяйте UI-состояние: превью, имя файла, прогресс, успешный статус, а не только факт «отдал send_keys».
Мини-чеклист
Загружаем файлы только через
send_keys
(Selenium) илиset_input_files
(Playwright).Для удалённых раннеров —
LocalFileDetector
.Кликаем по официальным контролам (кнопка/label), не ломая DOM.
При dropzone — либо API, либо Playwright/сложный сценарий drop-события.
Не назначаем
value
у file-input через JS.Не меняем стили/disabled у input для «обхода» — это делает тест невалидным и нестабильным.
Антипаттерн 8. Фальшивые HTTP-ответы и статусы 0/310/520
Симптом. В «обёртке» над requests
/httpx
ловятся любые сетевые исключения, после чего руками создаётся «синтетический» Response()
с придуманным status_code
— например 0
(нет сети), 310
(слишком много редиректов), 520
(«неизвестная ошибка»). Снаружи всё выглядит как «нормальный» HTTP-ответ.
Плохой пример (сокращённо)
import requests
def send_get_full_request(url, **kwargs) -> requests.Response:
try:
return requests.get(url, **kwargs) # реальный ответ (200/4xx/5xx)
except requests.exceptions.RequestException as e:
# "Синтетический" ответ вместо исключения
resp = requests.Response()
resp.url = url
resp._content = str(e).encode("utf-8")
resp.reason = type(e).__name__
if isinstance(e, requests.exceptions.Timeout):
resp.status_code = 408
elif isinstance(e, requests.exceptions.ConnectionError):
resp.status_code = 0 # ? несуществующий код
elif isinstance(e, requests.exceptions.TooManyRedirects):
resp.status_code = 310 # ? нестандартный код
else:
resp.status_code = 520 # ? «левый» код
return resp
Что здесь не так?
Подмена семантики.
0
— не HTTP-код вообще;310
/520
— нестандартные. Мониторинг, ретраи, SLA/алёрты, либы-клиенты и middleware перестают корректно отличать сетевые исключения от реальных HTTP-ответов сервера.Ложная телеметрия. Метрики «ошибок сервера 5xx» вдруг растут из-за таймаутов на клиенте — вы лечите не ту сторону.
Ломается контроль ошибок. Код, который рассчитывает на
raise_for_status()
/обработку исключений, получает «успешный вызов с кодом 0/520» и идёт дальше.Диагностика в никуда. Потерян стек исключения, не видно, где именно упали DNS/SSL/коннект/таймаут.
Несовместимость. Ретраи по «кодам 0/520» не работают с стандартными политиками (они ждут исключений, а не выдуманных статусов).

Как правильно?
Вариант A. «Чистый» контракт: либо реальный Response, либо исключение
На HTTP-уровне возвращаем реальный ответ (200/3xx/4xx/5xx) и при необходимости вызываем
raise_for_status()
.На сетевом уровне (таймаут/коннект/DNS/SSL) — не прячем проблему: пробрасываем исключение наружу. Ретраим по типам исключений.
import httpx
from backoff import on_exception, expo # pip install backoff
class Api:
def __init__(self, *, timeout: float = 5.0):
self.client = httpx.Client(timeout=timeout)
@on_exception(expo, (httpx.ConnectError, httpx.ReadTimeout), max_tries=3)
def get(self, url: str, **kwargs) -> httpx.Response:
resp = self.client.get(url, **kwargs)
return resp # real HTTP response; caller decides to raise_for_status()
def close(self):
self.client.close()
# использование
api = Api(timeout=5)
try:
r = api.get("https://api.example.com/items")
r.raise_for_status() # HTTP-ошибки — как HTTP-ошибки
data = r.json()
except (httpx.ConnectError, httpx.ReadTimeout) as e:
# Сетевые исключения: можно ретраить/логировать отдельно
...
except httpx.HTTPStatusError as e:
# Сервер ответил 4xx/5xx — это уже бизнес-логика обработки
...
Вариант B. Если нужен «обобщённый» результат — делаем явную модель
from dataclasses import dataclass
from typing import Any, Optional
import httpx
@dataclass
class ApiResult:
ok: bool
status: Optional[int] # None для сетевых ошибок
data: Optional[Any]
error: Optional[Exception]
def safe_get(client: httpx.Client, url: str) -> ApiResult:
try:
resp = client.get(url)
# не скрываем 4xx/5xx: это всё ещё реальный HTTP-ответ
return ApiResult(ok=resp.is_success, status=resp.status_code,
data=resp.json() if resp.headers.get("content-type","").startswith("application/json") else resp.text,
error=None)
except httpx.RequestError as e:
# сетевой уровень → status=None, ошибка явная
return ApiResult(ok=False, status=None, data=None, error=e)
Так тестам и прод-коду понятно, что именно случилось: HTTP-ошибка или сеть.
Важные практики!
Таймауты по умолчанию. Никогда не делайте «вечных» запросов.
Ретраи по исключениям, а не по «кодам 0/520». Добавляйте jitter/backoff.
Логируйте отдельными полями: метод, URL,
status_code
(если есть), тип исключения (если есть), длительность, попытку.Не глотайте стек. В логах должен сохраняться traceback сетевой ошибки.
Контент-тайп/парсинг. Не зовите бездумно
.json()
; проверяйте заголовок или используйте.is_success
и fallback кtext
.Метрики. Разводите «HTTP-ошибки» (4xx/5xx) и «сетевые исключения» (timeout/connect). Это разные SLO и разные владельцы.
Мини-чеклист
Не подменяем исключения на «ответы» с фальш-кодами.
Возвращаем реальный
Response
; сетевые проблемы — как исключения.Ретраим по
ConnectError
/Timeout
(backoff + jitter).Отдельные метрики/логи для HTTP-ошибок и сетевых исключений.
json=
вместо ручногоjson.dumps
;raise_for_status()
там, где нужно.Если хочется «универсальный результат» — делаем явную модель, а не придумываем HTTP-коды.
Антипаттерн 9. «Один файл на 3500 строк — это не фреймворк»
Симптом. Вся логика — от UI и API до SQL, VPN и нагрузочного тестирования — собрана в один гигантский файл на 3500 строк. Никаких модулей, никакой архитектуры, просто «куча всего».
Золотая табличка на таком коде могла бы быть такой:
«Работает — не трогай. Сломалось — не починишь».
Плохой пример (упрощённо)
# В одном и том же файле lib.py:
class LibUI:
def click_element_by_xpath(...): ...
def press_down_arrow(...): ...
# десятки методов для клавиатуры
class LibSQL:
def connect_postgres(...): ...
def connect_mysql(...): ...
def execute_query(...): ...
class LibAPI:
def send_post_request(...): ...
def send_get_request(...): ...
# внутри ещё exec и «фальшивые коды»
# ещё ниже: утилиты для VPN, файловой системы, нагрузочные тесты через httpx, CSV-обработчики...
Что здесь не так?
Нарушение SRP (Single Responsibility Principle). Один файл делает всё сразу. UI ≠ API ≠ SQL ≠ DevOps. Поддерживать невозможно.
Отсутствие модульности. Нельзя переиспользовать кусок кода в другом проекте: он тянет за собой весь «зоопарк».
Гигантский технический долг. Любая правка/рефакторинг → риск поломать чужую часть, потому что тесты завязаны на весь комбайн.
Порог входа. Новичок открывает файл и теряется. Где UI? Где база? Где API? Всё в одной простыне.
Нет тестируемости. Такой монолит нельзя изолированно покрыть юнит-тестами. Всё связано через глобалы.
Как правильно?
Делим на отдельные модули
ui.py
— обёртки над Playwright/Selenium.api.py
— клиент на httpx.db.py
— SQLAlchemy/psycopg2 утилиты.load.py
— нагрузочные сценарии в Locust.tools/
— вспомогательные функции (логирование, парсинг).
Каждый модуль отвечает только за своё.
Собираем архитектуру «фреймворка» как пакет
my_framework/
__init__.py
ui.py
api.py
db.py
load.py
utils/
logging.py
files.py
Пример
# ui.py
from playwright.sync_api import Page
def click(page: Page, selector: str):
page.locator(selector).click()
# api.py
import httpx
def get(client: httpx.Client, url: str):
return client.get(url)
→ Тестам больше не нужно импортировать «всё подряд». Они используют только нужное.
Мини-чеклист
Никогда не складывать всё в один «бог-файл».
Делить код на модули: UI, API, DB, нагрузка.
Использовать стандартные библиотеки (Playwright, httpx, SQLAlchemy, Locust).
Следовать SRP: один модуль = одна зона ответственности.
Писать юнит-тесты на утилиты, а не на «комбайн».
Почему это вредно?
На первый взгляд подобные «фреймворки» кажутся простыми и удобными: вызвал статический метод LibUI.click_element_by_xpath(...)
— и тест готов. Но это иллюзия простоты, за которую потом приходится очень дорого платить.
Эффект Даннинга–Крюгера в действии
Проблема в том, что авторы подобных решений сами не осознают глубину своих ошибок. Отсюда рождаются неудачные практики и самодельные обёртки/комбайны, хотя индустрия уже давно выработала зрелые инструменты и практики: Playwright для UI, httpx для HTTP, pytest для тестов, locust для нагрузки. Эти библиотеки не нуждаются в обёртках на 3500 строк с костылями и хаками.
Чем это плохо для новичков?
Формируется ложное представление, что «автоматизация» — это набор случайных статических методов.
Selenium-антипаттерны (
sleep
, стрелки вниз, дубли функций) закрепляются как «правильная практика».SQL, API и UI смешаны в одном файле → стираются границы ответственности. Студент перестаёт понимать, где UI, где база, а где сервис.
В реальном проекте такой подход ломается на первом же код-ревью или собеседовании.
Новичку действительно проще «вызвать метод и забыть», но это не обучение, а прививка неподдерживаемого кода. Потом придётся проходить «детокс»: переучиваться и заново строить мышление.
Что реально происходит?
Это не библиотека, а несвязанный набор утилит без архитектуры, с дублированием кода и небезопасными конструкциями.
Слоган «пишите меньше кода» достигается не грамотным дизайном, а тем, что всё завязано на костыли и хаки.
Технический долг зашивается прямо в головы новичков: они искренне думают, что «так и надо писать тесты».
Попытка «собрать всё и сразу» приводит к абсурду: UI-обвязка на Selenium, SQL для PostgreSQL/MySQL/SQLite, VPN, API на requests, нагрузка через httpx — всё в одном файле.
На деле мы рассмотрели лишь малую часть. Вся библиотека — это 3500 строк кода, которые проще выбросить и написать с нуля, чем пытаться поддерживать.
Как относиться?
Использовать такие вещи в продакшене или даже на учебном проекте — рискованно. Максимум — рассматривать как «справочник» того, что вообще можно сделать с Selenium или requests, а дальше переписать под задачу точечно.
Заключение
Мы все когда-то писали кривой код. Главное — вовремя от этого отвыкнуть и начать писать правильно. Если у вас есть примеры похожих граблей — поделитесь в комментариях: разберём и добавим в чеклист
Антипаттерны, которые мы рассмотрели, — это не просто «забавные костыли». Это системные ошибки, которые мешают автоматизации развиваться, превращают тесты в источник боли и откладывают технический долг на годы вперёд.
Главная мысль проста: писать автотесты правильно не сложнее, чем писать их неправильно. Разница лишь в том, что «правильные» практики дают надёжный, предсказуемый и поддерживаемый код, а «неправильные» — flaky, хаос и бесконечный рефакторинг.
Если вы только начинаете путь в автоматизации — ориентируйтесь на зрелые инструменты и устоявшиеся подходы:
Playwright или Selenium для UI,
SQLAlchemy для работы с БД,
Они уже решают 90% задач «из коробки» и избавляют вас от необходимости собирать собственный «велосипед на 3500 строк».
И самое важное: автотесты — это код. К нему применимы те же правила, что и к боевому продукту: модульность, читаемость, тестируемость, безопасность. Чем раньше вы это усвоите, тем меньше будет «детокса» в будущем.
Так что если вам попадётся библиотека-«комбайн» с магией и костылями — не спешите радоваться, что «писать тесты стало проще». Скорее всего, это ловушка. Лучше потратить чуть больше времени на освоение правильных практик и писать тесты, за которые не будет стыдно ни вам, ни вашему проекту.
Andrey_Solomatin
Какчество кода инструмента и его функциональность не всёгда связанны. Может быть он действительно удобный.
Вообще по описанию похоже на pytest, у него раньше была возможность использовать без установки. Толи скачать один файл, толи просто строкой вставить в тест. Сейчас найти не могу, скорее выпилили, было это лет 15 назад.