How I Run WordPress Plugin E2E Tests in Parallel: A Docker Site Pool and a Pinned Playwright Box

A WordPress plugin has to work on a fresh install and on a site that has run older versions for years. If it has a free and a paid edition, that is four combinations before a single test runs. I test all of them with Playwright, and two problems kept slowing me down:

  1. Parallel tests fought over one site. Two workers on the same WordPress install change the same settings, and a test fails because of another test, not because of a bug.
  2. Screenshots did not match. A visual test that passed on my Mac failed in CI, because the two machines drew the same page a little differently.

My setup solves both. Every Playwright worker gets its own WordPress site from a pool that is generated for the number of workers. And every screenshot is taken inside one pinned Playwright container, so a laptop and CI draw the same pixels.

This post explains the approach and the traps I hit on the way.


Table of Contents

  1. The Four Variants
  2. A Site Pool: One WordPress per Worker
  3. Generating the Compose File
  4. The wp-cli Sidecar and the uid Trap
  5. Old Sites Start from a Real Database
  6. Getting the Plugin Code onto the Sites
  7. MU-Plugins: Test Helpers in the REST API
  8. The Pinned Playwright Box
  9. Running It
  10. Traps I Hit
  11. Key Takeaways

The Four Variants

Each combination the plugin must support is a variant:

VariantEditionSite
pro-newpaida fresh install
pro-oldpaidan existing site, seeded from a database dump
lite-newfreea fresh install
lite-oldfreean existing site, seeded from a database dump

“New” sites catch bugs in the first-run experience. “Old” sites catch the harder ones: upgrades, migrations, and settings that a fresh install never has.

You rarely need all four at once. One environment variable picks the active variants:

SITES_TO_INIT=pro-old,lite-new

A Site Pool: One WordPress per Worker

Playwright runs tests in parallel workers. If two workers share one WordPress site, they share its database, its options and its cache. Worker A saves a setting, worker B reads it, and a test fails for a reason that has nothing to do with the code under test.

So each active variant is expanded into one instance per worker. With two workers and two variants, the pool has four sites:

{
  "workers": 2,
  "instances": {
    "pro-old-0":  { "variant": "pro-old",  "baseURL": "http://localhost:8200", "db": "wordpress_pro_old_0" },
    "pro-old-1":  { "variant": "pro-old",  "baseURL": "http://localhost:8201", "db": "wordpress_pro_old_1" },
    "lite-new-0": { "variant": "lite-new", "baseURL": "http://localhost:8300", "db": "wordpress_lite_new_0" },
    "lite-new-1": { "variant": "lite-new", "baseURL": "http://localhost:8301", "db": "wordpress_lite_new_1" }
  }
}

Each instance is fully separate: its own WordPress container, its own MariaDB container, its own wp-cli container, its own volumes, and its own port. Each variant has a port range, and the instance number is added to it: pro-old starts at 8200, pro-new at 8250, lite-new at 8300 and lite-old at 8350.

A test fixture reads this manifest and maps each variant to the worker’s own instance, ${variant}-${testInfo.parallelIndex}. A test never needs to know which worker it runs on. It asks for “the pro-old site”, and it gets its own.

Test data gets the same treatment. Every post, term or user a test creates carries a worker prefix, such as w1-t3, so two workers never create two things with the same name.


Generating the Compose File

A hand-written docker-compose.yml cannot grow with the worker count. So the compose file is generated. One small, pure function builds it from the active variants and the worker count:

export function buildPool(activeVariants, workers) {
  const services = {}, volumes = {}, instances = {}
  for (const variant of activeVariants) {
    for (let i = 0; i < workers; i++) {
      Object.assign(services, instanceServices(variant, i))
      const u = variant.replace(/-/g, '_')
      volumes[`wp_${u}_${i}`] = null
      volumes[`db_${u}_${i}`] = null
      const port = BASE_PORTS[variant] + i
      instances[`${variant}-${i}`] = { variant, baseURL: `http://localhost:${port}`, port, db: `wordpress_${u}_${i}` }
    }
  }
  return {
    compose: { name: 'plugin-testing', services, volumes },
    manifest: { workers, instances },
  }
}

Two details make this pleasant:

  • Compose accepts JSON. A -f file can be plain JSON, so the generator writes docker-compose.pool.yml with JSON.stringify and needs no YAML library.
  • The function is pure. It builds objects and writes nothing, so a unit test can check the ports, the names and the volumes without starting Docker.

The same run writes the manifest, .site-pool.json, so the compose file and the test fixtures can never disagree about which sites exist.


The wp-cli Sidecar and the uid Trap

Every instance has a wp-cli container next to its WordPress container. The tests use it to install plugins, change options and create content:

[`wpcli-${variant}-${i}`]: {
  image: 'wordpress:cli',
  entrypoint: ['wp', '--allow-root'],
  user: '33:33',
  volumes: [`wp_${u}_${i}:/var/www/html:cached`],
  depends_on: [`wordpress-${variant}-${i}`, `db-${variant}-${i}`],
}

The user: '33:33' line took me a long time to find. The wordpress:latest image runs PHP as www-data with uid 33. The wordpress:cli image is based on Alpine, where www-data is uid 82. Without the override, wp-cli writes into a web root that it does not own, and the write fails silently. A plugin install “succeeds”, and the plugin is not there. Setting the user to 33 makes both containers the same owner.


Old Sites Start from a Real Database

An “old” site is only useful if it looks like a real, long-lived site. So the -old variants start from a database dump of a site that has run older versions of the plugin, with real settings, content and history.

Two rules keep this safe:

  • Scrub the dump. A dump of a real site holds personal data: emails, names, IP addresses. Mine is scrubbed before it goes into the repo, and it has no personal data left.
  • Import it into every instance. Each pro-old-N gets its own copy of the same dump, so every worker starts from the same state.

Getting the Plugin Code onto the Sites

There are two ways to put the code under test onto the sites, and each suits one place:

  • In CI, copy the plugins in. The pipeline downloads the plugin builds for a branch and copies them into each site. Every run starts clean. The setup script sees the CI environment and turns the mounts off by itself.
  • On a laptop, mount the source. A bind mount puts your working copy straight into each site, so an edit shows up on the next page load with no copy step.

Bind mounts have one catch. Tests that upload or update a plugin through the WordPress admin need to write to the plugins folder, and a bind mount to your source tree is the wrong place for that. So one switch turns the mounts off:

PLUGIN_MOUNTS=false bun run site:init

MU-Plugins: Test Helpers in the REST API

A must-use plugin in every site adds small helpers to the REST API, so a test can check the state of the site directly instead of guessing from the page:

  • the WordPress version, in the REST API root
  • the comment count, on each post
  • the permalink structure

Must-use plugins load before every other plugin and cannot be turned off from the admin, so a test can always rely on them.


The Pinned Playwright Box

Visual regression tests compare a screenshot with a saved “baseline” image. They are only useful if the same page gives the same pixels every time. It does not, across machines:

  • a different Chromium version draws text differently
  • different fonts change line widths
  • a different CPU architecture changes the result too: an Apple Silicon Mac runs the arm64 build of Chromium, and a CI agent runs x64. It is the same version, but text and SVG transforms are drawn a little differently, and that is enough to fail a byte comparison.

So every visual test runs inside one pinned Docker image, on a laptop and in CI:

# The tag MUST match @playwright/test in the lockfile. Bump both together.
FROM mcr.microsoft.com/playwright:v1.63.0-jammy

# bun must match the version that made bun.lock, or --frozen-lockfile rejects it.
ARG BUN_VERSION=1.2.4
RUN npm install -g bun@${BUN_VERSION}

# The suite runs `docker compose` for site setup, so the box needs the Docker CLI.
RUN apt-get update && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin

# Baselines may only be updated inside this image.
ENV VR_PINNED_ENV=1

A small script, run-in-box.sh, runs any command in it:

docker/run-in-box.sh                          # compare screenshots
docker/run-in-box.sh 'bun x playwright test'  # the whole suite
docker/run-in-box.sh 'bun run vr:update'      # update the baselines

The details inside that script are where the work was:

  • Docker outside of Docker. The tests start and change the WordPress sites with docker compose. The box mounts the host’s Docker socket, so those commands reach the host’s Docker, not a new one inside the container.
  • Mount the repo at its real host path. The compose file uses relative bind mounts. They only resolve the same way if the repo sits at the same path inside the box as on the host.
  • Keep node_modules in a Linux volume. A Mac’s node_modules holds macOS binaries, and they must not leak into a Linux container.
  • Use the host network. The WordPress sites have http://localhost:<port> saved as their home URL, so the box must reach them through the same localhost.
  • Force one architecture when it matters. PW_BOX_PLATFORM=linux/amd64 makes an Apple Silicon Mac run the same x64 build as CI.
  • Guard the baselines. Updating baselines checks VR_PINNED_ENV first, and it refuses to run outside the box. A baseline from the wrong machine would make every later comparison wrong.

Running It

The day-to-day commands are short:

# Build the pool and set up every site
PW_WORKERS=2 SITES_TO_INIT=pro-old,lite-new bun run site:init

# Start, stop, or reset the sites
bun run site:start
bun run site:stop
bun run site:reset

# Run wp-cli against one site
bun run wp:plugin-list

# Run the tests
bun run test                 # on the host
bun run test:docker          # inside the pinned box
bun run vr:page 'settings'   # one visual test, inside the box

site:init does the whole setup for each instance: it waits for the database, installs WordPress (or imports the dump for an -old site), copies or mounts the plugins, adds the must-use helpers, and activates what the variant needs.


Traps I Hit

A plugin install “succeeds” and the plugin is missing. The wp-cli container writes as uid 82 into a web root owned by uid 33. Set user: '33:33' on the wp-cli service.

Screenshots differ between a Mac and CI, with the same versions. The CPU architecture is different. Run both on one platform with PW_BOX_PLATFORM.

Ctrl+C does not stop the run in the box. docker run leaves the container, and Playwright, running after the client stops. The script traps the exit and force-removes the container.

A later run is slow, and sites act strangely. A hard-killed box can leave an orphan container that keeps using the test sites. The script removes any old runner containers before it starts a new one.

CI cannot clean up its checkout. The box runs as root, so files it writes into the mounted repo belong to root. On exit, the script gives them back to the host user.

Upload tests fail on a laptop only. The plugin folder is a bind mount to the source tree. Run those tests with PLUGIN_MOUNTS=false.


Key Takeaways

  1. One site per worker. Parallel tests only stay independent when they do not share a database.
  2. Generate the compose file from the worker count, with a pure function you can unit-test.
  3. Seed “old” sites from a scrubbed real database, so upgrade bugs show up before users see them.
  4. Mount the source on a laptop, copy it in CI, with a switch for the tests that must write.
  5. Take every screenshot in one pinned image, down to the Playwright version, the fonts and the CPU architecture.
  6. Guard the baselines, so they can only be updated where they will be compared.

The result: tests run in parallel without stepping on each other, and a screenshot that passes on my laptop passes in CI too.

the end

If you found this post helpful, consider buying me a coffee. It keeps me writing!