Compare / BackstopJS
Moving from BackstopJS to Playwright and Screenery
BackstopJS still works, and people still install it. But its README asks for a new maintainer, and the last release on npm is from September 2024. This guide turns your backstop.json into one Playwright test and gives the screenshots a hosted home.
Where BackstopJS stands, checked October 2026
- The README says BackstopJS "needs a new maintainer/owner".
- The latest npm release is 6.3.25, published on 7 September 2024.
- It still had about 104,500 npm downloads in the week of 4 to 10 October 2026, so many pipelines run it.
- An open issue from August 2026 reports an outdated Puppeteer with a security problem.
What BackstopJS does well
- Scenarios without test code. A JSON file lists URLs, selectors and viewports. People who do not write tests can still add a page.
- A self-hosted report. The HTML report and its before-and-after scrubber need no account.
- Docker rendering, so screenshots match across machines.
- Free and MIT-licensed, with a strong following among Drupal and WordPress teams.
Side by side
| BackstopJS | Playwright + Screenery | |
|---|---|---|
| Scenarios | backstop.json | A Playwright test, which can read the same file |
| Baseline | backstop_data/bitmaps_reference, on one machine or committed | A committed folder, or the version each push replaces on Screenery |
| Report | Local HTML report | Pull-request comment, and an offline HTML viewer from --out |
| Agents | None | MCP server: diff @pr-N against @latest |
| Published URLs | None | @latest follows the default branch |
| Maintenance | Looking for a maintainer | Both under active development |
| Price | Free, open source | Playwright is free. Screenery has a Free plan; prices not published yet |
Commands, mapped
| BackstopJS | Playwright + Screenery |
|---|---|
| backstop test | npx playwright test npx screenery diff ./test-results --baseline ./screenery-baseline |
| backstop reference | npx screenery diff ./test-results --baseline ./screenery-baseline --update |
| backstop approve | The same --update, then commit the baseline |
| The HTML report | npx screenery diff … --out ./diff-report |
screenery diff needs no account and no network. It exits 1 when something changed.
Scenarios as a Playwright test
Keep backstop.json where it is. This test reads it and takes one screenshot per scenario and viewport:
// tests/backstop.spec.ts
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { test } from '@playwright/test';
const config = JSON.parse(readFileSync('backstop.json', 'utf8'));
const slug = (text: string) => text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
// Like BackstopJS, a url starting with . or / is a file relative to the working directory.
const toUrl = (url: string) => (/^[./]/.test(url) ? pathToFileURL(join(process.cwd(), url)).href : url);
const names = new Map<string, string>();
for (const raw of config.scenarios) {
const scenario = { ...config.scenarioDefaults, ...raw };
for (const viewport of scenario.viewports ?? config.viewports) {
const name = `${slug(scenario.label)}/${slug(viewport.label)}`;
if (names.has(name)) {
throw new Error(`"${scenario.label}" and "${names.get(name)}" would both save as ${name}. Rename one label.`);
}
names.set(name, scenario.label);
test(`${scenario.label} (${viewport.label})`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto(toUrl(scenario.url));
await page.screenshot({ path: `test-results/${name}.png`, fullPage: true });
});
}
}
A scenario labelled Homepage at a phone viewport is saved as test-results/homepage/phone.png and published as homepage/phone. The viewport goes last, so Screenery groups the sizes of one page together. Names must be lowercase, which is why the test slugs them. See Names.
The test follows BackstopJS on three details. A scenario's own viewports replace the global list, scenarioDefaults apply to every scenario, and a url starting with . or / is a local file. Two labels can slug to the same name, such as Account / Settings and Account Settings. The test then stops with an error instead of overwriting a screenshot. It does not add a number to tell them apart, because a name is a URL and must stay the same from run to run.
Scenario options
Most scenario keys are one Playwright line. Add the ones you use inside the test:
| backstop.json | Playwright |
|---|---|
| readySelector | await page.locator(scenario.readySelector).waitFor(); |
| delay | await page.waitForTimeout(scenario.delay); |
| hoverSelector | await page.hover(scenario.hoverSelector); |
| clickSelector | await page.click(scenario.clickSelector); |
| hideSelectors | The mask option of page.screenshot() |
| selectors | await page.locator(selector).screenshot({ path }); |
| misMatchThreshold | No exact match. See Graded changes |
misMatchThreshold is a share of pixels. Screenery's threshold is how different one pixel must be, and failOn picks which grade of change fails. Start with the defaults and loosen only what is noisy.
Baselines: committed, or hosted
You have two choices, and they work together.
A committed folder works like BackstopJS's reference folder. Create it from your current screenshots and commit it:
npx playwright test npx screenery diff ./test-results --baseline ./screenery-baseline --update git add screenery-baseline
Fonts and anti-aliasing differ between machines, which is why BackstopJS uses Docker. Create the baseline on the same image your CI runs, or it will fail on the first run. Keep the baseline outside the folder you push.
A hosted baseline needs no folder. Every push grades each PNG against the version it replaces on Screenery. On a pull request, that is the base branch's channel, then @latest. Set failAbove in screenery.config.json to fail the push before anything is published:
{
"diff": {
"failAbove": 1
}
}
failAbove is a percent. A screenshot that changed more than that fails the push, and nothing is published. alertAbove only warns. See Graded changes.
In CI
On GitHub Actions, run the test and add the Screenery step. There is no secret to store:
permissions:
id-token: write
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
# … install, then: npx playwright test …
- uses: screenery/push@v1
with:
project: acme/dashboard
path: test-results
diff-baseline: ./screenery-baseline
Leave out diff-baseline if you use the hosted baseline. Each pull request gets a comment showing what changed against @latest, and a merge to the default branch updates every embedded @latest URL. In other CI systems, run the CLI with a SCREENERY_TOKEN. See Getting started.
When to stay on BackstopJS
- Your scenarios are maintained by people who will never edit a test file.
- You need everything offline, with no hosted service at all. The screenery diff gate also runs offline, but publishing needs Screenery.
- Your pipeline works, nobody depends on new releases, and the open security issue does not affect you.
If you want a review app rather than published URLs, compare Argos and Percy too.
Sources
Checked October 2026.
See also: all comparisons.