Вступление

Автоматизация тестирования — это не только про код и фреймворки, но и про подходы, архитектуру и опыт. Многие начинающие автоматизаторы быстро погружаются в написание тестов, но при этом совершают ошибки, которые кажутся «мелочами» — пока не вырастают в большие проблемы: нестабильные тесты, сложность поддержки или путаницу в результатах.

В этой статье я собрал подборку самых частых ошибок, которые встречаются у начинающих инженеров по автоматизации. Цель этой статьи — не высмеять или «поймать на ошибках», а помочь разобраться: почему эти ошибки вредны и как их избежать.

Если вы только начинаете свой путь в автоматизации — возможно, узнаете себя и сможете избежать этих граблей. А если вы уже опытный инженер — есть шанс найти знакомые ситуации и, может быть, дополнить этот список своими наблюдениями.

Примеры в статье приведены на Python, но описанные подходы и принципы одинаково применимы и к другим языкам программирования.

1. Ошибка: один тест — одна проверка

Одна из первых «мантр», которые слышат начинающие автоматизаторы:

«Один тест — одна проверка»

Идея правильная: тест должен быть понятным и легко диагностируемым. Когда тест проверяет слишком много разных вещей, его падение превращается в квест: «а что именно пошло не так?»

Однако эту идею часто воспринимают слишком буквально:

def test_feature_check_title():
    login_page.check_title()

def test_feature_check_email():
    login_page.check_email()

def test_feature_check_password():
    login_page.check_password()

Формально — да, по одной проверке на тест. Но по сути это одна и та же функциональность (например, валидация формы логина), искусственно разорванная на отдельные кусочки. В итоге:

  • тестов становится больше, чем нужно;

  • поддержка усложняется;

  • запуск тестов становится дольше;

  • отчёты захламляются, а анализ результатов замедляется.

На практике важно другое: один тест — один сценарий или одна фича. Если логика проверки связана и естественным образом выполняется последовательно, её можно объединить:

def test_login_form_fields():
    login_page.check_title()
    login_page.check_email()
    login_page.check_password()

Если этот тест упал — значит, что-то в сценарии «форма логина» работает неправильно. И это нормально: отладить причину проще, чем поддерживать три отдельных теста, которые проверяют одно и то же «флоу».

Вывод:

  • Делите тесты по сценариям, а не по отдельным строчкам assert’ов.

  • Если проверки логически связаны — оставляйте их в одном тесте.

  • Если проверки независимы (например, разные фичи) — выносите их в отдельные тесты.

2. Ошибка: отсутствие test id

Одна из самых частых ошибок у начинающих автоматизаторов — игнорирование test id и привязка к хрупким селекторам:

class LoginPage:
    def __init__(self, page):
        self.email_input = page.locator("//div//div//input[@class='...']")
        self.password_input = page.locator("//div//div//input[@class='...']")

На первый взгляд — всё работает. Но как только дизайнер или фронтендер поменяет структуру или класс, тесты начнут падать. Это не баг продукта, а проблема теста — он оказался слишком хрупким.

Современный подход — использование специальных атрибутов для тестирования (data-testid, automation-id, и т.п.). Они не зависят от внешнего вида и верстки:

class LoginPage:
    def __init__(self, page):
        self.email_input = page.get_by_test_id("login-page-email-input")
        self.password_input = page.get_by_test_id("login-page-password-input")

Почему это важно:

  • тесты становятся стабильнее;

  • изменения UI не ломают сценарии;

  • тесты быстрее пишутся и проще поддерживаются.

Вывод: используйте test id, а не хрупкие XPath и CSS селекторы — это простой способ сделать ваши тесты более надёжными и «живучими» в долгосрочной перспективе.

3. Ошибка: Allure-шаги внутри теста

Новички часто начинают использовать Allure прямо внутри тестов:

def test_create_user():
    with allure.step('Create new user'):
        users_client.create_user()


def test_delete_user():
    with allure.step('Create new user'):
        user = users_client.create_user()

    with allure.step('Delete user'):
        users_client.delete_user(user.id)

На первый взгляд это выглядит удобно — отчёт получается красивым и понятным. Но на практике, когда тестов становится сотни или тысячи, дублирование шагов внутри каждого теста превращается в боль. Если завтра поменяется логика или название шага — менять придётся во всех тестах вручную.

Как лучше?

Вынесите шаги на уровень клиента и используйте их один раз:

class UsersClient:
    @allure.step('Create new user')
    def create_user(self):
        ...

    @allure.step('Delete user')
    def delete_user(self, user_id):
        ...

Теперь тесты становятся чистыми и читаемыми:

def test_create_user(users_client):
    users_client.create_user()


def test_delete_user(users_client):
    user = users_client.create_user()
    users_client.delete_user(user.id)

Что мы выиграли:

  • шаги отображаются в отчёте Allure автоматически;

  • тесты не захламлены техническими деталями;

  • поддержку и изменения делать проще: если меняется логика — правим в одном месте.

4. Ошибка: отсутствие API клиентов

Частая ошибка начинающих автоматизаторов — обращаться к API напрямую прямо из тестов:

def test_get_user():
    response = lib.get("http://localhost:8000/users/1")
    assert response.status_code == OK

На маленьком проекте это кажется удобным и быстрым: зачем писать ещё один класс, если и так работает? Но в перспективе это создаёт серьёзные проблемы:

  • URL и эндпоинты размазаны по тестам;

  • при изменении API нужно править десятки или сотни тестов;

  • тесты становятся «грязными» и плохо читаются.

Как лучше?

Используйте API‑клиенты. Да, кода изначально будет чуть больше, но в будущем это экономия времени и нервов:

class UsersClient:
    def get_user(self):
        return lib.get("http://localhost:8000/users/1")

Теперь тест выглядит чище:

def test_get_user():
    client = UsersClient()
    response = client.get_user()
    assert response.status_code == OK

Преимущества:

  • при изменении URL или параметров правим в одном месте;

  • тесты читаются как бизнес‑сценарии, а не как набор технических вызовов;

  • проще добавить логи, обработку ошибок и Allure‑шаги внутри клиента, не трогая сами тесты.

5. Ошибка: отсутствие PageObject

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

def test_feature():
    email_input = page.get_by_test_id("login-page-email-input")
    password_input = page.get_by_test_id("login-page-password-input")

    expect(email_input).to_be_visible()
    expect(password_input).to_be_visible()

На первый взгляд это удобно: всё на месте, тест сразу виден целиком. Но через пару месяцев проект разрастается, появляются десятки и сотни тестов — и в каждом из них повторяются одни и те же селекторы и проверки. Любое изменение верстки превращается в боль: приходится править десятки тестов вручную.

Как лучше — PageObject

Используйте PageObject — выделяйте логику взаимодействия со страницей в отдельные классы:

class LoginPage:
    def __init__(self, page):
        self.email_input = page.get_by_test_id("login-page-email-input")
        self.password_input = page.get_by_test_id("login-page-password-input")

    def check_visible(self):
        expect(self.email_input).to_be_visible()
        expect(self.password_input).to_be_visible()

Тест становится чище и лучше читается:

def test_feature(page):
    login_page = LoginPage(page)
    login_page.check_visible()

Преимущества:

  • изменения в верстке правятся в одном месте (в PageObject);

  • тесты читаются как бизнес‑сценарии, а не как технический набор команд;

  • легче добавлять дополнительную логику (логи, шаги, ожидания) без изменения тестов.

6. Ошибка: отсутствие параметризации

Эта ошибка встречается очень часто у начинающих автоматизаторов. Обычно выглядит она примерно так:

def test_feature1():
    login_page = LoginPage()
    login_page.check_login_form(email="user@mail.com", password="one")


def test_feature2():
    login_page = LoginPage()
    login_page.check_login_form(email="user@gmail.com", password="two")


def test_feature3():
    login_page = LoginPage()
    login_page.check_login_form(email="user@inbox.com", password="three")

А иногда ещё хуже — тест пишется один, а входные данные гоняются в цикле:

def test_feature():
    for email, password in [
        ("user@mail.com", "one"),
        ("user@gmail.com", "two"),
        ("user@inbox.com", "three"),
    ]:
        login_page = LoginPage()
        login_page.check_login_form(email=email, password=password)

На первый взгляд кажется, что так проще — но результат получается не очень:

  • если что-то падает, вы не знаете, на каких именно данных;

  • отчёт о тестировании становится нечитаемым (в отчёте это будет один тест);

  • сложнее управлять входными данными.

Как правильно?

Используйте встроенные возможности параметризации тестов, например в pytest:

import pytest

@pytest.mark.parametrize("email, password", [
    ("user@mail.com", "one"),
    ("user@gmail.com", "two"),
    ("user@inbox.com", "three"),
])
def test_feature(email, password):
    login_page = LoginPage()
    login_page.check_login_form(email=email, password=password)

Так каждый набор данных становится отдельным тестом:

  • упал конкретный вариант → сразу видно, где ошибка;

  • отчёт красивый и читаемый;

  • код лаконичный и поддерживаемый.

7. Ошибка: отсутствие фикстур

У начинающих автоматизаторов часто встречается такой код:

def test_feature1():
    login_page = LoginPage()
    login_page.check_visible()


def test_feature2():
    login_page = LoginPage()
    login_page.check_visible()

На первый взгляд всё вроде нормально: тесты выполняются, проверка проходит. Но есть одна проблема — повторение кода. Каждый тест создаёт LoginPage вручную, и таких мест может быть десятки или даже сотни.

Как правильно?

Вместо дублирования нужно использовать фикстуры. Например, в pytest:

import pytest

@pytest.fixture
def login_page():
    return LoginPage()

def test_feature1(login_page):
    login_page.check_visible()


def test_feature2(login_page):
    login_page.check_visible()

Теперь объект LoginPage создаётся одним местом (в фикстуре), а сами тесты стали чище и короче. Если завтра нужно будет, например, добавить авторизацию или настроить браузер для всех тестов — вы делаете это один раз в фикстуре, а не правите сотни тестов.

Бонус: уровни фикстур

  • scope="function" — фикстура создаётся перед каждым тестом (по умолчанию).

  • scope="module" — создаётся один раз на модуль.

  • scope="session" — один раз на всю сессию (например, если нужно поднять базу данных или сервис).

8. Ошибка: использование «магических чисел»

Часто у начинающих автоматизаторов в тестах можно встретить вот такой код:

def test_feature():
    response = users_client.get_user()
    assert response.status_code == 400

Сработает? Да, сработает. Но проблема в том, что «400» здесь непонятно что значит. Через месяц вы откроете тест и будете вспоминать: «А почему именно 400? Что мы тут проверяли? Это не найденный пользователь или ошибка валидации?»

Как правильно?

Вместо «магических чисел» используйте именованные константы, например, из стандартной библиотеки http:

from http import HTTPStatus

def test_feature():
    response = users_client.get_user()
    assert response.status_code == HTTPStatus.BAD_REQUEST

Теперь тест читается без лишних пояснений. По коду сразу понятно, что мы ожидаем ошибку в запросе, а не какой-то «мистический 400». А если в будущем поменяется код (например, сервис начнёт возвращать 422), вы сможете легко найти все такие проверки и обновить их.

Вывод

  • Используйте именованные константы (HTTPStatus, свои enum’ы или словари с кодами).

  • Избегайте «магических» значений в коде: это делает тесты понятнее и поддерживаемее.

9. Ошибка: отсутствие тестовых классов или сьютов

Часто встречается ситуация, когда все тесты пишутся «плоско», без объединения в классы или тестовые сьюты:

@pytest.mark.smoke
def test_feature1():
    ...

@pytest.mark.smoke
def test_feature2():
    ...

@pytest.mark.smoke
def test_feature3():
    ...

На маленьком проекте это может показаться нормальным, но когда тестов становится сотни или тысячи — поддержка превращается в боль. Найти нужный тест или сгруппировать логику становится сложно.

Как правильно?

Объединяйте тесты по смыслу в классы (сьюты). Например, если тесты относятся к одной функциональности, лучше оформить их так:

@pytest.mark.smoke
class TestFeature:
    def test_feature1(self):
        ...

    def test_feature2(self):
        ...

    def test_feature3(self):
        ...

Почему это важно?

  1. Структура. Когда тестов сотни, классы помогают быстро ориентироваться.

  2. Общие фикстуры. Можно подключать фикстуры для всего класса:

    @pytest.fixture(autouse=True)
    def prepare_data(self):
        ...
  3. Маркировка и запуск. Легче запускать целые группы (pytest -k TestFeature), а не отдельные тесты.

  4. Отчёты. В Allure и аналогах структура тестов отображается более читаемо.

Вывод

  • Объединяйте тесты в классы/сьюты.

  • Старайтесь, чтобы каждый класс тестировал одну конкретную область.

  • Это сделает проект понятнее и поддерживаемее.

10. Ошибка: использование assert вместо expect

Одна из частых ошибок — использовать обычные Python-ассерты для проверки UI-элементов:

def test_feature():
    element = page.locator("...")
    assert element.is_visible()

На первый взгляд всё нормально — проверка ведь работает. Но у Playwright (и некоторых других UI-фреймворков) есть собственный механизм проверок, специально заточенный под работу с асинхронным UI и ожиданиями:

def test_feature():
    element = page.locator("...")
    expect(element).to_be_visible()

Почему это важно?

  1. Ожидания внутри проверки. expect() автоматически дождётся, пока элемент появится, исчезнет или изменится. В то время как assert element.is_visible() проверяет состояние «здесь и сейчас», что часто приводит к флаки-тестам.

  2. Более читаемые ошибки. Если проверка упала, expect() выведет понятное сообщение с контекстом: «Ожидали, что элемент будет видим, но он не появился в течение X секунд».

  3. Лучшие отчёты. Использование expect() даёт более структурированную информацию в отчётах (например, в Allure).

Вывод

  • Для UI-тестов всегда используйте expect, а не «голый» assert.

  • assert хорошо подходит для чистых Python-проверок (например, числовых значений), но не для элементов UI.

11. Ошибка: хардкод значений вместо настроек

Иногда в коде тестов можно встретить что-то вроде:

def test_feature():
    response = lib.get("http://localhost:8000/users/1")
    assert response.status_code == OK

На первый взгляд, это работает. Но жёстко зашитые значения (http://localhost:8000) создают серьёзные проблемы:

  • поменялся домен или порт → придётся править десятки тестов;

  • одно окружение для разработчиков, другое для CI → тесты ломаются;

  • сложнее масштабировать проект на разные стенды.

Как делать правильно?

Выносите такие значения в конфигурацию:

class Settings:
    base_url = "http://localhost:8000"

А в тестах используйте их:

def test_feature(settings):
    response = lib.get(f"{settings.base_url}/users/1")
    assert response.status_code == OK

Дополнительно

  • В реальных проектах используют не просто класс, а файлы настроек (.env, config.yaml) и библиотеки вроде pydantic или dynaconf.

  • Это позволяет менять окружение одной переменной, без переписывания тестов.

12. Ошибка: хранение чувствительных данных в коде

Иногда встречается примерно такой код:

def test_login():
    response = lib.post(
        "http://localhost:8000/api/login",
        json={"username": "admin", "password": "SuperSecret123!"}
    )
    assert response.status_code == OK

На первый взгляд — удобно: всё в одном месте, тест запускается «из коробки». Но это плохая практика по нескольким причинам:

  • пароль хранится прямо в коде → он попадёт в репозиторий;

  • при смене пароля придётся править код;

  • если проект открытый (Open Source) или используется внешний CI/CD, данные могут утечь.

Как делать правильно?

Храните данные в переменных окружения:

export TEST_USER=admin
export TEST_PASSWORD=SuperSecret123!

Подключайте их через настройки (например, Pydantic Settings):

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    username: str
    password: str

settings = Settings()

def test_login():
    response = lib.post(
        "http://localhost:8000/api/login",
        json={"username": settings.username, "password": settings.password}
    )
    assert response.status_code == 200

Или храните секреты в безопасном месте (Vault, Doppler, 1Password CLI).

Резюме

Никогда не храните пароли, токены или ключи прямо в коде. Это не только риск утечек, но и лишняя боль при поддержке тестов.

13. Ошибка: асинхронность без надобности

Что обычно происходит? Начинающий автоматизатор видит современный Python и думает: «Асинхронность — это же круто, значит надо делать всё async!» В итоге появляются такие тесты:

import pytest

@pytest.mark.asyncio
async def test_get_user():
    response = await lib.get("http://localhost:8000/users/1")
    assert response.status_code == OK

В чём проблема?

  • Асинхронность добавляет сложность: нужны event-loop, поддержка в фреймворке, особая отладка.

  • Но выгоды нет: запрос всё равно выполняется последовательно, тесты не становятся быстрее, зато появляются проблемы с совместимостью библиотек.

  • Новичок тратит время на борьбу с RuntimeError: Event loop is closed, Task was destroyed but it is pending! и другими сюрпризами.

Как делать правильно?

Используйте асинхронность только там, где она реально нужна:

  • если вы тестируете нативно асинхронное API (например, websockets);

  • если библиотека тестирования или клиент изначально асинхронные (например, httpx.AsyncClient или playwright.async_api).

Во всех остальных случаях — обычный синхронный код проще и надёжнее:

def test_get_user():
    response = lib.get("http://localhost:8000/users/1")
    assert response.status_code == OK

14. Ошибка: связанные автотесты

Как выглядит на практике? Часто можно встретить такие тесты:

user_id = None

def test_create_user():
    global user_id
    user_id = users_client.create_user().id
    assert user_id is not None

def test_delete_user():
    response = users_client.delete_user(user_id)
    assert response.status_code == 200

Здесь test_delete_user зависит от того, что test_create_user отработал успешно. Если первый тест упадёт — второй даже не имеет смысла запускать.

Почему это плохо?

  • Автотесты должны быть изолированными: каждый тест можно запустить в любом порядке, хоть один, хоть все сразу.

  • Такой подход ломает параллельный запуск: тесты начинают «драться» за общие данные.

  • Если нужно прогнать только один тест (например, test_delete_user) — придётся запускать и test_create_user.

Как делать правильно?

Подготавливать данные внутри каждого теста или через фикстуры:

@pytest.fixture
def user_id(users_client):
    return users_client.create_user().id

def test_delete_user(user_id, users_client):
    response = users_client.delete_user(user_id)
    assert response.status_code == 200

Также можно:

  1. Использовать фабрики или сидинг. Можно заранее «посеять» пользователей (seed data) и работать с ними, не полагаясь на результат других тестов.

  2. Делать тесты атомарными. Каждый тест отвечает только за один сценарий и может быть выполнен независимо от остальных.

15. Ошибка: использование «сырых данных» вместо моделей

Начинающие автоматизаторы часто передают данные напрямую в виде словарей:

def test_create_user():
    response = client.post("/users", json={
        "name": "John",
        "age": 30,
        "email": "john@example.com"
    })
    assert response.status_code == OK
    

На старте это кажется простым и быстрым решением. Но со временем проект разрастается:

  • поля меняются;

  • появляются вложенные структуры;

  • кто-то случайно опечатался в ключе — и тест падает не там, где ожидаешь.

Как лучше?

Использовать модели данных:

  • Pydantic (рекомендуется для сложных структур и валидации);

  • dataclasses (как минимум, для читаемости и явной структуры).

from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int
    email: str

def test_create_user():
    user = User(name="John", age=30, email="john@example.com")
    response = client.post("/users", json=user.model_dump())
    assert response.status_code == OK

Преимущества:

  • автопроверка типов и обязательных полей;

  • меньше опечаток и «магических ключей»;

  • код тестов становится читабельнее и надёжнее.

Вывод:

Не передавайте «сырые словари» в коде. Используйте хотя бы dataclass, а лучше Pydantic-модели — это уменьшит количество скрытых ошибок и упростит поддержку тестов.

Вывод

Ошибки, которые делают начинающие автоматизаторы, чаще всего связаны не с языком программирования или выбором фреймворка, а с подходами и архитектурой. Даже идеально написанный тест не принесёт пользы, если он хрупкий, связан с другими тестами или требует правок в десятках мест при любом изменении продукта.

Главная мысль проста:

  • Старайтесь писать тесты так, чтобы их было легко поддерживать.

  • Используйте подходы и практики, которые давно зарекомендовали себя: PageObject, API‑клиенты, фикстуры, параметризацию, именованные константы.

  • Не бойтесь рефакторить свои тесты и учиться у более опытных коллег.

Автоматизация тестирования — это инженерная работа. Чем раньше вы начнёте мыслить как инженер, а не как «просто человек, который пишет тесты», тем быстрее вы вырастете и избежите этих ошибок.

Если хотите подробнее посмотреть примеры кода и готовые проекты, рекомендую эти статьи:

Если вы знаете ещё ошибки, которые встречаются у новичков — поделитесь ими в комментариях. Возможно, дополню список вашим опытом.

Комментарии (1)


  1. ahdenchik
    04.08.2025 08:39

    Кликбейтный заголовок: в широком смысле, "автоматизаторами" называют тех, кто автоматизирует бизнес