Skip to content

Commit 6d5aaa9

Browse files
committed
03: Playwright, interceptor, fixtures. Improve TestClient tests with beautifulsoup4.
1 parent 597f103 commit 6d5aaa9

File tree

10 files changed

+479
-12
lines changed

10 files changed

+479
-12
lines changed

docs/building/playwright.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Playwright Interceptors
2+
3+
PyScript testing needs a real browser -- web components which load Pyodide and execute Python, then change the DOM.
4+
[Playwright](https://playwright.dev/python/) provides this, but we'd like more convenience in the testing:
5+
6+
- Don't actually launch a web server to fetch the examples
7+
- Make it easier to write examples and tests by having some automation
8+
9+
In this step we bring in Playwright, but don't yet use PyScript.
10+
Here's the big idea: we do _not_ run a web server.
11+
Instead, we write a Playwright interceptor as a pytest fixture.
12+
13+
## Install Playwright
14+
15+
We need to add Playwright to PSC.
16+
In the current repo, this is done as part of a Makefile rule, which also copies the examples to a relative directory (bleh).
17+
18+
Instead, we'll just make it a development dependency.
19+
If a Contributor wants to write an example, they just need to clone the repo and do a Poetry install with dev dependencies.
20+
Kind of normal Python dev workflow.
21+
To make it work in CI using Nox, we added this dependency to the `noxfile.py`.
22+
23+
This still requires running `playwright install` manually, to get the Playwright browsers globally installed.
24+
That has to be added to PSC Contributor documentation.
25+
26+
## Fixture
27+
28+
With Playwright installed, now it is time to make it easier to write/run the tests for examples.
29+
In the previous step, we did "shallow" testing of an example, using `TestClient` to ensure the HTML was returned.
30+
We didn't actually load the HTML into a DOM, certainly didn't evaluate the PyScript web components, and _definitely_ didn't run some Python in Pyodide.
31+
32+
The current Collective uses Playwright's `page` fixture directly: you provide a URL, it tells the browser to make an HTTP request.
33+
This means it needs an HTTP server running.
34+
The repo fires up and shuts down a Python `SimpleHTTPServer` running in a thread, as part of test running.
35+
36+
If something gets hung...ouch.
37+
You have to wait for the thread to time out.
38+
39+
PSC changes this by not running an HTTP server for testing the examples.
40+
Instead, we use [Playwright interceptors](https://playwright.dev/python/docs/network#modify-responses).
41+
When the URL comes in, our Python code runs and returns a response...quite like `TestClient` pretends to run an ASGI server.
42+
Our "interceptor" looks at the URL, and if it is to the "fake" server, it reads/returns the path from disk.
43+
44+
This fixture is software, so we'll make a file at `src/psc/fixtures.py` and a test at `test_fixtures.py`.
45+
We also need to make `tests/conftest.py` to load this as a pytest plugin.
46+
The test file has dummy objects for the Playwright request/response/page/route etc.
47+
The tests exercise the main code paths we need for the interceptor:
48+
49+
- A request but _not_ to the fake server URL should just be passed-through to an HTTP request
50+
- A request to the fake server URL should extract the path
51+
- If that path exists in the project, read the file and return it
52+
- If not, raise a value error
53+
54+
With that in place, we write a `fixtures.fake_page` fixture function.
55+
It asks `pytest` to inject the real `page`.
56+
It then installs the interceptor by calling a helper function.
57+
This helper function is what we actually write the fixture test for.
58+
59+
## Serve Up Examples
60+
61+
We aren't going to test by fetching examples from an HTTP server.
62+
But our Viewers will look at examples from the HTTP server we made in the previous step.
63+
Let's add that to `app.py` with another `Mount`, this time pointing `/examples` at `src/psc/examples`.
64+
Also, add `first.html` with some dummy text as an "example".
65+
66+
Before the implementation, we add `test_app.test_first_example` as a failing test.
67+
Then, once `app.py` is fixed, the test will pass.
68+
69+
## First Test
70+
71+
Our fixture is now in place, with a test that has good coverage.
72+
We have a dummy example in `first.html`.
73+
Let's write a test that uses Playwright and the interceptor.
74+
75+
We just added a `TestClient` test -- a kind of "shallow" test -- for `first.html`.
76+
In `test_app.py` we add `test_first_example_full` as a Playwright test.
77+
78+
When we first run it, we see `fixture 'fake_page' not found`.
79+
This is because `conftest.py` needs to load the `psc.fixtures`.
80+
With that line added, the tests pass.
81+
82+
## Shallow vs. Full Markers
83+
84+
These Playwright tests are SLOW.
85+
When we get a bunch of examples, it's going to be a pain.
86+
As such, we'll want to emphasize unit tests and the shallow `TestClient` tests.
87+
88+
To make this first-class, we'll add 3 pytest markers to the project: unit, shallow, and full.
89+
We do so in `pyproject.toml` along with the option to warn if someone uses an undefined customer marker.
90+
91+
With this in place, we add decorators such as `@pytest.mark.full` to our tests.
92+
Later, we can run `pytest -m "not full"` to skip the Playwright tests.
93+
94+
## Better `TestClient` Testing
95+
96+
In this step we also improved the `TestClient` tests which makes sure our Starlette app serves what we expect.
97+
Part of that means testing HTML, so we installed `beatifulsoup4` and made a fixture that lets us issue a request and get back `BeautifulSoup`.
98+
These have rich CSS selector locators, so very convenient to use in tests.
99+
This fixture also raise an exception if it doesn't get a `200` so you don't have to test that any more.
100+
101+
Just to be clear: `TestClient` tests are nice when you do *not* need to get into the JS/Pyodide side.
102+
They are fast and zero mystery.
103+
104+
Of course, we added tests for those fixtures.
105+
106+
## QA
107+
108+
Cleaned up everything for pre-commit, mypy, nox, etc.
109+
Coverage is still 100%.
110+
111+
Along the way, Typeguard got mad at the introduction of the marker.
112+
I skipped investigation and just disabled Typeguard from the noxfile for now.

noxfile.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ def mypy(session: Session) -> None:
161161
"jinja2",
162162
"markdown-it-py",
163163
"python-frontmatter",
164+
"requests",
165+
"types-requests",
164166
)
165167
session.run("mypy", *args)
166168

poetry.lock

Lines changed: 28 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ beautifulsoup4 = "^4.11.1"
5656
html5lib = "^1.1"
5757
types-beautifulsoup4 = "^4.11.4"
5858
Jinja2 = "^3.1.2"
59+
types-requests = "^2.28.10"
5960

6061
[tool.poetry.scripts]
6162
psc = "psc.__main__:main"

src/psc/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
11
"""PyScript Collective."""
2+
3+
from pathlib import Path
4+
5+
6+
# Paths that can be referenced anywhere and get the right target.
7+
8+
SRC = Path(__file__).parent
9+
STATIC = SRC / "static"

src/psc/app.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
"""Provide a web server to browse the examples."""
2-
from pathlib import Path
32

43
from starlette.applications import Starlette
54
from starlette.requests import Request
@@ -8,8 +7,7 @@
87
from starlette.routing import Route
98
from starlette.staticfiles import StaticFiles
109

11-
12-
HERE = Path(__file__).parent
10+
from . import STATIC
1311

1412

1513
def index_page(request: Request) -> HTMLResponse:
@@ -19,7 +17,7 @@ def index_page(request: Request) -> HTMLResponse:
1917

2018
routes = [
2119
Route("/", index_page),
22-
Mount("/static", StaticFiles(directory=HERE / "static")),
20+
Mount("/static", StaticFiles(directory=STATIC)),
2321
]
2422

2523
app = Starlette(debug=True, routes=routes)

src/psc/fixtures.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Automate some testing."""
2+
from __future__ import annotations
3+
4+
from dataclasses import dataclass
5+
from dataclasses import field
6+
from mimetypes import guess_type
7+
from typing import Callable
8+
from urllib.parse import urlparse
9+
10+
import pytest
11+
from bs4 import BeautifulSoup
12+
from playwright.sync_api import Page
13+
from playwright.sync_api import Route
14+
from requests.models import Response
15+
from starlette.testclient import TestClient
16+
17+
from psc import SRC
18+
from psc.app import app
19+
20+
21+
@dataclass
22+
class MockTestClient:
23+
"""Pretend to be Starlette ``TestClient``."""
24+
25+
test_status_code: int = 200
26+
test_url: str | None = None
27+
test_content: bytes | None = None
28+
29+
def get(self, url: str) -> Response:
30+
"""Fake the TestClient response getting."""
31+
self.test_url = url
32+
response = Response()
33+
if self.test_url == "/broken":
34+
# This is a flag to test base_page exception raising
35+
response.status_code = 404
36+
else:
37+
response.status_code = self.test_status_code
38+
response._content = (
39+
self.test_content if self.test_content is not None else b"Test Result"
40+
)
41+
return response
42+
43+
44+
PageT = Callable[..., BeautifulSoup]
45+
46+
47+
@pytest.fixture
48+
def test_client() -> TestClient:
49+
"""Get a TestClient for the app."""
50+
return TestClient(app)
51+
52+
53+
def _base_page(client: TestClient | MockTestClient) -> PageT:
54+
"""Automate ``TestClient`` to return BeautifulSoup.
55+
56+
Along the way, default to raising an exception if the status
57+
code isn't 200.
58+
"""
59+
# Allow passing in a fake TestClient, for testing this fixture.
60+
61+
def _page(url: str, *, enforce_status: bool = True) -> BeautifulSoup:
62+
"""Callable that retrieves and returns soup."""
63+
response = client.get(url)
64+
if enforce_status and response.status_code != 200:
65+
raise ValueError(
66+
f"Request to {url} resulted in status code {response.status_code}"
67+
)
68+
return BeautifulSoup(response.text, "html5lib")
69+
70+
return _page
71+
72+
73+
def mocked_client_page() -> PageT:
74+
"""Get a fake test client, to allow testing the logic in base_page."""
75+
client = MockTestClient()
76+
return _base_page(client)
77+
78+
79+
@pytest.fixture()
80+
def client_page(test_client: TestClient) -> PageT:
81+
"""Main fixture for getting BeautifulSoup via TestClient."""
82+
return _base_page(test_client)
83+
84+
85+
@dataclass
86+
class DummyResponse:
87+
"""Fake the Playwright ``Response`` class."""
88+
89+
dummy_text: str = ""
90+
headers: dict[str, object] = field(
91+
default_factory=lambda: {"Content-Type": "text/html"}
92+
)
93+
status: int | None = None
94+
95+
def text(self) -> str:
96+
"""Fake the text method."""
97+
return self.dummy_text
98+
99+
def body(self) -> bytes:
100+
"""Fake the text method."""
101+
return bytes(self.dummy_text, "utf-8")
102+
103+
104+
@dataclass
105+
class DummyRequest:
106+
"""Fake the Playwright ``Request`` class."""
107+
108+
url: str
109+
110+
@staticmethod
111+
def fetch(request: DummyRequest) -> DummyResponse:
112+
"""Fake the fetch method."""
113+
return DummyResponse(dummy_text="URL Returned Text")
114+
115+
116+
@dataclass
117+
class DummyRoute:
118+
"""Fake the Playwright ``Route`` class."""
119+
120+
request: DummyRequest
121+
body: bytes | None = None
122+
status: str | None = None
123+
headers: dict[str, object] | None = None
124+
125+
def fulfill(self, body: bytes, headers: dict[str, object], status: int) -> None:
126+
"""Stub the Playwright ``route.fulfill`` method."""
127+
self.body = body
128+
self.headers = headers
129+
self.status = str(status)
130+
131+
132+
@dataclass
133+
class DummyPage:
134+
"""Fake the Playwright ``Page`` class."""
135+
136+
request: DummyRequest
137+
138+
139+
def route_handler(page: Page, route: Route) -> None:
140+
"""Called from the interceptor to get the data off disk."""
141+
this_url = urlparse(route.request.url)
142+
this_path = this_url.path[1:]
143+
is_fake = this_url.hostname == "fake"
144+
headers = dict()
145+
if is_fake:
146+
# We should read something from the filesystem
147+
this_fs_path = SRC / this_path
148+
if this_fs_path.exists():
149+
status = 200
150+
mime_type = guess_type(this_fs_path)[0]
151+
if mime_type:
152+
headers = {"Content-Type": mime_type}
153+
body = this_fs_path.read_bytes()
154+
else:
155+
status = 404
156+
body = b""
157+
else:
158+
# This is to a non-fake server. Only for cases where the
159+
# local HTML asked for something out in the big wide world.
160+
response = page.request.fetch(route.request)
161+
status = response.status
162+
body = response.body()
163+
headers = response.headers
164+
165+
route.fulfill(body=body, headers=headers, status=status)
166+
167+
168+
@pytest.fixture
169+
def fake_page(page: Page) -> Page: # pragma: no cover
170+
"""On the fake server, intercept and return from fs."""
171+
172+
def _route_handler(route: Route) -> None:
173+
"""Instead of doing this inline, call to a helper for easier testing."""
174+
route_handler(page, route)
175+
176+
# Use Playwright's route method to intercept any URLs pointed at the
177+
# fake server and run through the interceptor instead.
178+
page.route("**", _route_handler)
179+
180+
# Don't spend 30 seconds on timeout
181+
page.set_default_timeout(4000)
182+
return page

0 commit comments

Comments
 (0)