Как стать автором
Поиск
Написать публикацию
Обновить

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

Время на прочтение24 мин
Количество просмотров1.7K

Введение

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

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

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

Теги:
Хабы:
+5
Комментарии2

Публикации

Ближайшие события