DEV Community

PlacePack
PlacePack

Posted on • Originally published at placepack.top

Deterministic image fixtures for Playwright and Cypress visual regression tests

Visual regression testing has a dirty secret: most teams run it for a few weeks, then quietly disable it because the false-positive rate becomes unbearable. Images fetched from external URLs are usually the culprit. CDNs get updated. Photos get cropped. Lazy-loading timing changes. None of it is a real regression, but your snapshot diffs say otherwise.

The boring-but-correct solution is to stop relying on external image requests in tests entirely. Commit your placeholder images as local fixtures and never touch them again unless you're deliberately resizing something.

Why external URLs kill snapshot stability

When your component under test renders <img src="https://example.com/photo.jpg" />, at least three things can go wrong in a snapshot run:

  1. Network latency causes the image to not load before the screenshot is taken — you get a broken image icon in your baseline.
  2. The CDN returns a different variant (WebP vs JPEG, different crop, different compression) depending on Accept headers or A/B flags.
  3. The image is updated upstream and now genuinely looks different — but this is noise in your UI test, not a real regression you care about.

The fix: use a local file. Committed to your repo. Never changes unless you run your fixture refresh script.

Generating fixtures with the PlacePack pack endpoint

PlacePack has a POST /api/v1/pack endpoint that accepts a list of labeled sizes and returns a ZIP containing all the images. Use it in a one-time setup script:

// scripts/generate-fixtures.mjs
import { createWriteStream } from "fs";
import { mkdir } from "fs/promises";
import { pipeline } from "stream/promises";
import fetch from "node-fetch"; // or native fetch in Node 18+

const FIXTURES_DIR = "./tests/fixtures/images";

await mkdir(FIXTURES_DIR, { recursive: true });

const res = await fetch("https://placepack.top/api/v1/pack", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    sizes: [
      "hero:1200x630",
      "thumbnail:400x300",
      "avatar:80x80",
      "og-image:1200x630",
      "card:600x400",
    ].join("\n"),
    format: "png",
  }),
});

if (!res.ok) throw new Error(`Pack API error: ${res.status}`);

// The response is a ZIP file
const dest = createWriteStream(`${FIXTURES_DIR}/placeholders.zip`);
await pipeline(res.body, dest);

console.log("Fixtures written to", FIXTURES_DIR);
Enter fullscreen mode Exit fullscreen mode

Then unzip and commit the PNGs. Add a yarn fixtures script to package.json so any team member can refresh them:

{
  "scripts": {
    "fixtures": "node scripts/generate-fixtures.mjs && unzip -o tests/fixtures/images/placeholders.zip -d tests/fixtures/images && rm tests/fixtures/images/placeholders.zip"
  }
}
Enter fullscreen mode Exit fullscreen mode

The resulting files will be named after your labels: hero.png, thumbnail.png, avatar.png, etc. Exact, reproducible, no network required at test time.

Playwright test using local fixtures

// tests/product-card.spec.ts
import { test, expect } from "@playwright/test";
import path from "path";

const FIXTURES = path.resolve(__dirname, "fixtures/images");

test("ProductCard renders correctly", async ({ page }) => {
  // Intercept the image request and serve the local fixture instead
  await page.route("**/images/product-thumb**", (route) => {
    route.fulfill({ path: `${FIXTURES}/thumbnail.png` });
  });

  await page.goto("/components/product-card");
  await page.waitForLoadState("networkidle");

  await expect(page).toMatchSnapshot("product-card.png");
});
Enter fullscreen mode Exit fullscreen mode

Or if you control the component props directly (e.g., Playwright Component Testing):

// tests/components/ProductCard.spec.tsx
import { test, expect } from "@playwright/experimental-ct-react";
import { ProductCard } from "../../src/components/ProductCard";
import path from "path";

const thumb = path.resolve(__dirname, "../fixtures/images/thumbnail.png");

test("renders product card", async ({ mount }) => {
  const component = await mount(
    <ProductCard
      title="Wireless Headphones"
      price="$79"
      image={`file://${thumb}`}
    />
  );
  await expect(component).toMatchSnapshot();
});
Enter fullscreen mode Exit fullscreen mode

Cypress test using local fixtures

Cypress makes this even simpler — just drop the fixtures in cypress/fixtures/ and serve them via cy.intercept:

// cypress/e2e/product-card.cy.js
describe("ProductCard", () => {
  it("renders with placeholder image", () => {
    cy.intercept("GET", "**/product-thumb*", {
      fixture: "images/thumbnail.png",
    });

    cy.visit("/components/product-card");
    cy.get("[data-testid=product-card]").matchImageSnapshot("product-card");
  });
});
Enter fullscreen mode Exit fullscreen mode

Or reference the fixture file directly in a component stub:

// cypress/support/commands.js
Cypress.Commands.add("mountWithFixtures", (Component, props) => {
  const fixturePath = (name) => `/cypress/fixtures/images/${name}.png`;
  cy.mount(<Component {...props} image={fixturePath("thumbnail")} />);
});
Enter fullscreen mode Exit fullscreen mode

Refreshing fixtures intentionally

The whole point of committed fixtures is that they don't change accidentally. But you will occasionally need to resize something or add a new image size. The workflow:

  1. Update the sizes list in scripts/generate-fixtures.mjs
  2. Run yarn fixtures
  3. Review the diff — new or changed PNG files should show up in git status
  4. Commit with a message like chore: refresh image fixtures (added banner:800x200)

This makes fixture changes intentional and reviewable, exactly the same way you'd treat any other committed asset.

The PlacePack API is rate-limited to 30 requests per 60 seconds, but the pack endpoint counts as a single request regardless of how many images are in the ZIP. For typical fixture sets (under 20 images), you'll never come close to the limit.

Try it

The fastest way to preview your fixture images before committing them: open placepack.top, enter your sizes and labels, and download the ZIP directly from the generator UI.

For more on this setup — including a GitHub Actions step that auto-generates fixtures on PRs that touch image sizes — see placepack.top/visual-regression.

Top comments (0)