UI-тесты GUI-страниц (nicegui.testing.User)
Страховочная сетка перед UI-рефакторингом: services/backend/src/tests/test_ui_*.py
(маркер ui_refactor). Тесты гоняют реальные страницы rep2 в headless-браузере
NiceGUI (nicegui.testing.User) — без docker, без postgres и без сети.
Как устроено
| Файл | Роль |
|---|---|
services/backend/conftest.py |
подключает pytest-плагин nicegui.testing.user_plugin (иначе фикстуры user/create_user не существуют: у nicegui нет pytest11 entry point) |
services/backend/src/tests/ui_main.py |
«main-файл» симуляции: регистрирует те же страницы, что src/gui.py |
services/backend/src/tests/ui_fakes.py |
фейки внешних зависимостей + хелперы поиска по странице |
services/backend/src/tests/conftest.py |
фикстуры user (официальная, из плагина) и ui_env (фейки), autouse-guard сети |
services/backend/src/tests/test_ui_*.py |
тесты по страницам |
Запуск:
cd services/backend && PYTHONPATH=$PWD/src python -m pytest -q
# только UI-сетка:
cd services/backend && PYTHONPATH=$PWD/src python -m pytest -q -m ui_refactor
pytest.ini задаёт main_file = src/tests/ui_main.py — путь относительно
pytest.ini; это вход для nicegui.testing.user.
Правило №1: тесты не ходят в сеть
Autouse-фикстура forbid_network (src/tests/conftest.py) режет socket.connect,
connect_ex, sendto, create_connection и getaddrinfo. Любой реальный
outbound-вызов = падение теста с AssertionError: outbound network access is
forbidden in tests. ASGI-транспорт nicegui сокетов не использует, поэтому
симуляция браузера работает.
Следствие: если страница на рендере делает внешний запрос, его надо закрыть
фейком на границе сервиса. Пример из практики: doc_from_table при открытии
мастера настройки из фоновой задачи дёргает get_attributes
(/entity/<type>/metadata/attributes) — без подмены тест ловил реальный 401 от
api.moysklad.ru.
Что подменяется (ui_env)
Подменяются ШВЫ, а не внутренности страниц:
VendorService/ContextManager— account id, token, context, доп. поля инсталляции;GUI/GUIService/EdocsuzService— сервисы-фасады страниц;get_documents/get_attributes/run_all_rules_by_date— точечно, там где их зовёт UI-хендлер.
Сами src/endpoints/main/*.py тесты не меняют. Тест настраивает данные до
user.open(...) и читает записанные вызовы после (ui_env.calls).
Грабли NiceGUI-тестирования (проверено на практике)
-
pytest_pluginsтолько в top-level conftest. У nicegui нет pytest11 entry point, поэтому фикстурыuser/create_userпоявляются лишь если подключитьnicegui.testing.user_plugin. В не-корневом conftest pytest это запрещает → подключаем вservices/backend/conftest.py(rootdir). -
nicegui_reset_globals()вычищает модули page-функций изsys.modules. На teardown он удаляет модуль каждой page-функции, кромеtests.*. Без защиты повторныйfrom src.endpoints.main import payments_linkingв тесте создал бы ВТОРОЙ модуль, и monkeypatch ушёл бы не в тот объект, чьи глобалы видит отрисованная страница. Поэтомуui_main.pyпомечает page-функции__module__ = 'tests.ui_pages'. -
include_routerнакапливает вложенность lifespan'ов FastAPI._merge_lifespan_contextвызывается на каждыйinclude_router, аapp.reset()/nicegui_reset_globals()её не сбрасывают. На 30+ тестах это сотни вложенныхasynccontextmanager→RecursionErrorпри входе в lifespan следующего теста (и коварно: одиночный файл проходит, весь прогон — нет). Лечение вui_main.py: снятьapp.router.lifespan_contextдо регистрации и вернуть после. -
should_seeждёт только НОВЫЙ элемент. Если проверяемый текст уже был на странице, он вернётся мгновенно и фоновый таск не успеет выполниться. Для побочных эффектов нужен свой опрос (wait_for). -
user.find(ui.button, content='X')молча игнорируетcontent— при позиционномtarget-классе фильтр строится какmake_filter(kind=target). Работает толькоuser.find(kind=ui.button, content='X'). -
Имена событий нормализуются в camelCase:
.on("input-value", ...)→inputValue,.on("update:model-value", ...)→update:modelValue.UserInteraction.triggerтребует ИМЕННО нормализованное имя. -
Присвоение
.valueвызываетon_change, но не явные.on(...)-листенеры. Если страница подписана через.on("update:modelValue", handler), нужноinteract(user, [element]).trigger("update:modelValue", [True]). -
Нет виртуального DOM.
@ui.page-функция выполняется один раз на загрузку; дальше элементы обновляются in-place (set_text,set_visibility,update_rows). «Новых» элементов после клика ждать не нужно — проверяем свойства существующих. Исключение: диалоги и уведомления действительно добавляют новые элементы в layout. -
Page-builder имеет таймаут (3 с по умолчанию). Любой медленный вызов в
main()(БД, сеть) делаетuser.open()красным → фейки обязаны быть быстрыми и синхронными по возможности. -
Маркеры (
mark()) добавлять нельзя без правки прод-кода → поиск идёт по тексту (should_see) и по типу (user.find(ui.button)); для текста в нестандартных props (ui.timeline_entry: title/subtitle/body) — чтение props.
Где тестируется плохо и почему (вход для дизайн-фазы)
1. Данные приходят в страницу напрямую, без точки инъекции
Все шесть страниц в main() сами конструируют VendorService(...),
ContextManager(...), GUI(...) — модульными импортами. Нет порта/протокола,
который можно подставить, поэтому каждый тест обязан monkeypatch-ить имена в
конкретном модуле страницы. Любой рефакторинг импортов ломает тесты не по
существу, а по имени.
Что даст дизайн-фаза: вынести доступ к данным за интерфейс (порт), который страница получает снаружи, — тесты станут настоящими, а не фейк-driven.
2. BaseEnum.__str__ падал — ИСПРАВЛЕНО в этой ветке
Было (src/enums.py):
class BaseEnum(enum.StrEnum):
@classmethod
def __str__(cls):
return f"Enum({cls.name})" # у КЛАССА-enum нет .name -> AttributeError
str(MSDocument.purchaseorder), f-строки и print() на любом члене BaseEnum
бросали AttributeError: <enum 'MSDocument'> has no attribute 'name'
(PLUsageType не задет — он обычный IntEnum).
Последствия, которые это давало:
- nicegui
ElementFilterстроит содержимое элемента черезstr(haystack), поэтомуuser.should_see('текст')иuser.find(kind=..., content=...)падали на странице, где в элементе лежит членBaseEnum. Так работают:edocsuz-sync(селектыoptions=EdocsType/EdocsDocStatus) и мастер настройкиdoc-from-table(ui.select([MSDocument.processingplan])). - любой лог/
f"{ms_document}"в прод-коде — потенциальный 500.
Как исправлено: сломанный __str__ убран совсем — BaseEnum наследует
str.__str__ от StrEnum, поэтому str(член) == член.value (регресс-тесты:
src/tests/test_enums.py). Обходчики в ui_fakes.py (find_visible,
should_see_text, find_prop) оставлены: они нужны и без этого бага (см. п.3).
3. ui.select(options=<класс BaseEnum>) — «полуподдержка» nicegui
BaseEnum мимикрирует под dict через keys()/values(). ui.select такой
объект принимает, а ElementFilter — нет: он делает element.options.get(...) и
падает с AttributeError: type object 'EdocsType' has no attribute 'get'.
Одна и та же страница наполовину работает с официальным тестовым API.
4. Диалоги «невидимы» для проверок видимости
ui.dialog не меняет visible у детей при открытии/закрытии, а only_visible
в ElementFilter смотрит именно на visible. Значит:
- содержимое закрытого диалога находится поиском так же, как открытого;
- нельзя проверить регрессию «диалог не должен показываться»;
- тесты проверяют только факт появления элементов диалога (они добавляются в layout при открытии).
5. Однотипные кнопки/карточки неразличимы без маркеров
В payments-linking четыре карточки онбординга и четыре таблицы, у всех кнопки
с одинаковым текстом («Скрыть», «Как он связывает?», «Включить и создать
правило»). user.scope(marker=...) требует маркеров, а маркеры — изменения
прод-кода. Поэтому тесты опираются на «клик по элементу с минимальным id»
(детерминировано, но хрупко: порядок создания = порядок табов).
Что даст дизайн-фаза: стабильные mark()/id на карточках и таблицах.
6. Фоновые задачи не наблюдаемы через should_see
should_see ждёт только появления НОВОГО элемента; если проверяемый текст уже
был на странице, он вернётся мгновенно и фоновый таск (background_tasks.create_lazy)
не успеет выполниться. Для проверок вида «клик по табу догрузил правила» нужен
собственный опрос (wait_for в ui_fakes.py).
Дополнительно: background_tasks.create_lazy держит модульный реестр задач по
имени (lazy_tasks_running) и между тестами не сбрасывается — потенциальный
источник взаимного влияния тестов.
7. Текст в нестандартных props
ui.timeline_entry хранит текст в title/subtitle/body — ни should_see,
ни find(content=...) его не видят. Приходится читать props напрямую
(find_prop). То же для ui.tabs (имя в props['name']).
8. Двойная регистрация пути
SPrefixUI.main_page и DocFromTableUI.main_page вешают ДВА декоратора
@ui.page на разные имена приложений, но по умолчанию
(SET_PREFIX_APP_NAME == cons_set_prefix_app_name) оба дают один и тот же путь.
В тестах реально доступен один маршрут; второй (prod-имя приложения) не
проверяется. То же для cons_* роутеров payments_linking/edocsuz/set_project/
expenseitems — тесты ходят только по одному URL из пары.
9. Модульные глобалы как session state
expenseitems.py (global vendor_service, gui, access_token) и set_project.py
(global table1) держат состояние на уровне модуля. В тестах это скрыто
(каждый тест перерисовывает страницу), но при нескольких сессиях в одном
процессе данные перезаписываются. edocsuz_sync.py уже ушёл на ContextVar —
остальные страницы нет.
10. Две системы уведомлений
user.notify перехватывает только ui.notify. ui.notification(...)
(set_prefix.show_help, «Загружаем документы» в expenseitems) — обычный
элемент, его искать надо по layout. Для тестов это два разных способа проверки
одного UX-механизма.
11. notice_dialog в payments-linking вызывался неправильно (баг) — ИСПРАВЛЕНО
Было в payments_linking.main:
if access_token == '123':
notice_dialog(title="Внимание!", message="...", type="warning")
а сигнатура — notice_dialog(text, storage_key, storage=app.storage.general)
(custom_ui.py). Любой аккаунт с токеном '123' получал TypeError и 500
вместо предупреждения.
Как исправлено: вызов переписан на кит-диалог
NoticeDialog(text, storage_key=..., title=...) (src/ui_kit/dialogs.py, титул —
именованный kwarg), поэтому тест test_disabled_installation_branch больше не
xfail: он зелёный и падает при регрессии (маркер xfail снят).
12. Валидация формы добавления правила односторонняя
В set_project/expenseitems пустые селекты не дают пользователю никакой
обратной связи при клике «+» (add_rule просто выходит), а ошибка
(select.error = "Минимум 3 символа") ставится только из обработчика поиска
input-value. В тесте валидацию можно проверить лишь эмуляцией события ввода,
а не кликом по «+».
Важно про select.error: у обычного ui.select нет property error,
поэтому одного присваивания мало — сообщение надо писать и в пропы Quasar
(error / error-message). В set_project это делает хелпер
_set_select_error(...), в ките — ErrorMixin (ui_kit/inputs.py).
13. Мастер doc-from-table — только поверхностные проверки
Открытие мастера тянет get_attributes и get_documents в МойСклад из фоновых
задач; шаги «Распознать лист» / «Сохранить настройки» требуют Google Sheets
(Sheets) и МС-метаданных. Без сети и без полноценных фейков Google-слоя
проверяется только рендер шагов и наличие контролов, не их бизнес-логика.
14. Известная ловушка самого тестового API
user.find(ui.button, content='X') молча игнорирует content: при
позиционном target-классе фильтр строится как make_filter(kind=target) и
marker/content отбрасываются — вернутся ВСЕ кнопки. Работает только
user.find(kind=ui.button, content='X'). Также nicegui нормализует имена
событий в camelCase: .on("input-value", ...) слушается как inputValue,
.on("update:model-value", ...) — как update:modelValue.