Как НЕ нужно писать автотесты на Python

от автора

Введение

В этой статье я разберу несколько типичных ошибок, которые встречаются при написании автотестов на 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)

Мини-чеклист безопасности и здравого смысла

  • Никаких exec, eval, «прескриптов» строкой.

  • Сериализация — через 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 или профильные инструменты

  • Для API — httpx + pytest (и отчётность Allure).

  • Для нагрузки — Locust/k6 (метрики, сценарии, профили).

  • Для 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() 

Что здесь не так?

  1. Дребезг и flaky. Ручные sleep(.1) — это гадание на таймингах. На CI/других машинах поведение будет разным.

  2. Дублирование. Десятки почти одинаковых функций → тяжело поддерживать/менять.

  3. Не по-пользовательски. В E2E мы проверяем сценарии пользователя. Он кликает по видимым элементам; «стрелочками вниз» скроллит редко.

  4. Преждевременная низкоуровневость. Нажатия клавиш — последняя надежда, когда нет нормальных локаторов/методов.

  5. Нарушение ожиданий. Нет явных ожиданий состояния (элемент появится/станет кликабельным), только «жми и надейся».

Как правильно (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

Что здесь не так?

  1. Браузерная безопасность. Современные браузеры запрещают устанавливать value у input[type=file] через JS. Это сознательное ограничение безопасности. Такой «трюк» либо не сработает, либо сломается при первом же обновлении.

  2. Ломает приложение. Насильная правка display/disabled меняет DOM и состояние виджетов, из-за чего падают обработчики, валидации, стили. Тест больше не имитирует пользователя.

  3. Flaky/нестабильность. Чуть другой CSS/фреймворк — и магия перестаёт работать.

  4. Отсутствие кросс-браузерности. То, что «завелось» в 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.pySQLAlchemy/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, хаос и бесконечный рефакторинг.

Если вы только начинаете путь в автоматизации — ориентируйтесь на зрелые инструменты и устоявшиеся подходы:

Они уже решают 90% задач «из коробки» и избавляют вас от необходимости собирать собственный «велосипед на 3500 строк».

И самое важное: автотесты — это код. К нему применимы те же правила, что и к боевому продукту: модульность, читаемость, тестируемость, безопасность. Чем раньше вы это усвоите, тем меньше будет «детокса» в будущем.

Так что если вам попадётся библиотека-«комбайн» с магией и костылями — не спешите радоваться, что «писать тесты стало проще». Скорее всего, это ловушка. Лучше потратить чуть больше времени на освоение правильных практик и писать тесты, за которые не будет стыдно ни вам, ни вашему проекту.


ссылка на оригинал статьи https://habr.com/ru/articles/942532/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *