Writing Playwright tests for a Datasette Plugin
I really like Playwright for writing automated tests for web applications using a headless browser. It’s pretty easy to install and run, and it works well in GitHub Actions.
Today I integrated Playwright into the tests for one of my Datasette plugins for the first time. I based my work off Alex Garcia’s tests for datasette-comments.
I added Playwright to my datasette-search-all plugin as part of issue #19. Here’s what I did.
Playwright as a test dependency
I ended up needing two new test dependencies to get Playwright running: pytest-playwright and nest-asyncio (for reasons explained later).
I added those to my setup.py file like this:
    extras_require={        "test": ["pytest", "pytest-asyncio", "sqlite-utils", "nest-asyncio"],        "playwright": ["pytest-playwright"]    },I decided to make playwright part of its own group, so that I could avoid running Playwright tests by default due to the size of the extra browser dependency.
If I was using pyproject.toml for this project I would add this instead:
[project.optional-dependencies]test = ["pytest", "pytest-asyncio", "sqlite-utils", "nest-asyncio"]playwright = ["pytest-playwright"]With either of these patterns in place, the new dependencies can be installed like this:
pip install -e '.[test,playwright]'Running a localhost server for the tests
I decided to use a pytest fixture to start a localhost server running for the duration of the test. The simplest version of that (wait_until_responds from Alex’s datasette-comments) looks like this:
import pytestimport sqlite3from subprocess import Popen, PIPEimport sysimport timeimport httpx
@pytest.fixture(scope="session")def ds_server(tmp_path_factory):    tmpdir = tmp_path_factory.mktemp("tmp")    db_path = str(tmpdir / "data.db")    db = sqlite3.connect(db_path)    db.execute("""        create table foo (            id integer primary key,            bar text        )    """)    process = Popen(        [            sys.executable,            "-m",            "datasette",            "--port",            "8126",            str(db_path),        ],        stdout=PIPE,    )    wait_until_responds(        "http://localhost:8126/"    )    yield "http://localhost:8126"    process.terminate()    process.wait()
def wait_until_responds(url, timeout=5.0):    start = time.time()    while time.time() - start < timeout:        try:            httpx.get(url)            return        except httpx.ConnectError:            time.sleep(0.1)    raise AssertionError("Timed out waiting for {} to respond".format(url))The ds_server fixture creates a SQLite database in a temporary directory, runs Datasette against it using subprocess.Popen() and then waits for the server to respond to a request. Then it yields the URL to that server - that yielded value will become available to any test that uses that fixture.
Note that ds_server is marked as @pytest.fixture(scope="session"). This means that the fixture will be excuted just once per test session and re-used by each test. Without the scope="session" the server will be started and then terminated once per test, which is a lot slower.
See Session-scoped temporary directories in pytest for an explanation of the tmp_path_factory fixture.
Here’s what a basic test then looks like (in tests/test_playwright.py):
try:    from playwright import sync_apiexcept ImportError:    sync_api = Noneimport pytest
@pytest.mark.skipif(sync_api is None, reason="playwright not installed")def test_homepage(ds_server):    with sync_api.sync_playwright() as playwright:        browser = playwright.chromium.launch()        page = browser.new_page()        page.goto(ds_server + "/")        assert page.title() == "Datasette: data"Within that test, the full Python Playwright API is available for interacting with the server and running assertions. Since it’s running in a real headless Chromium instance all of the JavaScript will be executed as well.
I’m using a except ImportError pattern here such that my tests won’t fail if Playwright has not been installed. The @pytest.mark.skipif decorator causes the test to be marked as skipped if the module was not imported.
Running the tests
With this module in place, running the tests is like any other pytest invocation:
pytestOr run them specifically like this:
pytest tests/test_playwright.py# orpytest -k test_homepageRefactoring for cleaner code
After some experimentation I ended up with this pattern instead:
try:    from playwright import sync_apiexcept ImportError:    sync_api = Noneimport pytestimport nest_asyncio
nest_asyncio.apply()
pytestmark = pytest.mark.skipif(sync_api is None, reason="playwright not installed")
def test_ds_server(ds_server, page):    page.goto(ds_server + "/")    assert page.title() == "Datasette: data"    # It should have a search form    assert page.query_selector('form[action="/-/search"]')
def test_search(ds_server, page):    page.goto(ds_server + "/-/search?q=cleo")    # Should show search results, after fetching them    assert page.locator("table tr th:nth-child(1)").inner_text() == "rowid"    # ... assertions continueThere are two new tricks in here:
- I’m using the 
pytestmark = pytest.mark.skipif()pattern to apply thatskipifdecorator to every test in this file, without needing to repeat it. - I’m using the 
pagefixture provided by pytest-playwright. This gives me a newpageobject for each test, without me needing to call thewith sync_api.sync_playwright() as playwrightboilerplate every time. 
One catch with the page fixture is when I first started using it I got this error:
This event loop is already runningAfter some digging around I found a solution in this issue, which was to apply nest_asyncio.apply() at the start of the module.
Running this in GitHub Actions
I updated my .github/workflows/test.yml workflow to look like this:
name: Test
on: [push, pull_request]
permissions:  contents: read
jobs:  test:    runs-on: ubuntu-latest    strategy:      matrix:        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]    steps:    - uses: actions/checkout@v3    - name: Set up Python ${{ matrix.python-version }}      uses: actions/setup-python@v4      with:        python-version: ${{ matrix.python-version }}        cache: pip        cache-dependency-path: setup.py    - name: Cache Playwright browsers      uses: actions/cache@v3      with:        path: ~/.cache/ms-playwright/        key: ${{ runner.os }}-browsers    - name: Install dependencies      run: |        pip install '.[test,playwright]'        playwright install    - name: Run tests      run: |        pytestThis workflow configures caching for Playwright browsers, to ensure that playwright install only downloads the browser binaries the first time the workflow is executed.