Вступление
В этой статье я хочу показать, как на практике писать изоляционные UI-автотесты без флаков, стендов и бесконечной боли с окружением. Тема кажется противоречивой — UI-тесты традиционно считают самыми хрупкими и медленными — но на практике вокруг неё куда больше мифов, чем реальных ограничений.
Самое важное — такие UI-тесты не сложные. Они выглядят максимально просто, запускаются быстро и при этом дают высокую стабильность. Я бы даже сказал, что это эталон современного подхода к UI-автоматизации: минимальный код, полностью контролируемое окружение и запуск в CI/CD буквально в пару десятков строк.
Этот подход хорошо ложится на идею left shift testing и при этом отлично масштабируется. Без флаков, без магии ожиданий, без зависимости от внешних стендов и нестабильного backend’а.
Сразу дам определение, потому что термин «изоляционные UI-тесты» тоже используется нечасто. Изоляционные UI-тесты — это тесты пользовательского интерфейса, которые выполняются в полностью изолированной среде. Приложение поднимается локально, а все внешние зависимости — прежде всего backend-сервисы — полностью мокаются. В результате UI тестируется не «в вакууме», а в предсказуемом и управляемом окружении, где каждый сценарий задаётся явно.
Делается это не ради абстрактной «красоты», а ради стабильности, воспроизводимости и скорости. Мы убираем из тестов всё, что не относится напрямую к ответственности интерфейса, и проверяем ровно то, что пользователь видит и с чем взаимодействует.
Примеры в статье будут на Python и Playwright, но важно понимать: это не «питоновская» и не «плейрайтовская» магия. Точно такой же подход можно реализовать на Selenium, Cypress, WebdriverIO, Playwright на TypeScript и любом другом стеке. Ограничений по инструментам здесь нет — есть только архитектурное мышление и желание делать UI-тесты инженерно честно.
Ранее я уже писал про принципы стабильных автотестов и про left shift testing:
«Лучшие практики автоматизации тестирования: 9 принципов стабильных автотестов»
«Left Shift Testing: как выстроить процесс, чтобы тесты реально помогали»
После этих материалов мне регулярно задавали один и тот же вопрос: «Окей, звучит разумно. А как это выглядит на практике для UI?» В этой статье я как раз и показываю — без абстракций, без overengineering и без усложнений.
Сразу обозначу границы. Я не буду подробно объяснять, как работает браузер, что такое Page Object и как устроен Playwright под капотом. На эти темы уже есть огромное количество материалов, и при желании с ними легко ознакомиться: Habr
Здесь мы фокусируемся не на инструментах, а на подходе.
Контекст
Тестировать мы будем максимально простой фронтенд — Todo list. Это один index.html и немного vanilla JS, который:
при открытии страницы запрашивает список задач,
позволяет создать задачу,
позволяет удалить задачу,
после каждого действия перезагружает список.
На скриншоте это выглядит вот так: заголовок, поле ввода, кнопка Create и список задач с кнопками Delete.


Ключевой момент: фронт сразу ходит в API по адресу http://localhost:8000:
const API_BASE = 'http://localhost:8000/api/v1/tasks';И в рамках статьи мы сознательно делаем так, что «настоящего» backend’а у нас нет. Есть только контракт, который ожидает фронт.
Наша задача простая и инженерная: понять контракты → на основе контрактов сделать моки → написать изоляционные UI-тесты.
Контракт, который видит фронт
Фронтенду достаточно трёх HTTP-операций:
GET /api/v1/tasks— получить список задачPOST /api/v1/tasks— создать задачу ({ "title": "..." })DELETE /api/v1/tasks/{id}— удалить задачу
Ответ на GET — список задач вида:
[{ "id": "...", "title": "..." }]И всё. Никаких баз, транзакций и «внутренней кухни» нас не интересует — UI взаимодействует с внешним миром только через этот HTTP-контракт.
Почему это удобный пример
Важно зафиксировать: примеры будут на чистом HTML / JS не потому, что так “надо”, а чтобы не отвлекаться на детали фреймворков. Этот подход один в один переносится на React / Vue / Angular — разницы нет, пока UI ходит по HTTP и вы можете зафиксировать контракт.
data-testid — сразу делаем правильно
Ещё один принципиальный момент: в разметке заранее расставлены data-testid. Это сильно упрощает локаторы, делает тесты стабильнее и убирает привязку к CSS / текстам там, где она не нужна.
Как именно я подхожу к data-testid (схема нейминга, что стоит / не стоит размечать и почему) — у меня есть отдельная статья: «Тестовые идентификаторы: как и где расставлять правильно».
Дальше мы перейдём к мок-сервису: поднимем “несуществующий” backend на localhost:8000, научим его динамически задавать поведение из теста — и на этой базе соберём быстрые, детерминированные UI-автотесты.
Делаем мок
Мок в этом примере будет максимально простым. Это обычный HTTP-сервис, который притворяется backend’ом для фронтенда. Без overengineering, без «универсального решения на все случаи жизни». Ровно настолько сложным, насколько это нужно для изоляционных автотестов.
У мок-сервиса будет всего два административных эндпоинта:
POST /admin/rules— создать правила мокированияDELETE /admin/rules— удалить все правила мокирования
И один универсальный эндпоинт-диспетчер, который будет перехватывать все остальные запросы и отдавать ответы на основе заранее заданных правил.
Почему именно так.
Можно было пойти по пути персистентных моков: описать ответы в JSON-файлах, положить их рядом с мок-сервисом и просто раздавать по маршрутам. Такой подход вполне валиден, но он ближе к стабам. Он хорошо подходит, например, для нагрузочных тестов, когда нам не принципиально, какой именно ответ вернётся — главное, чтобы он был и соответствовал контракту.
Но здесь мы пишем UI-автотесты. И для UI особенно важно, чтобы:
браузер делал реальные HTTP-запросы,
фронтенд жил в привычном ему окружении,
а поведение backend’а было полностью контролируемым.
Для этого и нужен динамический мок, которым можно управлять во время выполнения теста. Именно поэтому правила мокирования создаются и удаляются через API.
Разумеется, существуют готовые решения вроде WireMock, в том числе с поддержкой динамических сценариев. В нашем случае они оказались избыточными: для UI-автотестов нам было важно получить минимальный, полностью контролируемый мок с прозрачным поведением и без лишней инфраструктуры.
Ниже — реализация. Она нарочно сделана минималистичной, чтобы было видно саму идею, а не обвязку.
Схема правил мокирования
from http import HTTPMethod, HTTPStatus
from typing import Any
from pydantic import Field
from libs.schema.base import BaseSchema
class MockRuleSchema(BaseSchema):
# Query-параметры запроса, по которым будет происходить матчинг
# Если пусто — запрос без query
query: dict[str, str] = Field(default_factory=dict)
# Полный путь запроса (например: /api/v1/users/{id})
route: str
# HTTP-метод, по умолчанию GET
method: HTTPMethod = HTTPMethod.GET
# Тело ответа, которое мок вернёт клиенту
# Тип Any, так как мок не накладывает ограничений на структуру
response: Any = None
# HTTP-статус ответа
status_code: HTTPStatus = HTTPStatus.OK
class CreateMockRulesRequest(BaseSchema):
# Список правил, которые будут добавлены в мок за один запрос
rules: list[MockRuleSchema]Одно правило мокирования описывает:
HTTP-метод,
путь запроса,
query-параметры,
тело ответа,
HTTP-статус.
Никакой магии. Если входящий запрос полностью совпадает с правилом — мок отдаёт заданный ответ.
Хранилище правил
import asyncio
from fastapi import Request
from tests.mock.schema import MockRuleSchema
class MockRulesStore:
def __init__(self):
# Lock нужен, так как правила могут изменяться во время обработки запросов
self.lock = asyncio.Lock()
self.rules: list[MockRuleSchema] = []
async def create(self, rules: list[MockRuleSchema]) -> None:
# Добавляем новые правила в общее хранилище
async with self.lock:
self.rules.extend(rules)
async def find(self, request: Request) -> MockRuleSchema | None:
# Извлекаем параметры входящего запроса
request_query = dict(request.query_params)
request_route = request.url.path
request_method = request.method
# Последовательно ищем правило, полностью совпадающее с запросом
async with self.lock:
for rule in self.rules:
if rule.method.value != request_method:
continue
if rule.route != request_route:
continue
if rule.query != request_query:
continue
return rule
# Если подходящего правила нет — возвращаем None
return None
async def clear(self) -> None:
# Полная очистка всех правил (обычно используется между тестами)
async with self.lock:
self.rules.clear()Здесь всё предельно прямолинейно:
правила хранятся в памяти,
доступ защищён
asyncio.Lock,поиск правила — это обычное последовательное сравнение метода, пути и query-параметров.
Да, это не самый оптимальный алгоритм. И да, здесь нет индексов, кэшей и прочих оптимизаций. Но для изоляционных автотестов это вообще не проблема. Правил мало, тесты быстрые, читаемость и предсказуемость важнее микросекунд.
API мок-сервиса
from fastapi import APIRouter, Request, HTTPException, status
from fastapi.responses import JSONResponse
from tests.mock.rules import MockRulesStore
from tests.mock.schema import CreateMockRulesRequest
mock_router = APIRouter()
mock_rules_store = MockRulesStore()
@mock_router.post("/admin/rules", status_code=status.HTTP_201_CREATED)
async def create_mock_rule_view(request: CreateMockRulesRequest):
# Создаём новые правила мокирования
await mock_rules_store.create(request.rules)
@mock_router.delete("/admin/rules", status_code=status.HTTP_204_NO_CONTENT)
async def delete_mock_rule_view():
# Полностью очищаем правила (обычно вызывается в teardown тестов)
await mock_rules_store.clear()
@mock_router.api_route(
"/{full_path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
)
async def dispatch_mock_rule_view(request: Request):
# Универсальный обработчик всех запросов к мок-сервису
rule = await mock_rules_store.find(request=request)
if not rule:
# Если правило не найдено — явно сигнализируем об этом
raise HTTPException(status_code=404, detail="no mock rule")
# Возвращаем заранее заданный ответ
return JSONResponse(content=rule.response, status_code=rule.status_code)Здесь три ключевых момента.
Первое — административные эндпоинты. Они позволяют из теста:
задать нужное поведение сервисов,
полностью очистить состояние мока между тестами.
Второе — универсальный dispatcher. Он принимает любой HTTP-запрос и пытается сопоставить его с правилами. Если правило найдено — возвращается нужный ответ. Если нет — 404. Никаких «молчаливых» фолбеков, всё максимально явно.
Третье — отсутствие логики. Мок ничего не считает, ничего не трансформирует и ничего не «угадывает». Он либо отдаёт заданный ответ, либо падает. Именно это делает тесты детерминированными.
Мок готов. Как видно, всё максимально просто и прозрачно — порядка ста строк кода. И это осознанно. Цель этого примера — не написать «идеальный мок на все случаи жизни», а показать сам подход. Дальше вы уже сами решаете: усложнять его, расширять или заменить на стороннее решение.
С точки зрения фронта ничего переключать и настраивать не нужно: он уже ходит на localhost:8000, и именно на этом адресе мы будем поднимать мок-сервис. С точки зрения UI код остаётся полностью неизменным — мы просто подсовываем ему предсказуемый backend.
Page Object и Page Factory: фиксируем UI-контракт страницы
Перед тем как писать UI-автотесты, нам нужно зафиксировать контракт пользовательского интерфейса. В UI-мире таким контрактом выступает Page Object — описание того, что есть на странице и как с этим можно взаимодействовать.
Сразу обозначу границы. В этой статье мы не будем подробно разбирать, что такое Page Object, Page Component и Page Factory, зачем они нужны и какие проблемы решают. На эту тему у меня есть отдельная большая статья, где всё разобрано детально, с примерами и запуском в CI/CD: «UI автотесты на Python с запуском на CI/CD и Allure отчетом. PageObject, PageComponent, PageFactory».
Здесь мы исходим из того, что:
Page Object — это контракт страницы,
тесты работают только с Page Object,
детали реализации UI спрятаны внутри него.
Наша цель — показать как этот подход ложится на изоляционные UI-тесты, а не объяснять сам паттерн с нуля.
Page Factory элементы
Начнём с Page Factory элементов. Их задача — инкапсулировать типовые взаимодействия с UI, чтобы тесты и страницы не работали напрямую с Playwright-локаторами.
Важно: эти элементы не содержат бизнес-логики. Они не знают, что тестируется, они знают только как работать с конкретным типом UI-элемента.
BaseElement
./tests/elements/base_element.py
from playwright.sync_api import Page, Locator, expect
class BaseElement:
def __init__(self, page: Page, locator: str):
# Каждый элемент знает:
# - страницу, на которой он живёт
# - шаблон testid, по которому его можно найти
self.page = page
self.locator = locator
@property
def type_of(self) -> str:
# Тип элемента используется только для читаемости и отладки
# (например, в логах или ошибках)
return "base element"
def get_locator(self, nth: int = 0, **kwargs) -> Locator:
# Ключевая идея:
# locator — это шаблон, который может параметризоваться
# (например: tasks-page-task-title-{task_id})
locator = self.locator.format(**kwargs)
# Мы намеренно используем get_by_test_id:
# - локаторы стабильны
# - не зависят от верстки и CSS
# - отражают контракт UI
return self.page.get_by_test_id(locator).nth(nth)
def click(self, nth: int = 0, **kwargs):
# Базовое действие: клик по элементу
locator = self.get_locator(nth, **kwargs)
locator.click()
def check_visible(self, nth: int = 0, **kwargs):
# Базовая проверка видимости элемента
locator = self.get_locator(nth, **kwargs)
expect(locator).to_be_visible()
def check_have_text(self, text: str, nth: int = 0, **kwargs):
# Проверка текста — часть UI-контракта,
# а не логики конкретного теста
locator = self.get_locator(nth, **kwargs)
expect(locator).to_have_text(text)Здесь важно несколько принципиальных моментов:
элементы работают только через
data-testid;локаторы параметризуемы — это позволяет работать со списками и динамическими элементами;
проверки и действия живут рядом, а не размазаны по тестам.
Button
from playwright.sync_api import expect
from tests.elements.base_element import BaseElement
class Button(BaseElement):
@property
def type_of(self) -> str:
return "button"
def check_enabled(self, nth: int = 0, **kwargs):
# Проверка доступности кнопки —
# часть контракта UI, а не бизнес-сценария
locator = self.get_locator(nth, **kwargs)
expect(locator).to_be_enabled()Кнопка расширяет базовый элемент только тем, что имеет смысл именно для кнопки. Никакой лишней функциональности.
Input
from playwright.sync_api import expect
from tests.elements.base_element import BaseElement
class Input(BaseElement):
@property
def type_of(self) -> str:
return "input"
def fill(self, value: str, nth: int = 0, **kwargs):
# Заполнение инпута — атомарное действие
locator = self.get_locator(nth, **kwargs)
locator.fill(value)
def check_have_value(self, value: str, nth: int = 0, **kwargs):
# Проверка значения — способ убедиться,
# что UI отреагировал на действие
locator = self.get_locator(nth, **kwargs)
expect(locator).to_have_value(value)Text
from tests.elements.base_element import BaseElement
class Text(BaseElement):
@property
def type_of(self) -> str:
return "text"Для текстового элемента нам не нужно ничего, кроме базовых проверок — и это нормально. Page Factory элементы не обязаны быть «равномерно сложными».
Page Object страницы задач
Теперь соберём всё это в Page Object конкретной страницы — Todo list.
from dataclasses import dataclass
from typing import Self, Protocol
from playwright.sync_api import Page
from tests.elements.button import Button
from tests.elements.input import Input
from tests.elements.text import Text
from tests.pages.base_page import BasePage
# Протокол описывает минимальный контракт задачи,
# который нам нужен на уровне UI.
# Page Object не должен зависеть от конкретных схем backend’а
# или тестовых моделей — ему важны только те поля,
# которые реально отображаются на странице.
class TaskLike(Protocol):
id: str
title: str
# Описание того, как одна задача должна выглядеть на странице.
# Это не "данные", а UI-контракт: что именно пользователь
# должен увидеть в интерфейсе.
@dataclass
class CheckVisibleTaskParams:
id: str
title: str
# Контракт состояния страницы в целом.
# Мы описываем ожидаемое состояние декларативно,
# а не проверяем UI по шагам в каждом тесте.
@dataclass
class CheckVisibleParams:
tasks: list[CheckVisibleTaskParams]
@classmethod
def empty(cls) -> Self:
# Явное описание пустого состояния страницы.
# Это упрощает тесты и делает сценарии читаемыми.
return cls(tasks=[])
@classmethod
def build(cls, tasks: list[TaskLike]) -> Self:
# Преобразование доменных объектов (или моков)
# в UI-контракт страницы.
# Page Object не знает, откуда пришли эти данные —
# он работает только с тем, что должен отобразить.
return cls(
tasks=[
CheckVisibleTaskParams(id=task.id, title=task.title)
for task in tasks
]
)
class TasksPage(BasePage):
def __init__(self, page: Page):
super().__init__(page)
# Все элементы страницы объявлены в одном месте.
# Это и есть UI-контракт страницы: если здесь что-то меняется,
# значит меняется интерфейс, а не тесты.
self.title = Text(page, "tasks-page-title")
self.task_input = Input(page, "tasks-page-task-input")
self.task_title = Text(page, "tasks-page-task-title-{task_id}")
self.create_task_button = Button(page, "tasks-page-create-task-button")
self.delete_task_button = Button(page, "tasks-page-delete-task-button-{task_id}")
def check_visible(self, params: CheckVisibleParams):
# Проверка базового состояния страницы.
# Этот метод отвечает за валидацию UI-контракта,
# а не за конкретный бизнес-сценарий.
self.title.check_visible()
self.title.check_have_text("Todo list")
self.task_input.check_visible()
self.create_task_button.check_visible()
self.create_task_button.check_enabled()
self.create_task_button.check_have_text("Create")
# Проверяем список задач декларативно,
# на основе ожидаемого состояния страницы.
# Тесты не проверяют DOM напрямую — они проверяют,
# что страница соответствует переданному контракту.
for task in params.tasks:
self.task_title.check_visible(task_id=task.id)
self.task_title.check_have_text(task.title, task_id=task.id)
self.delete_task_button.check_visible(task_id=task.id)
def fill_task_form(self, title: str):
# Пользовательское действие: заполнение формы.
# Мы сразу валидируем результат действия,
# чтобы ошибки UI ловились как можно раньше.
self.task_input.fill(title)
self.task_input.check_have_value(title)
def click_create_task_button(self):
# Явно проверяем, что UI готов к действию,
# прежде чем кликать. Это часть контракта интерфейса,
# а не "ожидание ради ожидания".
self.create_task_button.check_enabled()
self.create_task_button.click()
def click_delete_task_button(self, task_id: str):
# Удаление задачи — параметризованное действие,
# работающее с конкретным элементом списка.
self.delete_task_button.check_enabled(task_id=task_id)
self.delete_task_button.click(task_id=task_id)Что в итоге
В результате у нас:
Page Object описывает UI-контракт страницы;
Page Factory элементы инкапсулируют работу с DOM;
тесты работают только с Page Object;
UI-логика и backend-моки полностью разделены.
Дальше мы будем использовать этот Page Object в тестах, а поведение backend’а управлять через HTTP-мок — и именно в этом месте изоляционные UI-тесты начинают показывать свою реальную силу.
Контракт данных: что фронт ожидает от backend’а
Перед тем как писать моки и UI-тесты, важно зафиксировать ещё одну вещь — контракт данных, с которыми работает фронтенд.
Фронт не оперирует абстрактными «словарами» и «JSON-объектами». Он ожидает вполне конкретную структуру данных, и именно эта структура определяет его поведение. Если контракт соблюдён — UI работает корректно. Если нет — это либо ошибка backend’а, либо отдельный сценарий, который мы можем явно смоделировать в тесте.
Поэтому дальше мы будем:
описывать ответы backend’а через явные схемы,
использовать их как основу для моков,
и генерировать тестовые данные автоматически, а не хардкодить их в тестах.
Генерация тестовых данных
Для генерации тестовых данных мы используем faker. Это позволяет:
не захламлять тесты хардкодом,
получать реалистичные значения,
при этом сохранять читаемость сценариев.
Важно: генерация данных — это вспомогательная задача. Она не влияет на логику тестов и не подменяет бизнес-смысл сценариев.
from faker import Faker
class Fake:
def __init__(self, faker: Faker):
# Обёртка над Faker нужна не для "магии",
# а чтобы централизовать генерацию данных
# и иметь единый интерфейс во всём тестовом проекте.
self.faker = faker
def uuid(self) -> str:
# Идентификатор задачи.
# В UI он используется как ключ элемента
# и часть data-testid.
return self.faker.uuid4()
def string(self, min_chars: int = 20, max_chars: int = 30) -> str:
# Заголовок задачи — обычная строка,
# достаточной длины, чтобы проверить отображение в UI.
return self.faker.pystr(min_chars=min_chars, max_chars=max_chars)
# Глобальный экземпляр используется осознанно:
# для тестов нам не нужна строгая детерминированность значений,
# важна только их валидность с точки зрения контракта.
fake = Fake(faker=Faker())Схема задачи
Теперь опишем контракт задачи так, как его видит фронт.
from pydantic import BaseModel, Field, RootModel
from tests.libs.fake import fake
class TaskSchema(BaseModel):
# Идентификатор задачи.
# Используется фронтом для:
# - генерации data-testid
# - адресации операций удаления
id: str = Field(default_factory=fake.uuid)
# Заголовок задачи.
# Это единственное бизнес-поле,
# которое отображается в интерфейсе.
title: str = Field(default_factory=fake.string)Здесь принципиально важно: мы описываем не “как устроен backend”, а “что ожидает UI”. Если backend в реальности хранит больше полей — UI это не волнует.
Список задач
Ответ backend’а на GET /api/v1/tasks — это список задач. Мы фиксируем это явно.
class TasksSchema(RootModel[list[TaskSchema]]):
# Корневой список задач.
# Используется фронтом для отрисовки списка
# и в тестах — для описания ожидаемого состояния страницы.
root: list[TaskSchema]Это даёт нам несколько важных преимуществ:
моки строятся по контракту, а не «как получится»;
тесты работают с типизированными объектами;
Page Object получает данные в понятном и предсказуемом виде;
ошибки контракта ловятся сразу, а не на уровне DOM.
Почему это важно для UI-тестов
Этот слой кажется избыточным для простого Todo-примера, но именно он делает подход масштабируемым.
В реальных проектах:
структура ответов сложнее,
сценариев больше,
а UI зависит от данных сильнее.
Фиксируя контракт через схемы, мы:
упрощаем работу с моками,
делаем тесты читабельнее,
и сохраняем ту же философию, что и в API-тестах: контракт → моки → сценарии.
В следующих шагах мы начнём использовать эти схемы в фикстурах и посмотрим, как на их основе динамически управлять поведением backend’а прямо из UI-тестов.
Конфигурация тестового окружения
Чтобы изоляционные UI-тесты были воспроизводимыми и легко запускались локально и в CI/CD, нам нужна минимальная, но явная конфигурация окружения. Никакой магии — только то, что действительно используется в тестах.
Важно сразу зафиксировать: мы не вводим отдельные режимы для UI-тестов, не городим сложные флаги и не меняем поведение приложения. Мы просто описываем, где находится фронт и где находится мок-сервис.
Настройки тестов
import os
from pydantic import HttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from tests.libs.config.http import HTTPServerConfig, HTTPClientConfig
class Settings(BaseSettings):
model_config = SettingsConfigDict(
# Разрешаем дополнительные поля,
# чтобы конфигурация легко расширялась
# без переписывания кода.
extra='allow',
# Источник конфигурации задаётся через ENV_FILE.
# Это позволяет использовать одни и те же тесты
# локально и в CI/CD без изменений.
env_file=os.environ.get('ENV_FILE'),
env_file_encoding='utf-8',
# Поддержка вложенных параметров
# (например: MOCK_HTTP_CLIENT.HOST)
env_nested_delimiter='.'
)
# Базовый URL фронтенда.
# Именно по этому адресу тесты открывают приложение в браузере.
app_url: HttpUrl
# Флаг headless-режима браузера.
# В CI обычно true, локально — по желанию.
headless: bool
# Конфигурация HTTP-клиента для работы с мок-сервисом.
# Используется тестами для управления поведением backend’а.
mock_http_client: HTTPClientConfig
# Конфигурация HTTP-сервера мок-сервиса.
# Нужна для его запуска в тестовом окружении.
mock_http_server: HTTPServerConfig
# Глобальный объект настроек используется во всём тестовом проекте.
# Это упрощает доступ к конфигурации и делает её единой точкой правды.
settings = Settings()Здесь нет ничего специфичного для UI или Playwright — это просто аккуратная конфигурация, которая:
читается из окружения,
одинаково работает локально и в CI,
не требует изменений кода при переключении окружений.
Конфигурация для CI
Для запуска тестов в CI используется обычный .env.ci файл:
APP_URL=http://localhost:8080
HEADLESS=true
# mock-service
MOCK_HTTP_CLIENT.HOST=http://localhost:8000
MOCK_HTTP_SERVER.PORT=8000
MOCK_HTTP_SERVER.HOST=0.0.0.0
MOCK_HTTP_SERVER.WORKERS=1Здесь важно несколько принципиальных моментов:
фронт доступен по
localhost:8080— именно туда смотрят UI-тесты;мок-сервис поднимается на
localhost:8000— туда ходит фронт за данными;для мок-сервиса используется один воркер.
Последний пункт принципиален: мок хранит правила в памяти процесса. Один воркер гарантирует детерминированное поведение и отсутствие гонок между тестами. Для изоляционных UI-тестов нам важнее предсказуемость, чем внутренняя параллельность.
Клиент мок-сервиса: управляем backend’ом так же, как реальным сервисом
Прежде чем использовать мок в фикстурах и тестах, нам нужен аккуратный способ с ним взаимодействовать. И здесь есть принципиальный момент: мы не управляем моками через внутренние вызовы или shared state — мы работаем с ним по HTTP.
Это осознанное решение. Для тестов мок — такой же внешний сервис, как и любой другой backend. Он поднимается отдельно, имеет свой API и управляется через обычный HTTP-клиент.
from httpx import Response
from tests.config import settings
from tests.libs.http.client.base import HTTPClient, get_http_client
from tests.libs.http.client.handlers import handle_http_error, HTTPClientError
from tests.libs.logger import get_logger
from tests.mock.schema import CreateMockRulesRequest
class MockHTTPClientError(HTTPClientError):
# Кастомное исключение клиента мок-сервиса.
#
# Оно позволяет явно отделить ошибки взаимодействия с моками
# от всех остальных HTTP-ошибок в тестовом проекте.
pass
class MockHTTPClient(HTTPClient):
@handle_http_error(client='MockHTTPClient', exception=MockHTTPClientError)
def create_mock_rule_api(self, request: CreateMockRulesRequest) -> Response:
# Создание правил мокирования через admin API.
#
# Тесты не знают и не должны знать,
# как именно мок хранит правила внутри.
# Их ответственность — описать желаемое поведение backend’а.
return self.post(
'/admin/rules',
json=request.model_dump(mode='json', by_alias=True)
)
@handle_http_error(client='MockHTTPClient', exception=MockHTTPClientError)
def delete_mock_rule_api(self) -> Response:
# Полная очистка правил мокирования.
#
# Используется для явного сброса состояния между сценариями,
# чтобы каждый тест начинался с чистого backend’а.
return self.delete('/admin/rules')
def get_mock_http_client() -> MockHTTPClient:
# Фабрика HTTP-клиента мок-сервиса.
#
# Здесь мы используем те же базовые абстракции,
# что и для любых других HTTP-клиентов в проекте.
# Это важно: тесты работают с моками так же,
# как с реальными сервисами.
logger = get_logger("MOCK_SERVICE_HTTP_CLIENT")
client = get_http_client(
logger=logger,
config=settings.mock_http_client
)
return MockHTTPClient(client=client)Этот клиент может показаться «лишним», но именно он делает всю схему цельной:
мок — это отдельный сервис, а не внутренняя заглушка;
управление моками происходит через HTTP, а не через Python-объекты;
тесты используют те же клиентские абстракции, что и прод-код.
В результате:
исчезает скрытая магия;
тесты остаются честными по отношению к архитектуре;
сценарии легко читаются и расширяются.
Дальше этот клиент используется в фикстурах и тестах ровно так же, как любой другой HTTP-клиент — и это ещё один кирпичик в общей идее изоляционных UI-тестов.
Фикстуры: собираем UI-сценарии, а не окружение
В классических UI-тестах фикстуры часто превращаются в тяжёлый сетап: подготовка данных, прогрев стендов, сиды в базе, ожидания и костыли. В изоляционном подходе всё иначе.
Здесь фикстуры делают ровно две вещи:
Инициализируют инфраструктуру теста — браузер, Page Object, HTTP-клиенты.
Декларативно задают поведение backend’а через мок-сервис.
Мы не «готовим данные». Мы описываем сценарий, в котором UI должен оказаться.
import pytest
from playwright.sync_api import sync_playwright, Page
from tests.config import settings
from tests.libs.routes import APIRoutes
from tests.mock.client import MockHTTPClient, get_mock_http_client
from tests.mock.schema import CreateMockRulesRequest, MockRuleSchema
from tests.pages.tasks_page import TasksPage
from tests.schema.tasks import TasksSchema, TaskSchema
@pytest.fixture
def chromium_page() -> Page:
# Базовая фикстура браузера.
# Мы поднимаем реальный Chromium, без перехвата network-слоя
# и без каких-либо UI-моков.
#
# Это принципиально: браузер должен работать так же,
# как он работает в проде — делать реальные HTTP-запросы.
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=settings.headless)
# base_url задаётся через конфигурацию,
# чтобы тесты не зависели от конкретного окружения
# (локально, CI, другой порт и т.д.)
context = browser.new_context(base_url=f"{settings.app_url}/")
# Тесты получают уже готовую страницу,
# не заботясь о жизненном цикле браузера
yield context.new_page()
# Корректно закрываем браузер после теста
browser.close()
@pytest.fixture
def tasks_page(chromium_page: Page) -> TasksPage:
# Page Object создаётся поверх реальной страницы браузера.
#
# Тесты дальше работают только с Page Object,
# не взаимодействуя напрямую с DOM, локаторами
# или Playwright API.
return TasksPage(page=chromium_page)
@pytest.fixture
def mock_http_client() -> MockHTTPClient:
# HTTP-клиент для управления мок-сервисом.
#
# Через этот клиент тесты динамически задают
# поведение backend’а для конкретного сценария,
# не вмешиваясь в код фронта.
return get_mock_http_client()
@pytest.fixture
def mock_view_tasks(mock_http_client: MockHTTPClient) -> TasksSchema:
# Формируем тестовые данные строго по контракту,
# а не через абстрактные словари.
#
# С точки зрения сценария это означает:
# "когда пользователь открывает страницу,
# backend возвращает список из трёх задач".
tasks = TasksSchema(root=[TaskSchema() for _ in range(3)])
# Описываем поведение backend’а через мок:
# при GET-запросе к /api/v1/tasks
# он вернёт заранее заданный список задач.
#
# Никаких реальных сервисов здесь нет —
# только контракт и управляемый ответ.
mock_http_client.create_mock_rule_api(
CreateMockRulesRequest(
rules=[
MockRuleSchema(
route=APIRoutes.TASKS,
response=tasks.model_dump()
)
]
)
)
# Возвращаем данные в тест,
# чтобы использовать их для проверки UI-состояния страницы
return tasks
@pytest.fixture
def clear_mock(mock_http_client: MockHTTPClient) -> None:
# Фикстура для гарантированной очистки состояния мока.
#
# Каждый тест — это полностью изолированный сценарий.
# Мы не полагаемся на порядок выполнения тестов
# и не делим состояние между ними.
yield
# После завершения теста полностью очищаем правила мокирования,
# чтобы следующий сценарий начинался с чистого листа
mock_http_client.delete_mock_rule_api()На этом этапе хорошо видно ключевое отличие изоляционных UI-тестов от классических e2e:
фикстуры не подготавливают данные и окружение;
фикстуры описывают сценарий, в котором должен оказаться UI;
браузер всегда реальный и работает по настоящему HTTP;
изоляция достигается не на уровне Playwright, а на уровне backend’а.
UI-тесты перестают быть «тяжёлой интеграцией» и превращаются в детерминированные сценарии с полностью контролируемым окружением.
Именно поэтому дальше в тестах почти не останется инфраструктурного кода — останется только описание пользовательского поведения.
UI-тесты: когда сценарий — это весь тест
После всей инфраструктуры — моков, контрактов, Page Object и фикстур — сами UI-тесты становятся максимально тонкими. И это не «магия» и не «удачный пример», а прямое следствие архитектуры.
Тесты ниже:
работают через реальный браузер,
делают реальные HTTP-запросы,
полностью управляют поведением backend’а,
и при этом читаются как описание пользовательского сценария.
from http import HTTPMethod
import pytest
from tests.libs.routes import APIRoutes
from tests.mock.client import MockHTTPClient
from tests.mock.schema import CreateMockRulesRequest, MockRuleSchema
from tests.pages.tasks_page import TasksPage, CheckVisibleParams
from tests.schema.tasks import TasksSchema, TaskSchema
@pytest.mark.ui
@pytest.mark.tasks
@pytest.mark.regression
@pytest.mark.usefixtures("clear_mock")
class TestTasks:
# Все тесты в этом классе — UI-сценарии.
# Мы явно маркируем их как ui и regression,
# чтобы ими можно было управлять в CI/CD.
#
# clear_mock используется на уровне класса,
# чтобы каждый тест начинался с чистого состояния backend’а.
def test_view_tasks(self, tasks_page: TasksPage, mock_view_tasks: TasksSchema):
# Сценарий:
# пользователь открывает страницу и видит список задач.
#
# Поведение backend’а для этого сценария
# полностью задано фикстурой mock_view_tasks.
tasks_page.visit("/")
# Проверяем, что UI соответствует ожидаемому состоянию,
# описанному через декларативный контракт страницы.
tasks_page.check_visible(
CheckVisibleParams.build(tasks=mock_view_tasks.root)
)
def test_create_task(self, tasks_page: TasksPage, mock_http_client: MockHTTPClient):
# Сценарий:
# пользователь открывает страницу без задач
# и создаёт новую задачу.
tasks_page.visit("/")
tasks_page.check_visible(CheckVisibleParams.empty())
# Описываем, как backend должен себя вести:
# - POST-запрос на создание задачи проходит успешно
# - после этого GET-запрос возвращает обновлённый список
task = TaskSchema()
tasks = TasksSchema(root=[task])
mock_http_client.create_mock_rule_api(
CreateMockRulesRequest(
rules=[
# Обработка создания задачи
MockRuleSchema(
route=APIRoutes.TASKS,
method=HTTPMethod.POST
),
# Обновлённый список задач
MockRuleSchema(
route=APIRoutes.TASKS,
response=tasks.model_dump()
)
]
)
)
# UI-действия пользователя
tasks_page.fill_task_form(title=task.title)
tasks_page.click_create_task_button()
# Проверяем, что UI отобразил новое состояние,
# соответствующее заданному backend-контракту
tasks_page.check_visible(
CheckVisibleParams.build(tasks=[task])
)
def test_delete_task(
self,
tasks_page: TasksPage,
mock_view_tasks: TasksSchema,
mock_http_client: MockHTTPClient
):
# Сценарий:
# пользователь видит список задач и удаляет одну из них.
tasks_page.visit("/")
tasks_page.check_visible(
CheckVisibleParams.build(tasks=mock_view_tasks.root)
)
# Выбираем задачу, которую будем удалять,
# и описываем новое состояние backend’а
task = mock_view_tasks.root.pop(0)
# Полностью пересобираем правила мокирования,
# чтобы сценарий был явным и детерминированным
mock_http_client.delete_mock_rule_api()
mock_http_client.create_mock_rule_api(
CreateMockRulesRequest(
rules=[
# Обработка удаления конкретной задачи
MockRuleSchema(
route=f"{APIRoutes.TASKS}/{task.id}",
method=HTTPMethod.DELETE
),
# Обновлённый список после удаления
MockRuleSchema(
route=APIRoutes.TASKS,
response=mock_view_tasks.model_dump()
)
]
)
)
# UI-действие пользователя
tasks_page.click_delete_task_button(task_id=task.id)
# Проверяем, что UI перешёл в новое корректное состояние
tasks_page.check_visible(
CheckVisibleParams.build(tasks=mock_view_tasks.root)
)Почему это и есть «хорошие» UI-тесты
В этих тестах важно даже не то, что именно мы проверяем, а чего здесь нет. Здесь нет таймингов,
sleep, ретраев, подготовки данных через базу, зависимости от стендов или скрытой магии фреймворка. Браузер работает по реальному HTTP, а всё внешнее поведение системы задано явно.
Каждый тест делает всего три вещи: задаёт ожидаемое поведение backend’а, выполняет действия пользователя и проверяет итоговое состояние интерфейса.
За счёт этого тесты получаются быстрыми, стабильными и читаемыми — ровно такими, которые не страшно запускать на каждый pull request. Именно ради этого мы и проходили весь путь от изоляции backend’а до тонких UI-сценариев.
Полная свобода сценариев: то, что невозможно (или больно) в реальном окружении
Есть ещё одна важная причина, почему этот подход вообще стоит применять — полная свобода в тестовых сценариях.
В реальных окружениях регулярно возникают бизнес-кейсы, которые завязаны на время, состояние системы или редкие условия и при этом крайне сложно воспроизводимы. Простой пример — правило маркетплейса: в последние три дня месяца на все товары действует скидка 30%.
В живом окружении, чтобы проверить такой сценарий через UI, приходится либо играться с системным временем, либо поднимать отдельный стенд, либо договариваться с соседними командами, либо городить флаги и костыли. Всё это — ради одного сценария, с высоким риском сломать чужие тесты, повлиять на соседние процессы и без какой-либо гарантии воспроизводимости.
В изоляционном подходе этой проблемы просто нет. Такой сценарий — это один мок. Мы явно говорим: «в этом тесте backend возвращает цены уже со скидкой 30%» — и на этом всё заканчивается. UI при этом работает в реальном браузере, делает реальные HTTP-запросы и не знает, что это «особый случай». Для него это просто ещё один допустимый контракт данных.
Конец месяца, чёрная пятница, A/B-эксперимент, фича-флаг или редкий edge-case, который случается раз в год — всё это описывается на уровне моков, а не окружений. Именно в этот момент изоляционные UI-тесты начинают давать не только стабильность, но и реальную ценность для бизнеса.
Запуск в CI/CD
Теперь посмотрим, как всё это запускается в CI/CD. И здесь, как и во всей статье, никакой магии и сложных пайплайнов нет. Вся схема укладывается в простой docker-compose, два Dockerfile и стандартный workflow в GitHub Actions.
version: "3.9"
services:
mock:
# Мок-сервис — это единственный "backend" в системе.
# Он поднимается как отдельный HTTP-сервис
# и полностью управляется из тестов.
build:
context: .
dockerfile: tests/Dockerfile
# Мок доступен на localhost:8000 —
# именно на этот адрес ходит фронтенд.
ports: [ "8000:8000" ]
# Конфигурация мока передаётся через ENV_FILE,
# чтобы не хардкодить окружение внутри контейнера.
environment:
ENV_FILE: /app/.env.ci
container_name: "mock"
frontend:
# Фронтенд — это статическое приложение,
# которое работает так же, как в проде:
# браузер + HTML + JS + реальные HTTP-запросы.
build:
context: .
dockerfile: frontend/Dockerfile
# Фронт доступен на localhost:8080 —
# именно по этому адресу его открывают UI-тесты.
ports: [ "8080:80" ]
# Явно указываем зависимость:
# фронт должен стартовать после мока,
# чтобы все HTTP-запросы сразу были обслужены.
depends_on: [ mock ]
container_name: "frontend"Здесь принципиально важно несколько вещей:
поднимаются только два сервиса — фронтенд и мок;
никакого реального backend’а нет вообще;
фронт ходит в
localhost:8000, где поднят мок-сервис;вся изоляция достигается исключительно архитектурой, а не настройками тестов.
FROM python:3.12-slim
# Мок-сервис — это обычное Python-приложение,
# не отдельный "тестовый режим" и не специальная заглушка.
WORKDIR /app
# Устанавливаем только зависимости,
# необходимые для работы мока.
COPY tests/requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Копируем исходный код проекта целиком.
# Мок живёт в том же репозитории,
# что и тесты — это осознанное решение.
COPY . .
# Запускаем мок как обычный HTTP-сервис.
# Он не знает, что его используют UI-тесты —
# для него это просто клиенты по HTTP.
CMD ["python", "-m", "tests.mock.server"]Это обычный, скучный Dockerfile. И это хорошо. Мок — такой же HTTP-сервис, как и любой другой, без специальных режимов «для тестов».
FROM nginx:alpine
# Фронтенд — это чистые статические файлы.
# Никакой сборки, никакой логики,
# ровно то, что будет открываться браузером.
COPY frontend /usr/share/nginx/htmlФронт — это просто статические файлы, раздаваемые nginx. Никакой сборки, никаких зависимостей — браузер работает ровно с тем, что будет работать в проде.
GitHub Actions
Финальный шаг — запуск в CI. Используем GitHub Actions.
name: UI mock tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
run-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# Забираем исходный код проекта
- uses: actions/checkout@v6
# Устанавливаем Python нужной версии
- uses: actions/setup-python@v6
with:
python-version: '3.12'
# Поднимаем фронт и мок-сервис.
# Никаких внешних зависимостей больше нет.
- name: Start services
run: docker compose up -d --build
# Устанавливаем зависимости для UI-тестов
# и браузеры Playwright.
- name: Install test dependencies
run: |
pip install -r ./tests/requirements.txt
playwright install --with-deps
# Запускаем UI-тесты.
# ENV_FILE задаёт конфигурацию окружения,
# без изменения кода тестов.
- name: Run tests
run: pytest
env:
ENV_FILE: .env.ci
# Гарантированно останавливаем окружение,
# даже если тесты упали.
- name: Stop services
if: always()
run: docker compose down -vПайплайн предельно простой:
поднимаем фронт и мок,
устанавливаем зависимости Playwright,
запускаем тесты,
гарантированно прибираем окружение.
Никаких кастомных runner’ов, кэшей или танцев с бубном.
Результат
Результат выполнения можно посмотреть здесь: https://github.com/Nikita-Filonov/python-ui-mock-tests/actions/runs/20640200461
И теперь самое интересное — время выполнения. Полный прогон UI-тестов занимает около 3.3 секунды.
Для UI-тестов, которые:
поднимают реальный браузер,
работают по настоящему HTTP,
взаимодействуют с «backend’ом»,
— это чрезвычайно быстро.
По сути, это одни из самых быстрых UI-тестов, которые вообще можно получить, не скатываясь в unit-тесты или JS-моки.
А как же покрытие?
На этом месте в UI-тестах обычно звучит знакомое возражение:
«Но UI-тесты же проверяют только интерфейс, покрытие у них слабое».
И здесь важно честно ответить. Мы ничего не покрываем, когда UI-тесты работают нестабильно, флакают, падают по таймингам и в итоге выключаются из CI/CD. Такие тесты не дают покрытия — они дают иллюзию уверенности.
А когда UI-тесты быстрые, стабильные и детерминированные, мы как раз и проверяем всё, что действительно входит в зону ответственности интерфейса.
В текущей архитектуре UI-тесты проверяют фронтенд ровно там, где он отвечает за результат:
корректное отображение данных,
реакцию на пользовательские действия,
правильную работу с HTTP-контрактами backend’а,
переходы UI в ожидаемые состояния.
Мы сознательно не проверяем внутреннюю бизнес-логику backend’а через UI. И это не недостаток, а принцип. Backend должен проверяться на своём уровне — через такие же изоляционные API-тесты, по тем же контрактам.
В результате покрытие получается не «размазанным», а осмысленным:
UI тестируется на уровне UI,
backend — на уровне API,
каждый слой — в своей зоне ответственности,
в изолированной и предсказуемой среде.
Это и есть нормальная, масштабируемая модель покрытия для frontend-приложений, а не попытка проверить всё через браузер.
И, конечно, никто не запрещает оставить несколько end-to-end happy path сценариев, чтобы убедиться, что сборка системы в целом работает. Но именно несколько — как контрольный / smoke / sanity слой, а не как основной способ тестирования.
Что дальше?
Этот подход спокойно расширяется:
можно подключить Allure или другие отчёты;
добавить теги и метки под left shift;
расширить мок-сервис более сложными сценариями;
добавить трейсинг и видеть, какие запросы UI делает и в каком порядке;
подключить разработчиков к написанию UI-сценариев без погружения в тестовый фреймворк.
Архитектурных ограничений здесь нет. Всё упирается только в то, что именно вам нужно тестировать.
Пример в статье показан на Python и Playwright, но сам подход не привязан ни к языку, ни к инструменту. Точно так же он реализуется на Cypress, WebdriverIO, Selenium, Playwright на TypeScript — меняется синтаксис, но не идея.
И, пожалуй, самое важное — результат. Мы получили UI-тесты, которые:
тонкие,
стабильные,
быстрые.
При этом мы не строили отдельный «тестовый мир». Мы переиспользовали реальные контракты, реальные HTTP-запросы и реальный UI, просто аккуратно изолировали внешний мир.
В такой модели UI-тесты перестают быть болью и становятся инженерным инструментом. Их не страшно запускать локально, не страшно держать в CI/CD и не страшно масштабировать.
Именно поэтому такой подход работает. Не потому что он модный, а потому что он инженерно честный.
Заключение
Вся архитектура, код мок-сервиса, клиентов, фикстур и UI-тестов, разобранных в этой статье, доступны в открытом виде на GitHub: https://github.com/Nikita-Filonov/python-ui-mock-tests






















