惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

B
Blog
B
Blog RSS Feed
小众软件
小众软件
博客园_首页
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
大猫的无限游戏
大猫的无限游戏
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
博客园 - 聂微东
WordPress大学
WordPress大学
月光博客
月光博客
S
SegmentFault 最新的问题
Engineering at Meta
Engineering at Meta
量子位
V
Visual Studio Blog
罗磊的独立博客
Last Week in AI
Last Week in AI
The Cloudflare Blog
H
Help Net Security
J
Java Code Geeks
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Microsoft Azure Blog
Microsoft Azure Blog
The GitHub Blog
The GitHub Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
美团技术团队

alexwlchan

Abusing ID3 chapters to turn videos into glanceable podcasts Why can’t you combine .tar.gz files with cat? How Tailscale helped find the SQLite WAL-Reset bug Preventing line breaks in <code> elements Fixing a bug with byte order marks A Git hook to prevent committing directly to main Describing all my photos I don’t want to repeat repeat myself Rebuilding the computer room What can wonky APIs tell us about the web? Using the Screen Capture API to record a browser window Using Pytester to test my Playwright fixtures Rendering a chat thread in CSS and JavaScript Waiting for website changes in the browser Watching for file changes on macOS Building a basic cache with SQLite HTTP GET requests with the Python standard library Auditing my local Python packages Quietly quantum-resistant blogging Creating a personalised bin calendar Monki Gras 2026 “Prepping Craft” The selfish case for public libraries Dreaming of a ten-year computer Gumdrop, a silly app for messing with my webcam The bare minimum for syncing Git repos Creating Caddyfiles with Cog Swapping gems for tiles Parody posters for made-up movies The Good, the Bad, and the Gutters Using perceptual distance to create better headers
Using Playwright to test my static sites
2026-05-02 · via alexwlchan

I build a lot of static websites – including this site and all of my local media archives – and I want to test them. Most of my pages are static HTML and I can write automated tests that analyse the HTML, but for more complex sites I have JavaScript that runs in the browser and modifies the page. The only way to test that functionality is to open the page in a browser, click around, and see what happens. I could do that manually, but it quickly gets tedious.

To automate this process, I’ve been using a testing framework called Playwright, which is designed for this sort of end-to-end testing. It’s a tool that allows you to programatically control a web browser, look at the contents of a page, and make assertions about what’s there. Playwright can be used to test or script any kind of web app; I’m using it for static sites because those are the only web apps I have.

Playwright is available as a CLI, or there are libraries to use it with TypeScript, Python, .NET, and Java. All my other tests are written in Python, so that’s what I’m using.

Writing a basic test with Playwright

To set up Playwright with Python, you install the playwright library using pip or uv, then install a web browser for Playwright to control. (You can’t use Playwright with the browser you use day-to-day; you need special binaries with control hooks.)

I use Safari as my main browser, and Safari is based on WebKit, so let’s install that:

$ uv pip install playwright
$ python3 -m playwright install webkit

Then we can start writing tests. Here’s a basic test in which Playwright launches WebKit, opens example.com, and checks the text Example domain is visible on the page:

from playwright.sync_api import expect, sync_playwright


def test_basic_playwright() -> None:
    """
    Run a basic test with Playwright: load a web page and check it
    contains the expected text.
    """
    with sync_playwright() as p:
        browser = p.webkit.launch()

        page = browser.new_page()
        page.goto("https://example.com/")
        expect(page.get_by_text("Example domain")).to_be_visible()

        browser.close()

For a larger app, you might run your tests with multiple browsers to check compatibility – Playwright supports lots of other browsers, including Chromium, Firefox, and Mobile Safari in emulation. I’m just testing private sites where I’m the only user, so a single browser is fine.

This test passes in about half a second on my computer. That’s fine for a single test, but it would add up if I had lots of tests, each starting and stopping the browser every time. It would be nice to make that process faster, and to reduce some of the boilerplate as well.

A pair of Playwright fixtures

To reduce the repetition and reuse the browser instance, I have a couple of pytest fixtures to simplify things.

The first is a session-scoped fixture that starts the browser at the start of the test run, and closes it when I’m done:

from collections.abc import Iterator

from playwright.sync_api import Browser, sync_playwright
import pytest


@pytest.fixture(scope="session")
def browser() -> Iterator[Browser]:
    """
    Launch an instance of WebKit to interact with in tests.
    """
    with sync_playwright() as p:
        webkit = p.webkit.launch()
        yield webkit
        webkit.close()

Because this is a session-scoped fixture, it only runs once per test suite – that means the browser is only started once, then the same instance is reused for all the tests. This makes a large test suite significantly faster.

My other fixture is a bit more complicated – it gives you a page to interact with, and at the end of the test it checks the page didn’t have any warnings or errors. This is a strict approach, which helps me spot errors in areas I wasn’t explicitly testing. Here’s the fixture:

from collections.abc import Iterator

from playwright.sync_api import Browser, Page
import pytest


@pytest.fixture(scope="function")
def page(browser: Browser) -> Iterator[Page]:
    """
    Open a new page in the browser.
    
    If there are any errors or warnings when loading the page, the test
    will fail when this fixture is cleaned up.
    """
    p = browser.new_page()

    # Capture anything that gets logged to the console.
    console_messages = []
    p.on("console", lambda msg: console_messages.append(msg))

    # Capture any page errors
    page_errors = []
    p.on("pageerror", lambda err: page_errors.append(err))

    yield p

    # Check there weren't any console errors logged to the page.
    console_errors = [
        msg.text
        for msg in console_messages
        if msg.type == "error" or msg.type == "warning"
    ]
    assert console_errors == []

    # Check there weren't any page errors
    assert page_errors == []

These two fixtures allow for tighter, faster tests, focusing on what the test is actually checking. Here’s the example test, rewritten to use this fixture:

def test_playwright_with_fixture(page: Page) -> None:
    """
    Run a test using my Playwright fixture: load a web page, check it
    contains the expected test, and check it loads without errors.
    """
    page.goto("https://example.com/")
    expect(page.get_by_text("Example domain")).to_be_visible()

I use the page fixture for most tests, where I want to spot any unexpected errors or warnings. If I’m testing error handling specifically, I use the browser fixture and create a new page which isn’t treated as strictly.

Getting file:/// URIs for Playwright

Normally Playwright is used with http: and https: URLs, but my static websites are stored as HTML files on my local disk, and I often open them with file: URLs.

I could spin up a web server in my tests, but that’s extra overhead and might affect the results – there are subtle differences between how browsers handle pages opened with file: vs http:.

To convert file paths to file: URLs, I use the pathname2url function from the urllib.request module. I combine this with os.path.abspath to get a full URL I can pass to Playwright:

>>> from os.path import abspath
>>> from urllib.request import pathname2url
>>> path = "index.html"
>>> pathname2url(abspath(path), add_scheme=True)
'file:///Users/alexwlchan/repos/alexwlchan.net/index.html'

Assertions in Playwright

Playwright has a different set of assertion helpers to regular Python tests, and it takes some getting used to – I still have to consult the documentation when I write new tests.

Here are examples of assertions I’ve written using Playwright:

  • Testing that a redirect is working:

    resp = page.goto("https://alexwlchan.net/projects/chives/files/doesnotexist.txt")
    
    assert resp is not None
    assert resp.status == 200
    assert resp.url == "https://alexwlchan.net/projects/chives/files/?missing=doesnotexist.txt"
  • Test that text does or does not appear on a page:

    from playwright.sync_api import expect
    
    page.goto("https://www.example.com")
    
    expect(page.get_by_text("Example Domain")).to_be_visible()
    expect(page.get_by_text("Alex Chan")).not_to_be_visible()

    or:

    assert "Example Domain" in page.content()
    assert "Alex Chan" not in page.content()
  • Locate an element with a CSS selector, and check it does or doesn’t appear on a page:

    page.goto("https://www.example.com")
    
    expect(page.locator("h1")).to_be_visible()
    expect(page.locator("h2.title")).not_to_be_visible()
  • Locate an element, and make assertions about its attributes:

    page.goto("https://www.example.com")
    
    href = page.locator("a").first.get_attribute("href")
    assert href == "https://iana.org/domains/example"
  • Locate an element, and make assertions about the text it contains:

    page.goto("https://www.example.com")
    
    assert page.locator("a").inner_text() == "Learn more"
  • Check that an element with particular inner text is visible on the page:

    page.goto("https://www.example.com/")
    
    expect(page.locator('//h1[text()="Example Domain"]')).to_be_visible()
  • Locate an element immediately following a different element. I’ve used this a couple of times when I have tables or definition lists with a label in one element, and a value in another:

    dt_locator = page.locator('//dt[text()="Profile page:"]')
    next_dd = dt_locator.locator("xpath=following-sibling::*")
    
    assert (
        next_dd.inner_html().strip()
        == '<a href="https://www.flickr.com/photos/nasahqphoto/">NASA HQ PHOTO</a>'
    )
  • Check the number of matching elements on a page; for example, the length of a list:

    page.goto("https://alexwlchan.net/articles/")
    
    assert page.locator("#list_of_posts li").count() >= 10
  • Check the title of the page:

    page.goto("https://www.example.com/")
    
    assert page.title() == "Example Domain"
  • Check the behaviour of the page when JavaScript is disabled:

    context = browser.new_context(java_script_enabled=False)
    page = context.new_page()
    
    expect(page.locator("noscript .error")).to_be_visible()
    
    noscript_elem = page.locator("noscript .error")
    assert noscript_elem.inner_text() == "You must enable JavaScript to use this page."

This is just a fraction of what Playwright can do; it can be used to build far more complicated tests that walk through a web app and test multi-step user flows. I’m only using it to make assertions about snippets of JavaScript, but it’s still useful.

For a long time, I told myself that my static sites were simple enough not to need testing, but that didn’t prevent bugs from slipping in, and it limited what I could build. Now I can write proper tests for my sites, I can be more confident I haven’t broken anything, I can experiment faster, and I can try more ambitious ideas.