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.
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]'
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 pytest
import sqlite3
from subprocess import Popen, PIPE
import sys
import time
import 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_api
except ImportError:
sync_api = None
import 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.
With this module in place, running the tests is like any other pytest
invocation:
pytest
Or run them specifically like this:
pytest tests/test_playwright.py
# or
pytest -k test_homepage
After some experimentation I ended up with this pattern instead:
try:
from playwright import sync_api
except ImportError:
sync_api = None
import pytest
import 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 continue
There are two new tricks in here:
pytestmark = pytest.mark.skipif()
pattern to apply that skipif
decorator to every test in this file, without needing to repeat it.page
fixture provided by pytest-playwright. This gives me a new page
object for each test, without me needing to call the with sync_api.sync_playwright() as playwright
boilerplate every time.One catch with the page
fixture is when I first started using it I got this error:
This event loop is already running
After some digging around I found a solution in this issue, which was to apply nest_asyncio.apply()
at the start of the module.
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: |
pytest
This workflow configures caching for Playwright browsers, to ensure that playwright install
only downloads the browser binaries the first time the workflow is executed.
Created 2024-01-08T20:44:16-08:00, updated 2024-01-12T22:23:04-08:00 · History · Edit