Пишем быстрые UI-автотесты без флаков, стендов и боли: изоляционный подход в CI/CD
- вторник, 28 апреля 2026 г. в 00:00:09
В этой статье я хочу показать, как на практике писать изоляционные 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. Это сильно упрощает локаторы, делает тесты стабильнее и убирает привязку к 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-параметров.
Да, это не самый оптимальный алгоритм. И да, здесь нет индексов, кэшей и прочих оптимизаций. Но для изоляционных автотестов это вообще не проблема. Правил мало, тесты быстрые, читаемость и предсказуемость важнее микросекунд.
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.
Перед тем как писать 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 элементов. Их задача — инкапсулировать типовые взаимодействия с UI, чтобы тесты и страницы не работали напрямую с Playwright-локаторами.
Важно: эти элементы не содержат бизнес-логики. Они не знают, что тестируется, они знают только как работать с конкретным типом UI-элемента.
./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;
локаторы параметризуемы — это позволяет работать со списками и динамическими элементами;
проверки и действия живут рядом, а не размазаны по тестам.
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()
Кнопка расширяет базовый элемент только тем, что имеет смысл именно для кнопки. Никакой лишней функциональности.
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)
from tests.elements.base_element import BaseElement class Text(BaseElement): @property def type_of(self) -> str: return "text"
Для текстового элемента нам не нужно ничего, кроме базовых проверок — и это нормально. Page Factory элементы не обязаны быть «равномерно сложными».
Теперь соберём всё это в 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-тесты начинают показывать свою реальную силу.
Перед тем как писать моки и 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.
Этот слой кажется избыточным для простого 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 используется обычный .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-тестов нам важнее предсказуемость, чем внутренняя параллельность.
Прежде чем использовать мок в фикстурах и тестах, нам нужен аккуратный способ с ним взаимодействовать. И здесь есть принципиальный момент: мы не управляем моками через внутренние вызовы или 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-тестах фикстуры часто превращаются в тяжёлый сетап: подготовка данных, прогрев стендов, сиды в базе, ожидания и костыли. В изоляционном подходе всё иначе.
Здесь фикстуры делают ровно две вещи:
Инициализируют инфраструктуру теста — браузер, 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-тесты перестают быть «тяжёлой интеграцией» и превращаются в детерминированные сценарии с полностью контролируемым окружением.
Именно поэтому дальше в тестах почти не останется инфраструктурного кода — останется только описание пользовательского поведения.
После всей инфраструктуры — моков, контрактов, 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) )
В этих тестах важно даже не то, что именно мы проверяем, а чего здесь нет. Здесь нет таймингов,
sleep, ретраев, подготовки данных через базу, зависимости от стендов или скрытой магии фреймворка. Браузер работает по реальному HTTP, а всё внешнее поведение системы задано явно.
Каждый тест делает всего три вещи: задаёт ожидаемое поведение backend’а, выполняет действия пользователя и проверяет итоговое состояние интерфейса.
За счёт этого тесты получаются быстрыми, стабильными и читаемыми — ровно такими, которые не страшно запускать на каждый pull request. Именно ради этого мы и проходили весь путь от изоляции backend’а до тонких UI-сценариев.
Есть ещё одна важная причина, почему этот подход вообще стоит применять — полная свобода в тестовых сценариях.
В реальных окружениях регулярно возникают бизнес-кейсы, которые завязаны на время, состояние системы или редкие условия и при этом крайне сложно воспроизводимы. Простой пример — правило маркетплейса: в последние три дня месяца на все товары действует скидка 30%.
В живом окружении, чтобы проверить такой сценарий через UI, приходится либо играться с системным временем, либо поднимать отдельный стенд, либо договариваться с соседними командами, либо городить флаги и костыли. Всё это — ради одного сценария, с высоким риском сломать чужие тесты, повлиять на соседние процессы и без какой-либо гарантии воспроизводимости.
В изоляционном подходе этой проблемы просто нет. Такой сценарий — это один мок. Мы явно говорим: «в этом тесте backend возвращает цены уже со скидкой 30%» — и на этом всё заканчивается. UI при этом работает в реальном браузере, делает реальные HTTP-запросы и не знает, что это «особый случай». Для него это просто ещё один допустимый контракт данных.
Конец месяца, чёрная пятница, A/B-эксперимент, фича-флаг или редкий edge-case, который случается раз в год — всё это описывается на уровне моков, а не окружений. Именно в этот момент изоляционные UI-тесты начинают давать не только стабильность, но и реальную ценность для бизнеса.
Теперь посмотрим, как всё это запускается в 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. Никакой сборки, никаких зависимостей — браузер работает ровно с тем, что будет работать в проде.
Финальный шаг — запуск в 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