From 48e832ccb1a64520b147057f88096c99bc448684 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Thu, 15 Aug 2024 08:32:50 +0200 Subject: [PATCH] Provide two additional pytest plugins to make user and screen able to 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 --- .../authentication/test_authentication.py | 2 +- examples/chat_app/test_chat_app.py | 2 +- examples/todo_list/test_todo_list.py | 2 +- nicegui/testing/general_fixtures.py | 80 +++++++ nicegui/testing/plugin.py | 206 +----------------- nicegui/testing/screen_plugin.py | 84 +++++++ nicegui/testing/user_plugin.py | 61 ++++++ tests/test_download.py | 8 +- .../project_structure_documentation.py | 8 +- .../content/user_documentation.py | 4 +- 10 files changed, 244 insertions(+), 213 deletions(-) create mode 100644 nicegui/testing/general_fixtures.py create mode 100644 nicegui/testing/screen_plugin.py create mode 100644 nicegui/testing/user_plugin.py diff --git a/examples/authentication/test_authentication.py b/examples/authentication/test_authentication.py index 4ba056eab..019eecfab 100644 --- a/examples/authentication/test_authentication.py +++ b/examples/authentication/test_authentication.py @@ -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) diff --git a/examples/chat_app/test_chat_app.py b/examples/chat_app/test_chat_app.py index 2297e3d87..2d7f77e17 100644 --- a/examples/chat_app/test_chat_app.py +++ b/examples/chat_app/test_chat_app.py @@ -7,7 +7,7 @@ from . import main -pytest_plugins = ['nicegui.testing.plugin'] +pytest_plugins = ['nicegui.testing.user_plugin'] @pytest.mark.module_under_test(main) diff --git a/examples/todo_list/test_todo_list.py b/examples/todo_list/test_todo_list.py index b079125cd..37bf46dda 100644 --- a/examples/todo_list/test_todo_list.py +++ b/examples/todo_list/test_todo_list.py @@ -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) diff --git a/nicegui/testing/general_fixtures.py b/nicegui/testing/general_fixtures.py new file mode 100644 index 000000000..6efcebbaf --- /dev/null +++ b/nicegui/testing/general_fixtures.py @@ -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') diff --git a/nicegui/testing/plugin.py b/nicegui/testing/plugin.py index 4150a7466..34d147396 100644 --- a/nicegui/testing/plugin.py +++ b/nicegui/testing/plugin.py @@ -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 diff --git a/nicegui/testing/screen_plugin.py b/nicegui/testing/screen_plugin.py new file mode 100644 index 000000000..a0a595bf7 --- /dev/null +++ b/nicegui/testing/screen_plugin.py @@ -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) diff --git a/nicegui/testing/user_plugin.py b/nicegui/testing/user_plugin.py new file mode 100644 index 000000000..96ed539eb --- /dev/null +++ b/nicegui/testing/user_plugin.py @@ -0,0 +1,61 @@ +import asyncio +from typing import AsyncGenerator, Callable + +import httpx +import pytest + +from nicegui import Client, core, ui +from nicegui.functions.navigate import Navigate +from nicegui.functions.notify import notify + +from .general_fixtures import ( # noqa: F401 # pylint: disable=unused-import + nicegui_reset_globals, + prepare_simulation, + pytest_configure, +) +from .user import User + +# pylint: disable=redefined-outer-name + + +@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 + + +@pytest.fixture +async def user(nicegui_reset_globals, # noqa: F811, 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, # noqa: F811, 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 diff --git a/tests/test_download.py b/tests/test_download.py index 93a108f46..b821a6310 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -5,7 +5,7 @@ from fastapi.responses import PlainTextResponse from nicegui import app, ui -from nicegui.testing import Screen, plugin +from nicegui.testing import Screen, screen_plugin @pytest.fixture @@ -25,7 +25,7 @@ def test(): screen.open('/') screen.click('Download') screen.wait(0.5) - assert (plugin.DOWNLOAD_DIR / 'test.txt').read_text() == 'test' + assert (screen_plugin.DOWNLOAD_DIR / 'test.txt').read_text() == 'test' def test_downloading_local_file_as_src(screen: Screen): @@ -36,7 +36,7 @@ def test_downloading_local_file_as_src(screen: Screen): route_count_before_download = len(app.routes) screen.click('download') screen.wait(0.5) - assert (plugin.DOWNLOAD_DIR / 'slide1.jpg').exists() + assert (screen_plugin.DOWNLOAD_DIR / 'slide1.jpg').exists() assert len(app.routes) == route_count_before_download @@ -46,4 +46,4 @@ def test_download_raw_data(screen: Screen): screen.open('/') screen.click('download') screen.wait(0.5) - assert (plugin.DOWNLOAD_DIR / 'test.txt').read_text() == 'test' + assert (screen_plugin.DOWNLOAD_DIR / 'test.txt').read_text() == 'test' diff --git a/website/documentation/content/project_structure_documentation.py b/website/documentation/content/project_structure_documentation.py index b299fa877..24eb16b25 100644 --- a/website/documentation/content/project_structure_documentation.py +++ b/website/documentation/content/project_structure_documentation.py @@ -9,6 +9,8 @@ This makes specialized [fixtures](https://docs.pytest.org/en/stable/explanation/fixtures.html) available for testing your NiceGUI user interface. With the [`screen` fixture](/documentation/screen) you can run the tests through a headless browser (slow) and with the [`user` fixture](/documentation/user) fully simulated in Python (fast). + If you only want one kind of test fixtures, + you can also use the plugin `nicegui.testing.user_plugin` or `nicegui.testing.screen_plugin`. There are a multitude of ways to structure your project and tests. Here we only present two approaches which we found useful, @@ -59,7 +61,7 @@ def hello() -> None: from nicegui.testing import User from . import main - pytest_plugins = ['nicegui.testing.plugin'] + pytest_plugins = ['nicegui.testing.user_plugin'] @pytest.mark.module_under_test(main) async def test_click(user: User) -> None: @@ -136,7 +138,7 @@ def index(): from nicegui.testing import User from app.startup import startup - pytest_plugins = ['nicegui.testing.plugin'] + pytest_plugins = ['nicegui.testing.user_plugin'] async def test_click(user: User) -> None: startup() @@ -197,7 +199,7 @@ async def test_click(user: User) -> None: from nicegui.testing import User from app.startup import startup - pytest_plugins = ['nicegui.testing.plugin'] + pytest_plugins = ['nicegui.testing.user_plugin'] @pytest.fixture def user(user: User) -> User: diff --git a/website/documentation/content/user_documentation.py b/website/documentation/content/user_documentation.py index d4aec35d9..bd2853465 100644 --- a/website/documentation/content/user_documentation.py +++ b/website/documentation/content/user_documentation.py @@ -9,7 +9,9 @@ def user_fixture(): ui.markdown(''' We recommend utilizing the `user` fixture instead of the [`screen` fixture](/documentation/screen) wherever possible - because execution is as fast as unit tests and it does not need Selenium as a dependency. + because execution is as fast as unit tests and it does not need Selenium as a dependency + when loaded via `pytest_plugins = ['nicegui.testing.user_plugin']` + (see [project structure](/documentation/project_structure)). The `user` fixture cuts away the browser and replaces it by a lightweight simulation entirely in Python. You can assert to "see" specific elements or content, click buttons, type into inputs and trigger events.