MoreRSS

site iconAlex WlchanModify

I‘m a software developer, writer, and hand crafter from the UK. I’m queer and trans.
Please copy the RSS to your reader, or quickly subscribe to:

Inoreader Feedly Follow Feedbin Local Reader

Rss preview of Blog of Alex Wlchan

Describing all my photos

2026-07-06 02:01:19

I take a lot of photos, but I only keep a fraction.

Every week, I go through my camera roll and sort my photos into three buckets: keep, delete, or “needs action” (pictures like paperwork or screenshots that I need to do something with, but don’t want to keep indefinitely). This lets me filter out repetitive, blurry, or uninteresting shots.

A few years ago, I reviewed my entire 30,000 item library and I deleted almost a fifth of my photos, but I haven’t missed any of those photos. If anything, browsing my photo library has been nicer, because the average quality went up. My photo library only contains my best pictures, not just everything I’ve ever taken.

Recently I added a new step: I now write a description for every photo I’m keeping. Typically the description is a sentence or two of context, like what I was doing when I took the photo, or how I felt when I did. A lot of this context isn’t obvious from the image, and over time I forget those details. Sometimes I can piece it together later from my calendar or journal entries, but other times I just have a mystery image and I can’t remember why it was important.

This slows down the review process, but it only takes a minute or so per photo, and each description makes the photo library a more useful visual record of my life. When I revisit those photos, the memory is stronger because I recall more of the context.

This change has forced me to be more thoughtful about which photos I keep. If I can’t write even a sentence or two about what this photo means to me, is it worth keeping? Will I ever look back on it with fondness?

Even this minimal level of descriptive text is quite unusual. When I was working on the Data Lifeboat project, a lot of the early designs were built around rich uploader-supplied metadata, like title and description. These designs fell short in practice, because most people leave them empty. The vast majority of Flickr pictures don’t have a title or description, and those are photos people want to share! (The auto-generated filename from the camera doesn’t count.)

I’ve already written descriptions for about 5% of my photo library, and that number will gradually ratchet up. I’m writing a description for every new photo, and I’m slowly working backwards while the memories are still fresh.

I really recommend writing descriptions for some of your photos, whether that’s in a social media post, your digital photo library, or a printed album. A sentence of context can really anchor a memory.

If you open your own camera roll and look at the last photo you took, do you remember how you felt in that moment – or is it already starting to slip away?

Building it into Blink

Initially I was adding descriptions using Apple’s Photos app, but I really wanted to include them in Blink – a tiny Mac app I wrote a few years ago to review my photos. Blink lets me sort my library entirely using keyboard shortcuts, and I wanted to write descriptions without breaking that fast, mouse-free flow.

The Blink interface is intentionally sparse: there’s a horizontal thumbnail strip at the top, then the focused photo takes up most of the window. I use arrow keys to switch between photos, and I press 1/2/3 to categorise them (keep/delete/needs action).

To add descriptions, I added an overlay at the bottom of the window. It shows the current caption, or a placeholder if I haven’t set one yet:

Photo reviewing app. There's a horizontal row of thumbnails across the top of the window, then a focused photo filling most of the window. At the bottom of the photo is a black overlay 'Add a caption…'

If I press space, the overlay switches to a text field where I can enter a new description, or edit the existing description:

The same app, but now the caption has been replaced with a text field where I’ve typed 'Picking thread colours for my next cross-stitch ‘The Stitches of Titan’.

When I press ⌘+Enter, the description is saved to the “caption” field in my photo library. I never have to take my hands off the keyboard, so this doesn’t introduce much friction to my workflow.

Adding this to Blink was a fun exercise in revisiting old code.

Returning to an untouched app

Blink is written as a Swift app using SwiftUI, and this was my first time working with Blink and SwiftUI since September 2023. I’ve written short command-line scripts in Swift in the interim, but not a GUI app.

It took a while to get comfortable working in the Blink codebase again. As always, I wish I’d left more comments and documentation when I wrote this code. Names like FocusedImage or AssetHelpers clearly meant something three years ago, but I’d forgotten the meaning and had to relearn it.

Comments are something I can always get better at. I was fortunate to start my career at a company that had a very verbose commenting style, so I learnt some good habits early, but then I cycled through a few jobs where the standards were more lax, and my comments suffered. I was in the middle of that period in 2023, and the Blink code reflects that. My current workplace has a much stronger commenting culture, and I can feel that muscle strengthening again.

If you read my patch to add captions, you’ll notice it has much more commentary!

Intensely personal software

I consider Blink to be a tremendous success. I’ve used it to review thousands of photos since 2023, transforming my photo library from a digital dumping ground to a curated collection of highlights.

I initially wrote Blink as an experiment to learn SwiftUI and Mac development. I’d have been happy if I never used the code, but instead, it’s become an app I use every week.

As far as I know, nobody else has ever used it. The source code lives in a public repo, but it’s for educational interest rather than distribution. (The code is so tied to my machine that it crashes if pointed at anybody else’s photo library.)

I’ve considered cleaning it up, packaging it, and turning it into a “proper” app that other people could use, but that’s a lot of work I don’t find exciting, and it’s unclear if there’s any interest among people who aren’t me.

For now, Blink will continue to have exactly one user. That user doesn’t need an onboarding flow, a settings screen, or cloud syncing. They just want a keyboard-driven app to curate their photos, and write down the context before the memories disappear. For that user, it’s a five-star app.

[If the formatting of this post looks odd in your feed reader, visit the original article]

I don’t want to repeat repeat myself

2026-07-03 23:52:05

Yesterday at work, a customer spotted a typo in our UI: “you can use the use the Tailscale CLI”. After the typo was fixed, I wanted to find other cases of accidentally repeated words or phrases. I used two regular expressions to search every codebase for unnecessary repetition.

The first regex finds repeated words:

\b([A-Za-z]+) \1\b

Backfill product data from from Stripe
Learn more about about inviting users
Argument must be be one of host name, IP set name, IP prefix, or IP

There’s a capturing group for a single word made up of letters ([A-Za-z]+), a space, then a backreference to the group. I used [A-Za-z] rather than the word metacharacter \w because I didn’t want to include numbers, which would dramatically increase the number of matches in a codebase. This skips repetitions which include accented characters, but that’s fine because those are rare in my writing.

That expression is surrounded by word boundary assertions \b, which check that I’m at the start/end of a word – this avoids finding repeated character sequences within longer words, like “with the reason”.

The second regex finds repeated phrases:

\b([A-Za-z]+ [A-Za-z]+) \1\b

Follow the steps in the in the "How to" section
Log in to in to your account
To configure federated identities federated identities using the Go SDK

I’ve changed the capturing group, so now it looks for two words separated by a space.

Sometimes repetition is useful, like when I really really want to emphasise a point, but often it’s just a typo. Cleaning up these mistakes has been a fun Friday cleanup task.

[If the formatting of this post looks odd in your feed reader, visit the original article]

Rebuilding the computer room

2026-06-29 16:28:13

One of my distinct memories of childhood is the “computer room”. When I was young, computers weren’t a ubiquitous feature of our lives; they were bulky appliances with a fixed location, and you had to go somewhere to use them.

At home, it was my parents’ study. The first computer I remember using is their iMac G3, which is about as portable as a small tree.

At my grandparents’ house, it was their office in the corner of the house. Their desktop PC was far from the kitchen, bedrooms, and living room, sandwiched between the coat rack and the washing machine.

At school, it was classrooms with computers shoved in haphazardly, maximising the number of screens above all else. Outside the IT department, computers had their own desks. If a teacher wanted to use the computer in their classroom, they’d get up from their regular desk and move to the computer chair.

Even in buildings which didn’t have a dedicated room, computers still had a fixed location. If you wanted to use a computer, you had to go to it – whereas today, computers follow us around.

The laptop was the first device to test the walls of the computer room. Early laptops were limited compared to desktop computers – they were slower, battery-constrained, satellite devices to your main machine. If you wanted files on your desktop to be available on your laptop, you had to copy them manually using a floppy disk or a flash drive. You could use them to work from the sofa or the kitchen table, but they were so compromised that it was rarely your first choice.

Over time, laptops got better. They got faster processors, better battery life, and wireless networking. Laptops became more convenient for more types of task, and soon they were good enough to be your primary computing device.

Laptops promised a previously unknown level of computing freedom, the idea that you could now work from anywhere – a beach, a coffee shop, a couch. We welcomed the change, because the physical constraints of a desktop computer suddenly felt like an unnecessary friction.

Yet, some physical restrictions remained – laptops were still heavy and bulky objects. They were something you had to carry in bags, and not something you’d take out casually. There were lots of places where you’d never see or use a laptop.

Smartphones followed a similar trajectory to laptops. Early models were compromised, limited, and companion devices to “real” computers. I still remember what a big deal it was when Apple announced that iOS 5 would allow you to set up an iPhone without plugging it into a computer first – something we take for granted today. Over time, smartphones evolved in capability and performance, and for many people a smartphone is now their primary computing device.

The smartphone could go places the laptop never could – pockets, bathrooms, bedrooms. The compact size meant they could be carried anywhere, and in previously computer-free spaces it became easy to glance down at your phone. Computers had well and truly escaped the boundaries of the “computer room”, and could go with us practically anywhere.

The miniaturisation required for smartphones allowed tech companies to take this even further, and is now used in wearable devices like watches, glasses, and pins, allowing computers to maintain a permanent physical presence in our lives.

The cost of convenience

Unlike many trends in consumer technology, the shift towards portable computing wasn’t forced upon us by tech companies; it was something we actively welcomed. We fell in love with the convenience. The ability to work from a coffee shop, watch TV in bed, or answer messages on. a packed commuter train made computers more useful.

The smartphone tooked this further, pairing portability with consolidation. A single multipurpose device could fulfil the functions of a dozen single-use gadgets. The logic seemed sound: why carry a separate iPod, camera, dictaphone and notebook when one pocket-sized device could do all that, and more?

I don’t want to underplay these benefits – these changes have made computing more affordable, accessible, and useful. It would be disingenuous to argue that things were better when I was younger, or to suggest that we all go back to desktop towers. But this trend isn’t all good, and recently I’ve been more aware of the downsides.

Making computers more portable didn’t just make it easier for us to get to digital services; it made it easier for digital services to get to us.

Mediated by the smartphone, apps and websites now have a permanent, physical presence in our lives. A notification can reach us at any time, in any place – a phantom tap on the shoulder, distracting us from the physical world. These surfaces have become weaponised, and enormous resources are spent on designing addictive environments to maximise the time we spend within them.

I see the effects of this in my own behaviour. I check my phone every few minutes, not because I’m expecting a message, but because I’m waiting for that next dopamine hit. It’s become a reflex, a digital itch I’ve been trained to scratch, whether or not there’s anything worth seeing. When nothing arrives, I fill the silence with scrolling. I cycle repeatedly through the same few sites, looking for something new, glancing at content for seconds before moving on.

We’ve never found ourselves in a more aggressive information environment, and the physical proximity of our devices makes it hard to escape. This assault on our attention is not something our brains have evolved to cope with.

I don’t want to deny the benefits of portable computers, or the freedom of unshackling ourselves from a desk – but increasingly I find myself wishing for the walls of my childhood computer room. I long for the boundaries it once enforced, and the physical restrictions it put on the competition for my attention.

Rebuilding the walls

Over the last year, I’ve been trying to re-introduce those boundaries in my own life.

I’ve always been very strict about what apps can send me notifications – only things that really demand my attention. That includes messages from people I really care about, on-call pages from work, and extreme weather warnings. Breaking news, chatty group chats, and in-app marketing don’t make the cut.

I wore an Apple Watch for a while, primarily for the health features, but even with my limited notifications, it still became a distraction. Too many quiet moments with my partner were disturbed by a gentle buzz from my wrist – tiny demands for my attention that just weren’t worth the interruption. I’m currently trying a screenless fitness tracker, which sits silently on my wrist and never demands my attention.

My primary computer is now a desktop with a large monitor, and I’m fortunate to have a room I can use as an office. I also have a laptop, but I only use it when I leave the house – otherwise, it lives in a drawer under my desk.

My phone lives on a charging stand in my office, and I leave it there when I sleep. I also leave it there when I’m around the house, if I’m not waiting for something immediate like a phone call. I’ve actually taken to wearing skirts and dresses that don’t have pockets while I’m at home, to remind me to leave my phone at my desk.

There’s a growing trend among Gen Z to resist the all-in-one allure of the smartphone, and go back to dedicated devices. They’re swaping their smartphones for single-purpose tools like point-and-shoto cameras or dedicated MP3 players, devices that lack the ability to receive notifications. I haven’t gone that far yet, but it’s something I’m considering.

My computers are no longer something that follow me around – they’re confined to one room, and they can only get my attention when I’m in that room and working at my desk. The rest of the time, they can ping as loudly as they like, but I won’t hear it.

Since I started making these changes, I’ve felt calmer and more relaxed, especially when I’m at home. I can focus on the things that actually deserve my attention – cooking a meal, reading a book, chatting with my friends, playing on the sofa. I’m less worried about the distraction of my digital devices, or the effect it has on my life.

The computer room disappeared because we wanted more convenience, more ease, and less friction in our computing lives. But after a year of rebuilding those walls, I’m reminded that friction isn’t always a bad thing – it slows me down, but it also slows down the companies competing for my attention.

I don’t mind the extra steps it takes to reach my computer; I’ve become grateful for the distance. When I walk into my office and sit at my desk, I’m choosing to be there. When I walk away, I have a door I can close, and a life outside the room that the digital world is no longer allowed to reach.

[If the formatting of this post looks odd in your feed reader, visit the original article]

What can wonky APIs tell us about the web?

2026-06-21 22:29:21

A few days ago, Misty posted something that caught my eye:

Finding myself asking if there's ever been a wonkier official browser API than canPlayType

The HTMLMediaElement.canPlayType API tells you how likely it is that a browser can play media with a given MIME type, but the response is unusual. The word “likely” is important here, because it’s not a simple yes/no answer. The possible responses are:

  • "" – no, the browser can’t play the media
  • "probably" – the browser can probably play the media
  • "maybe" – there isn’t enough information to determine if the media is playable.

A ternary, probabilistic response is already a bit weird; the return values double down on the weirdness. A clearer set of return values would be "no", "probably" and "unknown".

But when thinking of wonky web APIs, my mind went somewhere else: to History.pushState() and replaceState(). These APIs are for manipulating your browser history, and take an unused parameter which you have to pass but do absolutely nothing.

Here’s the description of pushState from MDN (emphasis mine):

The pushState() method of the History interface adds an entry to the browser’s session history stack.

Syntax:

    pushState(state, unused)
    pushState(state, unused, url)

Parameters:

  • state – The state object is a JavaScript object which is associated with the new history entry created by pushState(). […]
  • unusedThis parameter exists for historical reasons, and cannot be omitted; passing an empty string is safe against future changes to the method.
  • url – The new history entry’s URL.

This begs the question: what historical reasons? Why does an API supported by every major browser have a parameter that nobody uses?

The History API was designed for use with single-page applications (SPAs) – sites that only load a single page, then use JavaScript to update the contents of a page, rather than loading a new page every time something changes. Using SPAs can make a site faster, because they only have to load the part of a page that’s changed, but they also break the behaviour of the browser’s “back” and “forward” buttons.

From the user’s point of view, they click links and the page changes, so if they click the “back” button, they expect to go back to their previous state.

But the browser only records a single history event, when the user first loads the SPA – all the in-page updates using JavaScript don’t register as new pages. When the user clicks the “back” button, the browser will take them to whatever page they were looking at before they opened the SPA.

Using pushState() and replaceState() allows an app to create synthetic history entries, so the “back” and “forward” buttons can step the user through the pages they’ve seen within the SPA.

The pushState() API first appeared in a draft HTML5 spec in January 2008, with three parameters:

  • The state object would be attached to the history event, and if the user clicked the “back” or “forward” buttons, your app would get a popstate event with the state value associated with the history event they’d selected.
  • The title parameter allowed apps to set a title for the entry saved in the browser’s session history, which could be different to the title shown in the browser window.
  • The url parameter allowed apps to set a URL for the history entry, which could be different to the URL shown in the browser window. If omitted, the browser would use the current URL.

The title parameter was always “advisory”, and in practice most browsers completely ignored the parameter, to avoid the confusion of mismatched titles in the browser UI and session history.

It soon became clear that the title parameter was pointless, but it was already too late to change. Lots of sites were built as single-page applications and already using the new pushState and replaceState APIs, and breaking those sites was unacceptable.

The argument could be neither removed nor made optional. If you removed it, you’d break sites that used the three-parameter pushState(state, title, url). If you made it optional, its position in the middle of the signature would leave browsers unable to distinguish between pushState(state, url) and pushState(state, title).

Instead, the spec was updated to rename the parameter to unused and clarify it has no effect.

This wonky API reflects the challenge of designing for the web: it’s difficult to design APIs without seeing how they’re used on real world websites, but then it’s too late to make changes in response to feedback. The web goes to incredible lengths to preserve backward compatibility, and it’s the ultimate form of “anything described as a prototype will get shipped in production”.

The longevity of vanilla web technology is why I keep using static websites for my media archives – more than anything else in my career in tech, web technology is what lasts.

[If the formatting of this post looks odd in your feed reader, visit the original article]

Using the Screen Capture API to record a browser window

2026-06-07 16:03:45

I was on the Lego website recently, and I enjoyed their animation on their age picker – rather than a plain text field, the numbers are made of Lego bricks that animate into view, accompanied by the sound of bricks snapping together:

Recorded from identity.lego.com/en-GB/age, 6 June 2026. Lego was founded in 1932 by Ole Kirk Christiansen. He purchased the first plastic moulding machine in 1947, and patented the stud-and-tube coupling system in 1958.

I don’t know if this age picker is visible everywhere, or if it’s specifically to deal with online age verification laws in the UK; whatever the purpose, I thought it was cute.

I wanted to save a copy of it, and because it has animation and audio, a static screenshot wouldn’t be enough. It took me a couple of attempts to record it as a video, and in doing so I learnt several new web APIs.

Rejected option #1: use QuickTime screen recording

On macOS, QuickTime Player can make a video recording of your screen. You can select the entire screen, a single window, or a specific area of the screen. I’ve used this a couple of times for bug reports and quick videos, and it works pretty well.

Unfortunately, QuickTime Player isn’t able to record the audio, so it only creates a silent version of the animation. The sounds of bricks snapping together is half the fun!

A quick search suggests there are ways to record screen audio in QuickTime Player, but they all require installing third-party plugins to make my Mac’s audio available as a pseudo-microphone. I’m very picky about what I install, and making a fun video doesn’t justify a new app.

Rejected option #2: use Playwright video recording

One tool I have installed already is Playwright, a framework for automating browsers. I use it to take screenshots and test my websites, and it turns out you can also use it to record videos.

To record a video, you create a new browser context which sets a video directory, interact with the page as normal, then close the context to save the video. Here’s an example using Playwright’s Python library to open my list of articles, then scrolls three times:

from playwright.sync_api import sync_playwright
import time

with sync_playwright() as p:
    browser = p.chromium.launch()
    
    # Create a new context that sets a video directory
    context = browser.new_context(record_video_dir="videos/")

    # Open my list of articles, then scroll down the page three times
    page = context.new_page()
    page.goto("https://alexwlchan.net/articles/")
    
    for _ in range(3):
        time.sleep(0.5)
        page.mouse.wheel(0, 250)
        time.sleep(0.5)

    # Close the context, which causes the video to be saved
    context.close()

When you run this script, you get a video in the videos/ directory.

I can imagine this might be useful in a large test suite, especially in a complex multi-step test. When a test fails, you can watch a screen recording of the browser during the test, which could be more informative than a textual log. (Indeed, Playwright has a video=retain-on-failure option which only preserves videos created during failing tests, for precisely this use case.)

I ran into two problems with this approach: like QuickTime, you can’t record screen audio; and you can only record videos at 1× pixel density, which makes a very low-resolution and blurry-looking video on modern screens.

Option #3: use the web’s Screen Capture API

Once again I am reminded that modern web tech is amazing, and web browsers are incredibly capable.

There’s a Screen Capture API to record the screen. You can select a tab, a window, or the entire screen. The feature has limited browser support so I don’t think I’d use it in a big web app, but it’s fine for a one-off screen recording. (I wonder how browser-based video conference apps like Google Meet do screen sharing? Do they use this API, or do they use something with wider support?)

To record video, first we call getDisplayMedia() to get the contents of a tab as a MediaStream. Using the example from the MDN docs:

async function startCapture(displayMediaOptions) {
  let captureStream;

  try {
    captureStream =
      await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);
  } catch (err) {
    console.error(`Error: ${err}`);
  }
  return captureStream;
}

const displayMediaOptions = {
  // Only allow the user to select a single browser tab
  video: { displaySurface: 'browser' },
  
  // Include the audio from the tab
  audio: true,
  
  // Offer the current tab as the default capture source
  preferCurrentTab: true,
};

const stream = await startCapture(displayMediaOptions);

When you run this in the DevTools console, it triggers a permissions dialog to confirm you want to start recording the contents of the tab. The JavaScript is running on the current page, so it’s theoretically able to see the stream you’re creating. You have to confirm you’re willing to share the website with itself:

Permissions dialog in Chrome titled 'Allow identity.lego.com to see this tab?' Below the title is a preview of the tab and a toggle to allow tab audio.

If we didn’t set displaySurface: 'browser', this would offer other options like sharing an arbitrary window or the entire screen. On my Mac, that delegates to an OS-level interface for choosing what to share.

Next, we have to pass the output of the stream to a MediaRecorder:

const mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/mp4' });

To store the video data, we create an array, and append to it as we receive dataavailable events:

let videoChunks = [];

mediaRecorder.addEventListener("dataavailable", (ev) => {
  if (ev.data.size > 0) videoChunks.push(ev.data);
})

Now the MediaRecorder is set up, we call the start() method to start writing data to videoChunks. We click and scroll in the browser window to capture whatever it is we want to record. When we’re done, we call stop() to finish the recording:

mediaRecorder.start();

// Do stuff in the browser tab that we want to record

mediaRecorder.stop();

To extract the recorded video data, we can concatenate the video chunks with a Blob object, then use FileReader to output the result as a base64-encoded data URL:

function printDataURL(chunks, mimeType) {
  const blob = new Blob(chunks, { type: mimeType });
  const reader = new FileReader();
  reader.readAsDataURL(blob);
  reader.addEventListener("loadend", () => {
    console.log(reader.result);
  });
}

printDataURL(videoChunks, mediaRecorder.mimeType);
// data:video/mp4;codecs=avc1,opus;base64,AAAAJGZ0eX…

I copy this base64-encoded string out of my DevTools console, save it to an MP4 file, and voila, I have a recording of this Lego age picker – complete with animation and audio.

As I was writing this post, I realised there’s an even smoother method, that saves you copying and base64-decoding the data: URL. Rather than reading the blob using a FileReader, we can create a blob URL that points to the object, then construct and click an <a> tag that downloads the blob:

function downloadVideo(chunks, mimeType) {
  // Construct the blob
  const blob = new Blob(chunks, { type: mimeType });
  
  // Create a blob URL
  const url = URL.createObjectURL(blob);
  
  // Create an <a> tag that points to the blob
  const a = document.createElement("a");
  a.href = url;
  a.download = "recording.mp4";
  
  // Click the <a> tag
  a.click();
  
  // Wait a second for the download to complete, then release the blob URL
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

downloadVideo(videoChunks, mediaRecorder.mimeType);

When I run this code, the video gets downloaded as an MP4 file directly to my Downloads folder.

It’s worth noting that when you call MediaRecorder.stop(), it emits a final dataavailable event and then a stop event. If you’re doing this interactively in the DevTools console, the delay between you typing mediaRecorder.stop() and downloadVideo() is plenty for the final chunk to be written to videoChunks. If you’re doing it programatically, you should only download the video when you see the stop event.

To create the video at the top of the post, I wrapped everything in a Python script that used Playwright to run JavaScript on the page, so I’d get consistent timing for the key strokes. The recording isn’t perfect – in particular, there’s a subtle glitch in the appearance of the final “6” – but it’s plenty good enough for a quick video.

I’d also like to work out how this animation works, but that’s a question for another day.

[If the formatting of this post looks odd in your feed reader, visit the original article]

Using Pytester to test my Playwright fixtures

2026-06-05 16:14:37

A month ago, I wrote about my Playwright fixture for testing static websites in a browser. I’ve been copying that fixture from project-to-project, but recently I decided to add it to chives, the utility library I use for all my static websites (or tiny archives).

One of my rules for chives is that everything in it has to be tested – but how do you test a pytest fixture? Test code is just code, and it isn’t immune to bugs. Who tests the tests?

Enter Pytester, a tool designed for testing pytest plugins. Pytester allows you to run isolated test suites, make assertions about the outcomes, and verify the behaviour of custom fixtures. In your top-level test suite, you always want everything to be passing, but with Pytester you can write a mixture of passing and failing tests, and check the results are what you expect.

Pytester is disabled by default, so you first enable it in your top-level conftest.py file (the pytest configuration file where you configure plugins and fixtures):

# conftest.py
pytest_plugins = ["pytester"]

Here’s an example of using Pytester where we create a test suite with two tests and check that one passes, one fails:

from pytest import Pytester


def test_with_pytester(pytester: Pytester):
    """
    Run an isolated test suite with pytester.
    """
    # Make a temporary pytest test file
    pytester.makepyfile(
        """
        def test_arithmetic():
            assert 2 + 2 == 4
    
        def test_list_inclusion():
            assert "yellow" in ["red", "green", "blue"]
        """
    )

    # Run the isolated test suite with pytest
    result = pytester.runpytest()

    # Check that one test passed, one failed
    result.assert_outcomes(passed=1, failed=1)

I can imagine creating something similar with some complicated collection of nested functions, exec() and pytest.raises, but using Pytester is a cleaner interface than what I’d build.

Under the hood, Pytester creates a temporary directory, writes specified files into it, then runs a fresh pytest subprocess against it. It has helper functions for writing files, including Python files (makepyfile), a conftest.py file (makeconftest), and plain text files (maketxtfile).

When we’re testing a fixture, we can create a conftest.py file that imports that fixture, then reference it in the tests. Here’s a more complicated example, where we import one of my Playwright fixtures in my conftest.py, write an HTML file into the temporary directory, then use them both in the test:

from pytest import Pytester


def test_browser_fixture(pytester: Pytester):
    """
    Try testing the browser fixture with pytester.
    """
    # Make a conftest.py file
    pytester.makeconftest("""
        from chives.browser_fixtures import browser
    """)
    
    # Make an HTML file
    (pytester.path / "greeting.html").write_text("""
        <p>Hello world!</p>
    """)
    
    # Make a temporary pytest test file
    pytester.makepyfile(
        """
        from chives.browser_fixtures import file_uri
        from playwright.sync_api import Browser, expect
    
        def test_browser_fixture(browser: Browser) -> None:
            uri = file_uri("greeting.html")

            p = browser.new_page()
            p.goto(uri)
            expect(p.get_by_text("Hello world!")).to_be_visible()
        """
    )

    # Run the isolated test suite with pytest
    result = pytester.runpytest()

    # Check that one test passed
    result.assert_outcomes(passed=1)

This pattern is sufficient for many fixtures, but it doesn’t work for Playwright – if you run this test, the isolated test suite gives an error rather than a passing test. Playwright needs you to install a web browser to work (for example, playwright install webkit), and Pytester runs in a sufficiently isolated environment that Playwright can’t find the browsers you already have installed.

We could run the install command inside the temporary directory, but that would be slow and inefficient – it would be better if we could tell Playwright to look for the already-installed browsers elsewhere. If we set the PLAYWRIGHT_BROWSERS_PATH environment variable inside our isolated test suite, Playwright will look there for browsers.

First, we need to work out where browsers are installed – we could hard-code the location, or we could inspect the executable_path property property on a browser:

from pathlib import Path
from playwright.sync_api import sync_playwright
import pytest


@pytest.fixture(scope="session")
def playwright_browsers_path() -> str:
    """
    Return the cache directory where Playwright browsers are installed.
    """
    with sync_playwright() as p:
        # In my local builds, this returns a path like:
        #
        #    ~/Library/Caches/ms-playwright/webkit-2272/pw_run.sh
        #
        # Unwrap two levels to get to the `ms-playwright` folder.
        return str(Path(p.webkit.executable_path).parent.parent)

Then we need to set this as an environment variable inside the Pytester test suite. I couldn’t find an easy way to set an environment variable; the best approach I came up with was to modify os.environ inside the conftest.py file. (Perhaps we could access the MonkeyPatch object and set more environment variables, but using private attributes is icky.)

Here’s how the new test starts:

def test_browser_fixture(pytester: Pytester, playwright_browsers_path: str):
    """
    Test the browser fixture with pytester.
    """
    # Make a conftest.py file
    pytester.makeconftest(f"""
        from chives.browser_fixtures import browser
        import os
        
        os.environ["PLAYWRIGHT_BROWSERS_PATH"] = {playwright_browsers_path!r}
    """)

    ...

and now the overall test passes. Here’s the complete code for the new test:

test_browser_fixture.py
from pathlib import Path
from playwright.sync_api import sync_playwright
import pytest
from pytest import Pytester


@pytest.fixture(scope="session")
def playwright_browsers_path() -> str:
    """
    Return the cache directory where Playwright browsers are installed.
    """
    with sync_playwright() as p:
        # In my local builds, this returns a path like:
        #
        #    ~/Library/Caches/ms-playwright/webkit-2272/pw_run.sh
        #
        # Unwrap two levels to get to the `ms-playwright` folder.
        return str(Path(p.webkit.executable_path).parent.parent)


def test_browser_fixture(pytester: Pytester, playwright_browsers_path: str):
    """
    Test the browser fixture with pytester.
    """
    # Make a conftest.py file
    pytester.makeconftest(f"""
        from chives.browser_fixtures import browser
        import os
        
        os.environ["PLAYWRIGHT_BROWSERS_PATH"] = {playwright_browsers_path!r}
    """)

    # Make an HTML file
    (pytester.path / "greeting.html").write_text("""
        <p>Hello world!</p>
    """)

    # Make a temporary pytest test file
    pytester.makepyfile(
        """
        from chives.browser_fixtures import file_uri
        from playwright.sync_api import Browser, expect
    
        def test_browser_fixture(browser: Browser) -> None:
            uri = file_uri("greeting.html")

            p = browser.new_page()
            p.goto(uri)
            expect(p.get_by_text("Hello world!")).to_be_visible()
        """
    )

    # Run the isolated test suite with pytest
    result = pytester.runpytest()

    # Check that one test passed
    result.assert_outcomes(passed=1)

The full test suite is more extensive, and checks that certain scenarios fail or error – will the fixtures spot the mistakes I expect them to? For example, my Page fixture is meant to load a page and fail the test if there are any console warnings or errors; does it actually fail the test correctly?

I don’t expect to use Pytester very often, because it’s rare for me to write fixtures complex enough to need their own test suite – but sometimes I do, and it’s good to know how to create another layer of safety net.

[If the formatting of this post looks odd in your feed reader, visit the original article]