惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

www.infosecurity-magazine.com
www.infosecurity-magazine.com
Security Archives - TechRepublic
Security Archives - TechRepublic
TaoSecurity Blog
TaoSecurity Blog
Cloudbric
Cloudbric
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
N
News and Events Feed by Topic
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
S
Securelist
The Cloudflare Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
D
DataBreaches.Net
S
Schneier on Security
L
LangChain Blog
Jina AI
Jina AI
M
MIT News - Artificial intelligence
Recent Announcements
Recent Announcements
T
Tenable Blog
B
Blog RSS Feed
V
Visual Studio Blog
Simon Willison's Weblog
Simon Willison's Weblog
G
Google Developers Blog
T
The Exploit Database - CXSecurity.com
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
WordPress大学
WordPress大学
W
WeLiveSecurity
I
InfoQ
The Hacker News
The Hacker News
雷峰网
雷峰网
月光博客
月光博客
P
Privacy & Cybersecurity Law Blog
O
OpenAI News
Hacker News: Ask HN
Hacker News: Ask HN
T
Threat Research - Cisco Blogs
GbyAI
GbyAI
The Last Watchdog
The Last Watchdog
P
Privacy International News Feed
Cyberwarzone
Cyberwarzone
S
SegmentFault 最新的问题
L
Lohrmann on Cybersecurity
人人都是产品经理
人人都是产品经理
V
V2EX
V
Vulnerabilities – Threatpost
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
C
Cybersecurity and Infrastructure Security Agency CISA
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
T
Troy Hunt's Blog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
阮一峰的网络日志
阮一峰的网络日志
SecWiki News
SecWiki News
Microsoft Azure Blog
Microsoft Azure Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Пишем быстрые UI-автотесты без флаков, стендов и боли: изоляционный подход в CI/CD
sound_right · 2026-04-27 · via Все публикации подряд на Хабре

Вступление

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

После этих материалов мне регулярно задавали один и тот же вопрос: «Окей, звучит разумно. А как это выглядит на практике для UI?» В этой статье я как раз и показываю — без абстракций, без overengineering и без усложнений.

Сразу обозначу границы. Я не буду подробно объяснять, как работает браузер, что такое Page Object и как устроен Playwright под капотом. На эти темы уже есть огромное количество материалов, и при желании с ними легко ознакомиться: Habr

Здесь мы фокусируемся не на инструментах, а на подходе.

Контекст

Тестировать мы будем максимально простой фронтенд — Todo list. Это один index.html и немного vanilla JS, который:

  • при открытии страницы запрашивает список задач,

  • позволяет создать задачу,

  • позволяет удалить задачу,

  • после каждого действия перезагружает список.

На скриншоте это выглядит вот так: заголовок, поле ввода, кнопка Create и список задач с кнопками Delete.

Empty state

Empty state

State with tasks

State with tasks

Ключевой момент: фронт сразу ходит в 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-автотестов нам было важно получить минимальный, полностью контролируемый мок с прозрачным поведением и без лишней инфраструктуры.

Ниже — реализация. Она нарочно сделана минималистичной, чтобы было видно саму идею, а не обвязку.

Схема правил мокирования

./tests/mock/schema.py

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-статус.

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

Хранилище правил

./tests/mock/rules.py

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 мок-сервиса

./tests/mock/api.py

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

./tests/elements/button.py

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

./tests/elements/input.py

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

./tests/elements/text.py

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.

./tests/pages/tasks_page.py

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. Это позволяет:

  • не захламлять тесты хардкодом,

  • получать реалистичные значения,

  • при этом сохранять читаемость сценариев.

Важно: генерация данных — это вспомогательная задача. Она не влияет на логику тестов и не подменяет бизнес-смысл сценариев.

./tests/libs/fake.py

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())

Схема задачи

Теперь опишем контракт задачи так, как его видит фронт.

./tests/schema/tasks.py

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 — это список задач. Мы фиксируем это явно.

./tests/schema/tasks.py

class TasksSchema(RootModel[list[TaskSchema]]):
    # Корневой список задач.
    # Используется фронтом для отрисовки списка
    # и в тестах — для описания ожидаемого состояния страницы.
    root: list[TaskSchema]

Это даёт нам несколько важных преимуществ:

  • моки строятся по контракту, а не «как получится»;

  • тесты работают с типизированными объектами;

  • Page Object получает данные в понятном и предсказуемом виде;

  • ошибки контракта ловятся сразу, а не на уровне DOM.

Почему это важно для UI-тестов

Этот слой кажется избыточным для простого Todo-примера, но именно он делает подход масштабируемым.

В реальных проектах:

  • структура ответов сложнее,

  • сценариев больше,

  • а UI зависит от данных сильнее.

Фиксируя контракт через схемы, мы:

  • упрощаем работу с моками,

  • делаем тесты читабельнее,

  • и сохраняем ту же философию, что и в API-тестах: контракт → моки → сценарии.

В следующих шагах мы начнём использовать эти схемы в фикстурах и посмотрим, как на их основе динамически управлять поведением backend’а прямо из UI-тестов.

Конфигурация тестового окружения

Чтобы изоляционные UI-тесты были воспроизводимыми и легко запускались локально и в CI/CD, нам нужна минимальная, но явная конфигурация окружения. Никакой магии — только то, что действительно используется в тестах.

Важно сразу зафиксировать: мы не вводим отдельные режимы для UI-тестов, не городим сложные флаги и не меняем поведение приложения. Мы просто описываем, где находится фронт и где находится мок-сервис.

Настройки тестов

./tests/config.py

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-клиент.

./tests/mock/client.py

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-тестах фикстуры часто превращаются в тяжёлый сетап: подготовка данных, прогрев стендов, сиды в базе, ожидания и костыли. В изоляционном подходе всё иначе.

Здесь фикстуры делают ровно две вещи:

  1. Инициализируют инфраструктуру теста — браузер, Page Object, HTTP-клиенты.

  2. Декларативно задают поведение backend’а через мок-сервис.

Мы не «готовим данные». Мы описываем сценарий, в котором UI должен оказаться.

./tests/suites/conftest.py

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’а,

  • и при этом читаются как описание пользовательского сценария.

./tests/suites/test_tasks.py

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.

docker-compose.yaml

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, где поднят мок-сервис;

  • вся изоляция достигается исключительно архитектурой, а не настройками тестов.

./tests/Dockerfile

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-сервис, как и любой другой, без специальных режимов «для тестов».

./frontend/Dockerfile

FROM nginx:alpine

# Фронтенд — это чистые статические файлы.
# Никакой сборки, никакой логики,
# ровно то, что будет открываться браузером.
COPY frontend /usr/share/nginx/html

Фронт — это просто статические файлы, раздаваемые nginx. Никакой сборки, никаких зависимостей — браузер работает ровно с тем, что будет работать в проде.

GitHub Actions

Финальный шаг — запуск в CI. Используем GitHub Actions.

./.github/workflows/test.yml

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

Пайплайн предельно простой:

  1. поднимаем фронт и мок,

  2. устанавливаем зависимости Playwright,

  3. запускаем тесты,

  4. гарантированно прибираем окружение.

Никаких кастомных 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