Введение
В этой статье я разберу несколько типичных ошибок, которые встречаются при написании автотестов на 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 строк».
И самое важное: автотесты — это код. К нему применимы те же правила, что и к боевому продукту: модульность, читаемость, тестируемость, безопасность. Чем раньше вы это усвоите, тем меньше будет «детокса» в будущем.
Так что если вам попадётся библиотека-«комбайн» с магией и костылями — не спешите радоваться, что «писать тесты стало проще». Скорее всего, это ловушка. Лучше потратить чуть больше времени на освоение правильных практик и писать тесты, за которые не будет стыдно ни вам, ни вашему проекту.
ссылка на оригинал статьи https://habr.com/ru/articles/942532/
Добавить комментарий