Explorar o código

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 <falko@zauberzeug.com>
Rodja Trappe hai 9 meses
pai
achega
48e832ccb1

+ 1 - 1
examples/authentication/test_authentication.py

@@ -6,7 +6,7 @@ from . import main
 
 # pylint: disable=missing-function-docstring
 
-pytest_plugins = ['nicegui.testing.plugin']
+pytest_plugins = ['nicegui.testing.user_plugin']
 
 
 @pytest.mark.module_under_test(main)

+ 1 - 1
examples/chat_app/test_chat_app.py

@@ -7,7 +7,7 @@ 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)

+ 1 - 1
examples/todo_list/test_todo_list.py

@@ -6,7 +6,7 @@ from . import main
 
 # pylint: disable=missing-function-docstring
 
-pytest_plugins = ['nicegui.testing.plugin']
+pytest_plugins = ['nicegui.testing.user_plugin']
 
 
 @pytest.mark.module_under_test(main)

+ 80 - 0
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')

+ 4 - 202
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

+ 84 - 0
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)

+ 61 - 0
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

+ 4 - 4
tests/test_download.py

@@ -5,7 +5,7 @@ import pytest
 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_download_text_file(screen: Screen, test_route: str):  # pylint: disable
     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'

+ 5 - 3
website/documentation/content/project_structure_documentation.py

@@ -9,6 +9,8 @@ doc.text('Project Structure', '''
     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 simple_project_code():
                 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 modular_project():
                 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 @@ def custom_user_fixture():
                 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:

+ 3 - 1
website/documentation/content/user_documentation.py

@@ -9,7 +9,9 @@ from . import doc
 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.