Skip to content

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-тестирования (проверено на практике)

  1. pytest_plugins только в top-level conftest. У nicegui нет pytest11 entry point, поэтому фикстуры user/create_user появляются лишь если подключить nicegui.testing.user_plugin. В не-корневом conftest pytest это запрещает → подключаем в services/backend/conftest.py (rootdir).

  2. 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'.

  3. 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 до регистрации и вернуть после.

  4. should_see ждёт только НОВЫЙ элемент. Если проверяемый текст уже был на странице, он вернётся мгновенно и фоновый таск не успеет выполниться. Для побочных эффектов нужен свой опрос (wait_for).

  5. user.find(ui.button, content='X') молча игнорирует content — при позиционном target-классе фильтр строится как make_filter(kind=target). Работает только user.find(kind=ui.button, content='X').

  6. Имена событий нормализуются в camelCase: .on("input-value", ...) → inputValue, .on("update:model-value", ...) → update:modelValue. UserInteraction.trigger требует ИМЕННО нормализованное имя.

  7. Присвоение .value вызывает on_change, но не явные .on(...)-листенеры. Если страница подписана через .on("update:modelValue", handler), нужно interact(user, [element]).trigger("update:modelValue", [True]).

  8. Нет виртуального DOM. @ui.page-функция выполняется один раз на загрузку; дальше элементы обновляются in-place (set_text, set_visibility, update_rows). «Новых» элементов после клика ждать не нужно — проверяем свойства существующих. Исключение: диалоги и уведомления действительно добавляют новые элементы в layout.

  9. Page-builder имеет таймаут (3 с по умолчанию). Любой медленный вызов в main() (БД, сеть) делает user.open() красным → фейки обязаны быть быстрыми и синхронными по возможности.

  10. Маркеры (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.