Skip to content

Commit

Permalink
Provide two additional pytest plugins to make user and screen able to…
Browse files Browse the repository at this point in the history
… load independently (#3511)

* creating two additional plugins to make user and screen able to load independently

* fix download tests

* update docs to explain new plugins

* code review

---------

Co-authored-by: Falko Schindler <falko@zauberzeug.com>
  • Loading branch information
rodja and falkoschindler committed Aug 15, 2024
1 parent 7185449 commit 48e832c
Show file tree
Hide file tree
Showing 10 changed files with 244 additions and 213 deletions.
2 changes: 1 addition & 1 deletion examples/authentication/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

# pylint: disable=missing-function-docstring

pytest_plugins = ['nicegui.testing.plugin']
pytest_plugins = ['nicegui.testing.user_plugin']


@pytest.mark.module_under_test(main)
Expand Down
2 changes: 1 addition & 1 deletion examples/chat_app/test_chat_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from . import main

pytest_plugins = ['nicegui.testing.plugin']
pytest_plugins = ['nicegui.testing.user_plugin']


@pytest.mark.module_under_test(main)
Expand Down
2 changes: 1 addition & 1 deletion examples/todo_list/test_todo_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

# pylint: disable=missing-function-docstring

pytest_plugins = ['nicegui.testing.plugin']
pytest_plugins = ['nicegui.testing.user_plugin']


@pytest.mark.module_under_test(main)
Expand Down
80 changes: 80 additions & 0 deletions nicegui/testing/general_fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import importlib
from typing import Generator, List, Type

import pytest
from starlette.routing import Route

import nicegui.storage
from nicegui import Client, app, binding, core, run, ui
from nicegui.page import page

# pylint: disable=redefined-outer-name


def pytest_configure(config: pytest.Config) -> None:
"""Add the "module_under_test" marker to the pytest configuration."""
config.addinivalue_line('markers',
'module_under_test(module): specify the module under test which then gets automatically reloaded.')


@pytest.fixture
def nicegui_reset_globals() -> Generator[None, None, None]:
"""Reset the global state of the NiceGUI package."""
for route in app.routes:
if isinstance(route, Route) and route.path.startswith('/_nicegui/auto/static/'):
app.remove_route(route.path)
for path in {'/'}.union(Client.page_routes.values()):
app.remove_route(path)
app.openapi_schema = None
app.middleware_stack = None
app.user_middleware.clear()
app.urls.clear()
core.air = None
# NOTE favicon routes must be removed separately because they are not "pages"
for route in app.routes:
if isinstance(route, Route) and route.path.endswith('/favicon.ico'):
app.routes.remove(route)
importlib.reload(core)
importlib.reload(run)
element_classes: List[Type[ui.element]] = [ui.element]
while element_classes:
parent = element_classes.pop()
for cls in parent.__subclasses__():
cls._default_props = {} # pylint: disable=protected-access
cls._default_style = {} # pylint: disable=protected-access
cls._default_classes = [] # pylint: disable=protected-access
element_classes.append(cls)
Client.instances.clear()
Client.page_routes.clear()
app.reset()
Client.auto_index_client = Client(page('/'), request=None).__enter__() # pylint: disable=unnecessary-dunder-call
# NOTE we need to re-add the auto index route because we removed all routes above
app.get('/')(Client.auto_index_client.build_response)
binding.reset()
yield


def prepare_simulation(request: pytest.FixtureRequest) -> None:
"""Prepare a simulation to be started.
By using the "module_under_test" marker you can specify the main entry point of the app.
"""
marker = request.node.get_closest_marker('module_under_test')
if marker is not None:
with Client.auto_index_client:
importlib.reload(marker.args[0])

core.app.config.add_run_config(
reload=False,
title='Test App',
viewport='',
favicon=None,
dark=False,
language='en-US',
binding_refresh_interval=0.1,
reconnect_timeout=3.0,
tailwind=True,
prod_js=True,
show_welcome_message=False,
)
nicegui.storage.set_storage_secret('simulated secret')
206 changes: 4 additions & 202 deletions nicegui/testing/plugin.py
Original file line number Diff line number Diff line change
@@ -1,202 +1,4 @@
import asyncio
import importlib
import os
import shutil
from pathlib import Path
from typing import AsyncGenerator, Callable, Dict, Generator, List, Type

import httpx
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from starlette.routing import Route

import nicegui.storage
from nicegui import Client, app, binding, core, run, ui
from nicegui.functions.navigate import Navigate
from nicegui.functions.notify import notify
from nicegui.page import page

from .screen import Screen
from .user import User

# pylint: disable=redefined-outer-name

DOWNLOAD_DIR = Path(__file__).parent / 'download'


def pytest_configure(config: pytest.Config) -> None:
"""Add the "module_under_test" marker to the pytest configuration."""
config.addinivalue_line('markers',
'module_under_test: specify the module under test which then gets automatically reloaded.')


@pytest.fixture
def nicegui_chrome_options(chrome_options: webdriver.ChromeOptions) -> webdriver.ChromeOptions:
"""Configure the Chrome options for the NiceGUI tests."""
chrome_options.add_argument('disable-dev-shm-usage')
chrome_options.add_argument('no-sandbox')
chrome_options.add_argument('headless')
chrome_options.add_argument('disable-gpu' if 'GITHUB_ACTIONS' in os.environ else '--use-gl=angle')
chrome_options.add_argument('window-size=600x600')
chrome_options.add_experimental_option('prefs', {
'download.default_directory': str(DOWNLOAD_DIR),
'download.prompt_for_download': False, # To auto download the file
'download.directory_upgrade': True,
})
if 'CHROME_BINARY_LOCATION' in os.environ:
chrome_options.binary_location = os.environ['CHROME_BINARY_LOCATION']
return chrome_options


@pytest.fixture
def capabilities(capabilities: Dict) -> Dict:
"""Configure the Chrome driver capabilities."""
capabilities['goog:loggingPrefs'] = {'browser': 'ALL'}
return capabilities


@pytest.fixture
def nicegui_reset_globals() -> Generator[None, None, None]:
"""Reset the global state of the NiceGUI package."""
for route in app.routes:
if isinstance(route, Route) and route.path.startswith('/_nicegui/auto/static/'):
app.remove_route(route.path)
for path in {'/'}.union(Client.page_routes.values()):
app.remove_route(path)
app.openapi_schema = None
app.middleware_stack = None
app.user_middleware.clear()
app.urls.clear()
core.air = None
# NOTE favicon routes must be removed separately because they are not "pages"
for route in app.routes:
if isinstance(route, Route) and route.path.endswith('/favicon.ico'):
app.routes.remove(route)
importlib.reload(core)
importlib.reload(run)
element_classes: List[Type[ui.element]] = [ui.element]
while element_classes:
parent = element_classes.pop()
for cls in parent.__subclasses__():
cls._default_props = {} # pylint: disable=protected-access
cls._default_style = {} # pylint: disable=protected-access
cls._default_classes = [] # pylint: disable=protected-access
element_classes.append(cls)
Client.instances.clear()
Client.page_routes.clear()
app.reset()
Client.auto_index_client = Client(page('/'), request=None).__enter__() # pylint: disable=unnecessary-dunder-call
# NOTE we need to re-add the auto index route because we removed all routes above
app.get('/')(Client.auto_index_client.build_response)
binding.reset()
yield


@pytest.fixture(scope='session')
def nicegui_remove_all_screenshots() -> None:
"""Remove all screenshots from the screenshot directory before the test session."""
if os.path.exists(Screen.SCREENSHOT_DIR):
for name in os.listdir(Screen.SCREENSHOT_DIR):
os.remove(os.path.join(Screen.SCREENSHOT_DIR, name))


@pytest.fixture()
def nicegui_driver(nicegui_chrome_options: webdriver.ChromeOptions) -> Generator[webdriver.Chrome, None, None]:
"""Create a new Chrome driver instance."""
s = Service()
driver_ = webdriver.Chrome(service=s, options=nicegui_chrome_options)
driver_.implicitly_wait(Screen.IMPLICIT_WAIT)
driver_.set_page_load_timeout(4)
yield driver_
driver_.quit()


@pytest.fixture
def screen(nicegui_reset_globals, # pylint: disable=unused-argument
nicegui_remove_all_screenshots, # pylint: disable=unused-argument
nicegui_driver: webdriver.Chrome,
request: pytest.FixtureRequest,
caplog: pytest.LogCaptureFixture,
) -> Generator[Screen, None, None]:
"""Create a new SeleniumScreen fixture."""
prepare_simulation(request)
screen_ = Screen(nicegui_driver, caplog)
yield screen_
logs = screen_.caplog.get_records('call')
if screen_.is_open:
screen_.shot(request.node.name)
screen_.stop_server()
if DOWNLOAD_DIR.exists():
shutil.rmtree(DOWNLOAD_DIR)
if logs:
pytest.fail('There were unexpected logs. See "Captured log call" below.', pytrace=False)


@pytest.fixture
async def user(nicegui_reset_globals, # pylint: disable=unused-argument
prepare_simulated_auto_index_client, # pylint: disable=unused-argument
request: pytest.FixtureRequest,
) -> AsyncGenerator[User, None]:
"""Create a new user fixture."""
prepare_simulation(request)
async with core.app.router.lifespan_context(core.app):
async with httpx.AsyncClient(app=core.app, base_url='http://test') as client:
yield User(client)
ui.navigate = Navigate()
ui.notify = notify


@pytest.fixture
async def create_user(nicegui_reset_globals, # pylint: disable=unused-argument
prepare_simulated_auto_index_client, # pylint: disable=unused-argument
request: pytest.FixtureRequest,
) -> AsyncGenerator[Callable[[], User], None]:
"""Create a fixture for building new users."""
prepare_simulation(request)
async with core.app.router.lifespan_context(core.app):
yield lambda: User(httpx.AsyncClient(app=core.app, base_url='http://test'))
ui.navigate = Navigate()
ui.notify = notify


@pytest.fixture()
def prepare_simulated_auto_index_client(request):
"""Prepare the simulated auto index client."""
original_test = request.node._obj # pylint: disable=protected-access
if asyncio.iscoroutinefunction(original_test):
async def wrapped_test(*args, **kwargs):
with Client.auto_index_client:
return await original_test(*args, **kwargs)
request.node._obj = wrapped_test # pylint: disable=protected-access
else:
def wrapped_test(*args, **kwargs):
Client.auto_index_client.__enter__() # pylint: disable=unnecessary-dunder-call
return original_test(*args, **kwargs)
request.node._obj = wrapped_test # pylint: disable=protected-access


def prepare_simulation(request: pytest.FixtureRequest) -> None:
"""Prepare a simulation to be started.
By using the "module_under_test" marker you can specify the main entry point of the app.
"""
marker = request.node.get_closest_marker('module_under_test')
if marker is not None:
with Client.auto_index_client:
importlib.reload(marker.args[0])

core.app.config.add_run_config(
reload=False,
title='Test App',
viewport='',
favicon=None,
dark=False,
language='en-US',
binding_refresh_interval=0.1,
reconnect_timeout=3.0,
tailwind=True,
prod_js=True,
show_welcome_message=False,
)
nicegui.storage.set_storage_secret('simulated secret')
# pylint: disable=unused-import
from .general_fixtures import nicegui_reset_globals, pytest_configure # noqa: F401
from .screen_plugin import nicegui_chrome_options, nicegui_driver, nicegui_remove_all_screenshots, screen # noqa: F401
from .user_plugin import create_user, prepare_simulated_auto_index_client, user # noqa: F401
84 changes: 84 additions & 0 deletions nicegui/testing/screen_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import os
import shutil
from pathlib import Path
from typing import Dict, Generator

import pytest
from selenium import webdriver
from selenium.webdriver.chrome.service import Service

from .general_fixtures import ( # noqa: F401 # pylint: disable=unused-import
nicegui_reset_globals,
prepare_simulation,
pytest_configure,
)
from .screen import Screen

# pylint: disable=redefined-outer-name

DOWNLOAD_DIR = Path(__file__).parent / 'download'


@pytest.fixture
def nicegui_chrome_options(chrome_options: webdriver.ChromeOptions) -> webdriver.ChromeOptions:
"""Configure the Chrome options for the NiceGUI tests."""
chrome_options.add_argument('disable-dev-shm-usage')
chrome_options.add_argument('no-sandbox')
chrome_options.add_argument('headless')
chrome_options.add_argument('disable-gpu' if 'GITHUB_ACTIONS' in os.environ else '--use-gl=angle')
chrome_options.add_argument('window-size=600x600')
chrome_options.add_experimental_option('prefs', {
'download.default_directory': str(DOWNLOAD_DIR),
'download.prompt_for_download': False, # To auto download the file
'download.directory_upgrade': True,
})
if 'CHROME_BINARY_LOCATION' in os.environ:
chrome_options.binary_location = os.environ['CHROME_BINARY_LOCATION']
return chrome_options


@pytest.fixture
def capabilities(capabilities: Dict) -> Dict:
"""Configure the Chrome driver capabilities."""
capabilities['goog:loggingPrefs'] = {'browser': 'ALL'}
return capabilities


@pytest.fixture(scope='session')
def nicegui_remove_all_screenshots() -> None:
"""Remove all screenshots from the screenshot directory before the test session."""
if os.path.exists(Screen.SCREENSHOT_DIR):
for name in os.listdir(Screen.SCREENSHOT_DIR):
os.remove(os.path.join(Screen.SCREENSHOT_DIR, name))


@pytest.fixture()
def nicegui_driver(nicegui_chrome_options: webdriver.ChromeOptions) -> Generator[webdriver.Chrome, None, None]:
"""Create a new Chrome driver instance."""
s = Service()
driver_ = webdriver.Chrome(service=s, options=nicegui_chrome_options)
driver_.implicitly_wait(Screen.IMPLICIT_WAIT)
driver_.set_page_load_timeout(4)
yield driver_
driver_.quit()


@pytest.fixture
def screen(nicegui_reset_globals, # noqa: F811, pylint: disable=unused-argument
nicegui_remove_all_screenshots, # pylint: disable=unused-argument
nicegui_driver: webdriver.Chrome,
request: pytest.FixtureRequest,
caplog: pytest.LogCaptureFixture,
) -> Generator[Screen, None, None]:
"""Create a new SeleniumScreen fixture."""
prepare_simulation(request)
screen_ = Screen(nicegui_driver, caplog)
yield screen_
logs = screen_.caplog.get_records('call')
if screen_.is_open:
screen_.shot(request.node.name)
screen_.stop_server()
if DOWNLOAD_DIR.exists():
shutil.rmtree(DOWNLOAD_DIR)
if logs:
pytest.fail('There were unexpected logs. See "Captured log call" below.', pytrace=False)
Loading

0 comments on commit 48e832c

Please sign in to comment.