BackstopJS Visual Regression for Component Libraries

BackstopJS Visual Regression for Component Libraries

BackstopJS is a visual regression testing tool that compares screenshots of web pages or components across versions. It's particularly useful for component libraries and design systems where you need to verify that changes to shared components don't break visual consistency across many consumers.

How BackstopJS Works

BackstopJS:

  1. Navigates to URLs you specify using Puppeteer or Playwright
  2. Takes screenshots of specified selectors or full pages
  3. Compares new screenshots against reference (baseline) images
  4. Reports diffs with visual overlays

Unlike unit tests that check logic, BackstopJS verifies pixels — catching regressions from CSS refactors, dependency updates, or browser behavior changes.

Installation

npm install --save-dev backstopjs
npx backstop init

init creates a starter backstop.json configuration file.

Configuration

BackstopJS is configured through backstop.json:

{
  "id": "my-component-library",
  "viewports": [
    {
      "label": "mobile",
      "width": 375,
      "height": 812
    },
    {
      "label": "tablet",
      "width": 768,
      "height": 1024
    },
    {
      "label": "desktop",
      "width": 1440,
      "height": 900
    }
  ],
  "onBeforeScript": "puppet/onBefore.js",
  "onReadyScript": "puppet/onReady.js",
  "scenarios": [
    {
      "label": "Button - Primary",
      "url": "http://localhost:6006/iframe.html?id=components-button--primary",
      "selectors": [".docs-story"],
      "misMatchThreshold": 0.1,
      "requireSameDimensions": true
    }
  ],
  "paths": {
    "bitmaps_reference": "backstop_data/bitmaps_reference",
    "bitmaps_test":      "backstop_data/bitmaps_test",
    "html_report":       "backstop_data/html_report",
    "ci_report":         "backstop_data/ci_report"
  },
  "report": ["browser", "CI"],
  "engine": "puppeteer",
  "engineOptions": {
    "args": ["--no-sandbox"]
  },
  "asyncCaptureLimit": 5,
  "asyncCompareLimit": 50,
  "debug": false
}

Scenarios

Scenarios define what to test. Each scenario is a URL + configuration:

{
  "label": "Button - All States",
  "url": "http://localhost:3000/components/button",
  "selectors": [
    ".button-primary",
    ".button-secondary",
    ".button-disabled"
  ],
  "hoverSelector": ".button-primary",
  "clickSelector": null,
  "postInteractionWait": 300,
  "misMatchThreshold": 0,
  "requireSameDimensions": true,
  "hideSelectors": [".timestamp", ".version-badge"],
  "removeSelectors": [".cookie-banner"],
  "delay": 500
}

Key scenario options:

Option Description
selectors CSS selectors to screenshot (defaults to full page)
hoverSelector Hover this element before screenshot
clickSelector Click this element before screenshot
keyPressSelectors Type into elements
postInteractionWait Wait N ms after interaction
misMatchThreshold Allowed pixel difference (0–100 percent)
hideSelectors Hide (invisible, preserves layout)
removeSelectors Remove from DOM entirely
delay Wait N ms before screenshot

Testing a Storybook Component Library

For component libraries using Storybook, generate scenarios programmatically from your stories:

// generate-backstop-config.js
const fs = require('fs');

const stories = [
  { id: 'components-button--primary',     label: 'Button Primary' },
  { id: 'components-button--secondary',   label: 'Button Secondary' },
  { id: 'components-button--disabled',    label: 'Button Disabled' },
  { id: 'components-input--default',      label: 'Input Default' },
  { id: 'components-input--error',        label: 'Input Error State' },
  { id: 'components-modal--open',         label: 'Modal Open' },
  { id: 'components-card--with-image',    label: 'Card With Image' },
  { id: 'components-dropdown--open',      label: 'Dropdown Open' },
];

const scenarios = stories.map(({ id, label }) => ({
  label,
  url: `http://localhost:6006/iframe.html?id=${id}&viewMode=story`,
  selectors:    ['#storybook-root'],
  misMatchThreshold: 0.1,
  delay: 300,
  hideSelectors: ['.docs-story .title'],
}));

const config = {
  id: 'component-library',
  viewports: [
    { label: 'mobile',  width: 375,  height: 812 },
    { label: 'desktop', width: 1440, height: 900 },
  ],
  scenarios,
  paths: {
    bitmaps_reference: 'backstop_data/bitmaps_reference',
    bitmaps_test:      'backstop_data/bitmaps_test',
    html_report:       'backstop_data/html_report',
    ci_report:         'backstop_data/ci_report',
  },
  report:   ['CI'],
  engine:   'puppeteer',
  engineOptions: { args: ['--no-sandbox'] },
  asyncCaptureLimit: 5,
  asyncCompareLimit: 50,
};

fs.writeFileSync('backstop.json', JSON.stringify(config, null, 2));
console.log(`Generated ${scenarios.length} scenarios`);

Run before testing:

node generate-backstop-config.js
npx backstop test

Workflow Commands

# Create reference screenshots (first run / after intentional changes)
npx backstop reference

<span class="hljs-comment"># Compare against references
npx backstop <span class="hljs-built_in">test

<span class="hljs-comment"># Approve current screenshots as new reference
npx backstop approve

<span class="hljs-comment"># Open HTML report
npx backstop openReport

The HTML report shows side-by-side comparisons with diff overlays — pass/fail status per scenario and viewport.

Custom Scripts (onBefore and onReady)

BackstopJS lets you run custom Puppeteer scripts before and after navigation.

onBefore.js — runs before navigating to the URL:

// backstop_data/engine_scripts/puppet/onBefore.js
module.exports = async (page, scenario, vp) => {
  await require('./loadCookies')(page, scenario);
};

onReady.js — runs after navigation, before screenshot:

// backstop_data/engine_scripts/puppet/onReady.js
module.exports = async (page, scenario, vp) => {
  // Wait for fonts to load
  await page.evaluateHandle('document.fonts.ready');
  
  // Disable animations
  await page.addStyleTag({
    content: `
      *, *::before, *::after {
        animation-duration: 0s !important;
        transition-duration: 0s !important;
      }
    `,
  });
  
  // Wait for any lazy-loaded images
  await page.evaluate(() => {
    return new Promise((resolve) => {
      const images = document.querySelectorAll('img[loading="lazy"]');
      if (images.length === 0) return resolve();
      let loaded = 0;
      images.forEach((img) => {
        if (img.complete) {
          loaded++;
          if (loaded === images.length) resolve();
        } else {
          img.addEventListener('load', () => {
            loaded++;
            if (loaded === images.length) resolve();
          });
        }
      });
    });
  });
};

Authentication

For pages requiring authentication, use the cookiePath option or a custom onBefore script:

// backstop_data/engine_scripts/puppet/auth.js
module.exports = async (page, scenario) => {
  if (scenario.cookiePath) {
    const cookies = require(scenario.cookiePath);
    await page.setCookie(...cookies);
  }
};
{
  "label": "Dashboard (authenticated)",
  "url": "http://localhost:3000/dashboard",
  "cookiePath": "backstop_data/engine_scripts/puppet/authCookies.json",
  "selectors": [".dashboard-content"],
  "onBeforeScript": "puppet/auth.js"
}

Or use localStorage:

// onBefore.js
module.exports = async (page, scenario) => {
  await page.evaluateOnNewDocument(() => {
    localStorage.setItem('auth_token', 'test-token-123');
  });
};

Docker for Reproducible Screenshots

Cross-platform screenshot differences are the main source of false positives in visual regression testing. Run BackstopJS in Docker:

# Dockerfile.backstop
FROM backstopjs/backstopjs:6.1.4

WORKDIR /src
COPY backstop.json .
COPY backstop_data/engine_scripts ./backstop_data/engine_scripts
# Generate reference in Docker
docker run --<span class="hljs-built_in">rm \
  --network host \
  -v $(<span class="hljs-built_in">pwd)/backstop_data:/src/backstop_data \
  backstopjs/backstopjs:6.1.4 reference

<span class="hljs-comment"># Test in Docker
docker run --<span class="hljs-built_in">rm \
  --network host \
  -v $(<span class="hljs-built_in">pwd)/backstop_data:/src/backstop_data \
  backstopjs/backstopjs:6.1.4 <span class="hljs-built_in">test

All team members and CI generate screenshots in the same environment — no more "works on my machine" screenshot differences.

CI Integration

name: Visual Regression

on: [pull_request]

jobs:
  visual:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      
      - name: Install dependencies
        run: npm ci
      
      - name: Build Storybook
        run: npm run build-storybook
      
      - name: Serve Storybook
        run: npx serve storybook-static -p 6006 -s &
      
      - name: Wait for Storybook
        run: npx wait-on http://localhost:6006
      
      - name: Generate scenarios
        run: node generate-backstop-config.js
      
      - name: Run visual regression tests
        run: npx backstop test --config backstop.json
      
      - name: Upload report on failure
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: backstop-report
          path: backstop_data/html_report/
      
      - name: Upload diff images on failure
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: backstop-diffs
          path: backstop_data/bitmaps_test/

Approving Changes

When a PR intentionally changes component appearance:

# Run tests, see what changed
npx backstop <span class="hljs-built_in">test

<span class="hljs-comment"># Review diffs in browser report
npx backstop openReport

<span class="hljs-comment"># Approve all passing + changed screenshots as new reference
npx backstop approve

<span class="hljs-comment"># Commit the new baselines
git add backstop_data/bitmaps_reference/
git commit -m <span class="hljs-string">"chore: update visual baselines for new design tokens"

Performance Tips

Large component libraries can generate hundreds of screenshots. Speed improvements:

Increase concurrency:

{
  "asyncCaptureLimit": 10,
  "asyncCompareLimit": 100
}

Scope to changed components:

# Only test button scenarios
npx backstop <span class="hljs-built_in">test --filter <span class="hljs-string">"Button"

Skip reference generation in CI — reference images are committed to the repo; CI only runs test, never reference.

Summary

BackstopJS provides robust visual regression testing with:

  • Multi-viewport testing — catch responsive design regressions
  • Selector-based screenshots — test individual components, not full pages
  • Interaction support — hover, click, and type before capturing
  • Custom scripts — authentication, animation disabling, wait strategies
  • HTML reports — visual diff review with side-by-side comparison
  • Docker support — consistent screenshots across environments

For component libraries, the programmatic scenario generation approach — building backstop.json from your story manifest — scales to hundreds of components without manual configuration maintenance.

Read more