Пишем быстрые UI-автотесты без флаков, стендов и боли: изоляционный подход в CI/CD
Большинство UI-тестов флакают, медленно работают и в итоге отключаются в CI. Показываю альтернативу — изоляционные UI-тесты без стендов, таймингов и боли.
Вступление
В этой статье я хочу показать, как на практике писать изоляционные 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.
Ключевой момент: фронт сразу ходит в API по адресу http://localhost:8000:
И в рамках статьи мы сознательно делаем так, что «настоящего» backend’а у нас нет. Есть только контракт, который ожидает фронт.
Наша задача простая и инженерная: понять контракты → на основе контрактов сделать моки → написать изоляционные UI-тесты.
Контракт, который видит фронт
Фронтенду достаточно трёх HTTP-операций:
- GET /api/v1/tasks — получить список задач
- POST /api/v1/tasks — создать задачу ({ "title": "..." })
- DELETE /api/v1/tasks/{id} — удалить задачу
Ответ на GET — список задач вида:
И всё. Никаких баз, транзакций и «внутренней кухни» нас не интересует — UI взаимодействует с внешним миром только через этот HTTP-контракт.
Почему это удобный пример
Важно зафиксировать: примеры будут на чистом HTML / JS не потому, что так “надо”, а чтобы не отвлекаться на детали фреймворков. Этот подход один в один переносится на React / Vue / Angular — разницы нет, пока UI ходит по HTTP и вы можете зафиксировать контракт.
data-testid — сразу делаем правильно
Ещё один принципиальный момент: в разметке заранее расставлены data-testid. Это сильно упрощает локаторы, делает тесты стабильнее и убирает привязку к CSS / текстам там, где она не нужна.
Как именно я подхожу к data-testid (схема нейминга, что стоит / не стоит размечать и почему) — у меня есть отдельная статья:
CSS и XPath — отстой. Секрет стабильных автотестов в test-id
Дальше мы перейдём к мок-сервису: поднимем “несуществующий” backend на localhost:8000, научим его динамически задавать поведение из теста — и на этой базе соберём быстрые, детерминированные UI-автотесты.
Делаем мок
Мок в этом примере будет максимально простым. Это обычный HTTP-сервис, который притворяется backend’ом для фронтенда. Без overengineering, без «универсального решения на все случаи жизни». Ровно настолько сложным, насколько это нужно для изоляционных автотестов.
У мок-сервиса будет всего два административных эндпоинта:
- POST /admin/rules — создать правила мокирования
- DELETE /admin/rules — удалить все правила мокирования
И один универсальный эндпоинт-диспетчер, который будет перехватывать все остальные запросы и отдавать ответы на основе заранее заданных правил.
Почему именно так.
Можно было пойти по пути персистентных моков: описать ответы в JSON-файлах, положить их рядом с мок-сервисом и просто раздавать по маршрутам. Такой подход вполне валиден, но он ближе к стабам. Он хорошо подходит, например, для нагрузочных тестов, когда нам не принципиально, какой именно ответ вернётся — главное, чтобы он был и соответствовал контракту.
Но здесь мы пишем UI-автотесты. И для UI особенно важно, чтобы:
- браузер делал реальные HTTP-запросы,
- фронтенд жил в привычном ему окружении,
- а поведение backend’а было полностью контролируемым.
Для этого и нужен динамический мок, которым можно управлять во время выполнения теста. Именно поэтому правила мокирования создаются и удаляются через API.
Разумеется, существуют готовые решения вроде WireMock, в том числе с поддержкой динамических сценариев. В нашем случае они оказались избыточными: для UI-автотестов нам было важно получить минимальный, полностью контролируемый мок с прозрачным поведением и без лишней инфраструктуры.
Ниже — реализация. Она нарочно сделана минималистичной, чтобы было видно саму идею, а не обвязку.
Схема правил мокирования
Одно правило мокирования описывает:
- HTTP-метод,
- путь запроса,
- query-параметры,
- тело ответа,
- HTTP-статус.
Никакой магии. Если входящий запрос полностью совпадает с правилом — мок отдаёт заданный ответ.
Хранилище правил
Здесь всё предельно прямолинейно:
- правила хранятся в памяти,
- доступ защищён asyncio.Lock,
- поиск правила — это обычное последовательное сравнение метода, пути и query-параметров.
Да, это не самый оптимальный алгоритм. И да, здесь нет индексов, кэшей и прочих оптимизаций. Но для изоляционных автотестов это вообще не проблема. Правил мало, тесты быстрые, читаемость и предсказуемость важнее микросекунд.
API мок-сервиса
Здесь три ключевых момента.
Первое — административные эндпоинты. Они позволяют из теста:
- задать нужное поведение сервисов,
- полностью очистить состояние мока между тестами.
Второе — универсальный 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
Здесь важно несколько принципиальных моментов:
- элементы работают только через data-testid;
- локаторы параметризуемы — это позволяет работать со списками и динамическими элементами;
- проверки и действия живут рядом, а не размазаны по тестам.
Button
Кнопка расширяет базовый элемент только тем, что имеет смысл именно для кнопки. Никакой лишней функциональности.
Input
Text
Для текстового элемента нам не нужно ничего, кроме базовых проверок — и это нормально. Page Factory элементы не обязаны быть «равномерно сложными».
Page Object страницы задач
Теперь соберём всё это в Page Object конкретной страницы — Todo list.
Что в итоге
В результате у нас:
- Page Object описывает UI-контракт страницы;
- Page Factory элементы инкапсулируют работу с DOM;
- тесты работают только с Page Object;
- UI-логика и backend-моки полностью разделены.
Дальше мы будем использовать этот Page Object в тестах, а поведение backend’а управлять через HTTP-мок — и именно в этом месте изоляционные UI-тесты начинают показывать свою реальную силу.
Контракт данных: что фронт ожидает от backend’а
Перед тем как писать моки и UI-тесты, важно зафиксировать ещё одну вещь — контракт данных, с которыми работает фронтенд.
Фронт не оперирует абстрактными «словарами» и «JSON-объектами». Он ожидает вполне конкретную структуру данных, и именно эта структура определяет его поведение. Если контракт соблюдён — UI работает корректно. Если нет — это либо ошибка backend’а, либо отдельный сценарий, который мы можем явно смоделировать в тесте.
Поэтому дальше мы будем:
- описывать ответы backend’а через явные схемы,
- использовать их как основу для моков,
- и генерировать тестовые данные автоматически, а не хардкодить их в тестах.
Генерация тестовых данных
Для генерации тестовых данных мы используем faker. Это позволяет:
- не захламлять тесты хардкодом,
- получать реалистичные значения,
- при этом сохранять читаемость сценариев.
Важно: генерация данных — это вспомогательная задача. Она не влияет на логику тестов и не подменяет бизнес-смысл сценариев.
Схема задачи
Теперь опишем контракт задачи так, как его видит фронт.
Здесь принципиально важно: мы описываем не “как устроен backend”, а “что ожидает UI”. Если backend в реальности хранит больше полей — UI это не волнует.
Список задач
Ответ backend’а на GET /api/v1/tasks — это список задач. Мы фиксируем это явно.
Это даёт нам несколько важных преимуществ:
- моки строятся по контракту, а не «как получится»;
- тесты работают с типизированными объектами;
- Page Object получает данные в понятном и предсказуемом виде;
- ошибки контракта ловятся сразу, а не на уровне DOM.
Почему это важно для UI-тестов
Этот слой кажется избыточным для простого Todo-примера, но именно он делает подход масштабируемым.
В реальных проектах:
- структура ответов сложнее,
- сценариев больше,
- а UI зависит от данных сильнее.
Фиксируя контракт через схемы, мы:
- упрощаем работу с моками,
- делаем тесты читабельнее,
- и сохраняем ту же философию, что и в API-тестах: контракт → моки → сценарии.
В следующих шагах мы начнём использовать эти схемы в фикстурах и посмотрим, как на их основе динамически управлять поведением backend’а прямо из UI-тестов.
Конфигурация тестового окружения
Чтобы изоляционные UI-тесты были воспроизводимыми и легко запускались локально и в CI/CD, нам нужна минимальная, но явная конфигурация окружения. Никакой магии — только то, что действительно используется в тестах.
Важно сразу зафиксировать: мы не вводим отдельные режимы для UI-тестов, не городим сложные флаги и не меняем поведение приложения. Мы просто описываем, где находится фронт и где находится мок-сервис.
Настройки тестов
Здесь нет ничего специфичного для UI или Playwright — это просто аккуратная конфигурация, которая:
- читается из окружения,
- одинаково работает локально и в CI,
- не требует изменений кода при переключении окружений.
Конфигурация для CI
Для запуска тестов в CI используется обычный .env.ci файл:
Здесь важно несколько принципиальных моментов:
- фронт доступен по localhost:8080 — именно туда смотрят UI-тесты;
- мок-сервис поднимается на localhost:8000 — туда ходит фронт за данными;
- для мок-сервиса используется один воркер.
Последний пункт принципиален: мок хранит правила в памяти процесса. Один воркер гарантирует детерминированное поведение и отсутствие гонок между тестами. Для изоляционных UI-тестов нам важнее предсказуемость, чем внутренняя параллельность.
Клиент мок-сервиса: управляем backend’ом так же, как реальным сервисом
Прежде чем использовать мок в фикстурах и тестах, нам нужен аккуратный способ с ним взаимодействовать. И здесь есть принципиальный момент: мы не управляем моками через внутренние вызовы или shared state — мы работаем с ним по HTTP.
Это осознанное решение. Для тестов мок — такой же внешний сервис, как и любой другой backend. Он поднимается отдельно, имеет свой API и управляется через обычный HTTP-клиент.
Этот клиент может показаться «лишним», но именно он делает всю схему цельной:
- мок — это отдельный сервис, а не внутренняя заглушка;
- управление моками происходит через HTTP, а не через Python-объекты;
- тесты используют те же клиентские абстракции, что и прод-код.
В результате:
- исчезает скрытая магия;
- тесты остаются честными по отношению к архитектуре;
- сценарии легко читаются и расширяются.
Дальше этот клиент используется в фикстурах и тестах ровно так же, как любой другой HTTP-клиент — и это ещё один кирпичик в общей идее изоляционных UI-тестов.
Фикстуры: собираем UI-сценарии, а не окружение
В классических UI-тестах фикстуры часто превращаются в тяжёлый сетап: подготовка данных, прогрев стендов, сиды в базе, ожидания и костыли. В изоляционном подходе всё иначе.
Здесь фикстуры делают ровно две вещи:
- Инициализируют инфраструктуру теста — браузер, Page Object, HTTP-клиенты.
- Декларативно задают поведение backend’а через мок-сервис.
Мы не «готовим данные». Мы описываем сценарий, в котором UI должен оказаться.
На этом этапе хорошо видно ключевое отличие изоляционных UI-тестов от классических e2e:
- фикстуры не подготавливают данные и окружение;
- фикстуры описывают сценарий, в котором должен оказаться UI;
- браузер всегда реальный и работает по настоящему HTTP;
- изоляция достигается не на уровне Playwright, а на уровне backend’а.
UI-тесты перестают быть «тяжёлой интеграцией» и превращаются в детерминированные сценарии с полностью контролируемым окружением.
Именно поэтому дальше в тестах почти не останется инфраструктурного кода — останется только описание пользовательского поведения.
UI-тесты: когда сценарий — это весь тест
После всей инфраструктуры — моков, контрактов, Page Object и фикстур — сами UI-тесты становятся максимально тонкими. И это не «магия» и не «удачный пример», а прямое следствие архитектуры.
Тесты ниже:
- работают через реальный браузер,
- делают реальные HTTP-запросы,
- полностью управляют поведением backend’а,
- и при этом читаются как описание пользовательского сценария.
Почему это и есть «хорошие» 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.
Здесь принципиально важно несколько вещей:
- поднимаются только два сервиса — фронтенд и мок;
- никакого реального backend’а нет вообще;
- фронт ходит в localhost:8000, где поднят мок-сервис;
- вся изоляция достигается исключительно архитектурой, а не настройками тестов.
FROM python:3.12-slim # Мок-сервис — это обычное Python-приложение, # не отдельный "тестовый режим" и не специальная заглушка. WORKDIR /app # Устанавливаем только зависимости, # необходимые для работы мока. COPY tests/requirements.txt requirements.txt RUN pip install --no-cache-dir -r requirements.txt # Копируем исходный код проекта целиком. # Мок живёт в том же репозитории, # что и тесты — это осознанное решение. COPY . . # Запускаем мок как обычный HTTP-сервис. # Он не знает, что его используют UI-тесты — # для него это просто клиенты по HTTP. CMD ["python", "-m", "tests.mock.server"]
Это обычный, скучный Dockerfile. И это хорошо. Мок — такой же HTTP-сервис, как и любой другой, без специальных режимов «для тестов».
FROM nginx:alpine # Фронтенд — это чистые статические файлы. # Никакой сборки, никакой логики, # ровно то, что будет открываться браузером. COPY frontend /usr/share/nginx/html
Фронт — это просто статические файлы, раздаваемые nginx. Никакой сборки, никаких зависимостей — браузер работает ровно с тем, что будет работать в проде.
GitHub Actions
Финальный шаг — запуск в CI. Используем GitHub Actions.
Пайплайн предельно простой:
- поднимаем фронт и мок,
- устанавливаем зависимости 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