Testing

Note

Testing is officially supported since 1.9

For testing Lona comes with a pytest plugin, based on pytest-aiohttp. The plugin contains two fixtures: lona_app_context and lona_project_context.

lona_app_context spins up a test server using an API like the Lona script interface, lona_project_context takes the project root and settings of a Lona project to start the test server. Lona test servers run on an unprivileged random port.

The Lona testing infrastructure is meant to be used together with a browser testing library like playwright or selenium. The test suite for the Lona project and all examples in this article use playwright because it is easy to use and supports asynchronous programming out of the box. If you prefer selenium, which requires synchronous programming, take a look at Writing Synchronous Tests

lona_app_context Example

def setup_app(app):
    from lona.html import Button
    from lona import LonaView

    @app.route('/')
    class MyLonaView(LonaView):
        def handle_request(self, request):
            self.show(Button('click me!'))
            self.await_click()
            self.show('SUCCESS')


async def test_button_click(lona_app_context):
    from playwright.async_api import async_playwright

    context = await lona_app_context(setup_app)

    async with async_playwright() as p:
        browser = await p.chromium.launch()
        browser_context = await browser.new_context()
        page = await browser_context.new_page()

        await page.goto(context.make_url('/'))
        assert await page.inner_text('#lona') != 'SUCCESS'
        await page.click('button')
        await page.wait_for_selector('#lona:has-text("SUCCESS")')

lona_project_context Example

async def test_test_project(lona_project_context):
    import os

    from playwright.async_api import async_playwright

    context = await lona_project_context(
        project_root='/home/test-user/my-lona-project,
        settings=['settings.py'],
    )

    async with async_playwright() as p:
        browser = await p.chromium.launch()
        browser_context = await browser.new_context()
        page = await browser_context.new_page()

        await page.goto(context.make_url('/my-url/'))
        await page.wait_for_selector('#lona>h1:has-text("Hello World")')

LonaContext Objects

Both lona_app_context and lona_project_context return an lona.pytest.LonaContext object that holds all state for the running test.

LonaContext.app

Reference to the lona.LonaApp object of the running test server. When using lona_project_context this variable is None.

LonaContext.server

Reference to the Server object of the running test server.

LonaContext.loop

Reference to the running asyncio ioloop.

LonaContext.make_url()

lona.pytest.LonaContext.make_url(
    self,
    path: 'str' = '',
) -> str
    Takes a path and returns a full URL to the running test server.

    This method is necessary because the Lona pytest plugin spins up a test
    server that runs on an unprivileged random port, so URLs are not stable
    between test runs.

    :path: (str) path to to view

LonaContext.debug_interactive()

lona.pytest.LonaContext.debug_interactive(
    self,
    webbrowser: 'bool' = True,
    sync: 'bool' = False,
    locals: 'Dict[str, Any] | None' = None,
) -> Future | None
    This method pauses the current test by starting an rlpython shell and
    starts a webbrowser that points to the currently running test server.
    The test continues when the rlpython shell is exited.

    Async Example:

        async def test_my_feature(lona_app_context):
            context = await lona_app_context(setup_app)

            await context.debug_interactive(locals=locals())

    Sync Example:

        async def test_my_feature(lona_app_context):
            context = await lona_app_context(setup_app)

            def run_test():
                context.debug_interactive(
                    sync=True,
                    locals=locals(),
                )

            context.event_loop.run_in_executor(None, run_test)

    :webbrowser: (bool) start a webbrowser that points to the test server
    :sync:       (bool) run blocking in the current thread
    :locals:     (dict|None) variable overrides for the rlpython shell

Writing Synchronous Tests

If you need to use blocking synchronous code in your test, because you want to access a database, or your browser testing library does not support asynchronous code, you can move your actual test code into a thread.

def setup_app(app):
    from lona.html import HTML, H1
    from lona import LonaView

    @app.route('/my-url/')
    class MyLonaView(LonaView):
        def handle_request(self, request):
            self.my_variable = 'foo'

            return HTML(
                H1('Hello World'),
            )


async def test_my_lona_view(lona_app_context, selenium):
    context = await lona_app_context(setup_app)

    # actual test
    def run_test():
        selenium.get(lona_context.make_url('/my-url/'))
        element = selenium.find_element_by_css_selector('h1')

        assert element.text == 'Hello World'

    # run test in a thread
    await context.loop.run_in_executor(None, run_test)

Accessing View Variables From A Test

To access the variables of a Lona view, you can get your running view object using Server.get_views().

def setup_app(app):
    from lona.html import HTML, H1
    from lona import LonaView

    @app.route('/my-url/')
    class MyLonaView(LonaView):
        def handle_request(self, request):
            self.my_variable = 'foo'

            return HTML(
                H1('Hello World'),
            )


async def test_my_lona_view(lona_app_context):
    from playwright.async_api import async_playwright

    context = await lona_app_context(setup_app)

    async with async_playwright() as p:
        browser = await p.chromium.launch()
        browser_context = await browser.new_context()
        page = await browser_context.new_page()

        await page.goto(context.make_url())
        await page.wait_for_selector('#lona h1:has-text("Hello World")')

        view = context.server.get_views(url='/my-url/')[0]

        assert view.my_variable == 'foo'

Timing Sensitive Tests

Lona has a complex rendering mechanism that has some overhead to it. This means that some state changes happen slightly delayed, which can result in failing tests.

lona.pytest.eventually is meant to retry asserts periodically with a timeout.

lona.pytest.eventually(
    timeout: 'float' = 5,
    interval: 'float' = 1,
) -> Iterator[AsyncContextManager]
    Wait for expected state in async test.

    The function is meant to be iterated. Each time it returns new attempt.
    Attempt is an async context manager to wrap assertions.
    It suppresses all exceptions until time is out.
    If no exception is raised iteration stops.

    Example::

        counter = 0
        for attempt in eventually():
            async with attempt:
                counter += 1
                assert counter > 3

    :param timeout: time in seconds during which it produces new attempts
    :param interval: seconds to sleep between attempts

Tips

  • The root HTML element where all Lona views run has the id lona when using the default template. The CSS selector is #lona in this case.