Testing with a Browser

Goals

Why Test in a Browser?

What kinds of bugs can a browser catch that Litestar's test client cannot?

What do I need to install to run Playwright tests with pytest?

Starting a Server for Tests

What is the difference between tests that use Litestar's test client and tests that use Playwright?

What does scope="session" mean for a pytest fixture, and why use it for a server?

Write a conftest.py with a session-scoped fixture called server_url that starts server_pw.py and shuts it down after all tests finish.

i
import subprocess
import time
from pathlib import Path

import pytest

LESSON_DIR = Path(__file__).parent


@pytest.fixture(scope="session")
def server_url():
    proc = subprocess.Popen(
        ["uv", "run", "litestar", "run", "--app", "server_pw:app"],
        cwd=LESSON_DIR,
    )
    time.sleep(1.5)
    yield "http://localhost:8000"
    proc.terminate()
    proc.wait()

Navigating Pages

Copy the server from the forms lesson into this directory as server_pw.py.

i
    return Litestar(
        [
            index,
            detail,
            delete_sighting,
            add_form,
            add_sighting,
            upload_form,
            upload_csv,
            styles,
        ]
    )


app = make_app()

Write a test file called test_browser.py with a single test that opens the home page and checks its title.

Add two more tests: one that confirms the sightings table is present, and one that clicks a row link and waits for the detail page to load.

Filling in a Form

How does Playwright simulate typing in a form field, and how do you select which field to target?

Add a test to test_browser.py that opens the add-sighting form, fills in the required fields, submits the form, and confirms the browser ends up back on the home page.

i
def test_home_page_title(page, server_url):
    page.goto(server_url)
    assert "Sasquatch Sightings" in page.title()


def test_home_page_has_table(page, server_url):
    page.goto(server_url)
    assert page.locator("table").count() == 1


def test_click_sighting_link(page, server_url):
    page.goto(server_url)
    page.locator("table a").first.click()
    page.wait_for_url("**/sighting/**")
    assert "Sighting" in page.title()


def test_add_sighting(page, server_url):
    page.goto(f"{server_url}/add")
    page.fill('[name="species"]', "G. canadensis")
    page.fill('[name="color"]', "brown")
    page.fill('[name="datetime"]', "2024-06-15 09:30")
    page.fill('[name="latitude"]', "49.5")
    page.fill('[name="longitude"]', "-123.1")
    page.click('[type="submit"]')
    page.wait_for_url(server_url + "/")
    assert page.url == server_url + "/"

Run the tests from the testclient/ directory:

i
pytest test_browser.py --browser=firefox -v

See [playwright2025] for the full Playwright for Python documentation.

Check Understanding

A classmate writes client.post("/add", data={...}) with Litestar's TestClient to test the add form. What does this test confirm, and what does it miss?

The TestClient test confirms that the server receives the POST request, inserts the row into the database, and returns a 303 redirect. It does not confirm that the required attribute on the form actually stops an empty field from being submitted, because that check happens in the browser before the request is ever sent. A Playwright test is needed to verify browser-side behavior.

Why does the server_url fixture use scope="session" rather than the default scope?

The default scope is "function", which creates and tears down the fixture for every test function. Starting and stopping a real server process for each test would add several seconds per test and make the suite impractical. scope="session" creates the fixture once, shares it with all tests, and tears it down only after the last one finishes.

The test below fails intermittently: it passes on a fast machine and fails on a slow one. What is the most likely cause, and how do you fix it?

After .click(), the browser sends the request and starts loading the detail page, but the assertion may run before the new page has finished loading. On a fast machine the timing happens to work out; on a slow one it reads the old title and fails. Adding page.wait_for_url("**/sighting/**") after the click tells Playwright to pause until the navigation is complete before reading the title.

def test_click_link(page, server_url):
    page.goto(server_url)
    page.locator("table a").first.click()
    assert "Sighting" in page.title()
After proc.terminate(), why does conftest.py also call proc.wait()?

proc.terminate() sends a signal telling the server process to stop, but the process may take a moment to actually exit. Without proc.wait(), the next test run might start before the old server has released port 8000, causing a "port already in use" error. proc.wait() blocks until the process has completely stopped, so the port is free before the session teardown finishes.

The add-sighting test fills in species, color, datetime, latitude, and longitude but leaves sex and weight blank. Will the form submit successfully, and why?

Yes. The sex and weight fields do not have the required attribute, so the browser allows submission with those fields empty. The server receives empty strings for those fields and converts them to None before inserting into the database. The test confirms the round trip worked by checking that the browser ended up back on the home page.

Exercises

Test the Delete Button

Write a test that navigates to a sighting detail page, clicks "Delete this sighting", waits for the browser to return to the home page, and confirms that the deleted sighting's ID no longer appears as a link in the table.

Test a Missing Sighting

Write a test that visits /sighting/9999 and confirms the page title is not "Sasquatch Sightings". Run the test first with --headed to see what the browser actually shows for an error page, then write an assertion based on what you observe.

Verify Required Field Enforcement

Without calling page.fill for the species field, call page.click('[type="submit"]') on the add form and assert that page.url is still the /add URL afterward. This confirms the browser refused to submit the form because a required field was empty.

Test the CSV Upload Form

Write a test that navigates to /upload, attaches the sample.csv file from the forms lesson using page.set_input_files('[name="csv_file"]', path_to_csv), clicks Upload, and confirms the browser returns to the home page.