DEV Community

Cover image for How to Test 40+ UI Components in Under a Minute: Speeding Up Screenshot Tests
Anton Trishin
Anton Trishin

Posted on

How to Test 40+ UI Components in Under a Minute: Speeding Up Screenshot Tests

Hey DEV! My name is Anton, and I am a frontend developer. Our team is responsible for the "Product Snippets" library — those exact real estate cards you see in our search results.

The problem is that we have over 40 types of these cards: snippets for secondary, primary, suburban, and short-term real estate, with each type having multiple sizes for different screen resolutions. All of them live in a single monorepo library built on React 19. Any fix in shared styles, global design tokens, or a basic update to the design system components turned into a game of Minesweeper: tweak a margin in one snippet type, and the layout breaks or padding shifts in another. We would find out about this either during the release testing phase or, even worse, from users after production deployment.

In this article, I will share how we implemented a full-scale Visual Regression Testing workflow based on Storybook, Playwright, and Jest, the challenges we faced while stabilizing screenshots, and how we made the tests run flawlessly.

Why Not Jest Snapshot Tests?

Many developers confuse classic DOM Snapshot testing (checking the HTML structure) with visual testing (comparing actual rendered images). Snapshot tests did not fit our needs for several reasons:

  1. Tons of useless code. Any change to an autogenerated class prefix (for instance, in CSS Modules) completely breaks the text snapshot. As a result, diffs become huge, unreadable, and useless.
  2. Blindness to the actual UI. A test compiler can show that the HTML structure is perfect. However, if an element is hidden behind another due to a z-index, a button shifts because of position: absolute, or flex-wrap breaks, a text-based Snapshot simply will not notice.

We needed a tool that could "photograph" over 40 snippet variants in a real headless browser in a matter of seconds and compare them pixel by pixel. We chose a combination of @storybook/test-runner + playwright + jest-image-snapshot.

How It Works

We built a fairly simple but robust architecture:

  1. Storybook is the single source of truth for the UI. Every snippet exists as a Story: different sizes, states, and property types. In this setup, Storybook acts as a unified catalog of UI scenarios.
  2. Test Runner (Jest + Storybook Test Runner). It iterates through all Stories, renders them in Headless Chrome (via Playwright), and captures screenshots of each state.
  3. Jest Image Snapshot is the comparison brain. It compares the reference baseline image with the current render.Test Runner Configuration. Here is our test-runner-jest.config.js:
const { getJestConfig } = require('@storybook/test-runner');

const defaultOptions = getJestConfig();

module.exports = {
  ...defaultOptions,
  testTimeout: 30000,
  testMatch: ['<rootDir>/src/**/*.stories.[jt]s?(x)', '<rootDir>/src/**/*.story.[jt]s?(x)'],
  testPathIgnorePatterns: [
    'node_modules',
    '.cache',
    'dist',
    'lib',
  ],
  maxWorkers: '50%',
  transform: { ...defaultOptions.transform },
};

Enter fullscreen mode Exit fullscreen mode

Startup Scripts. To make the tool as developer-friendly as possible, we created two npm scripts:

"screenshot:test": "npm run build-storybook && (serve storybook-static -p 6006 & wait-on http://127.0.0.1:6006 && test-storybook)",
"screenshot:update": "npm run build-storybook && (serve storybook-static -p 6006 & wait-on http://127.0.0.1:6006 && test-storybook --updateSnapshot)"
Enter fullscreen mode Exit fullscreen mode

The Three Big Stabilization Issues and How We Fixed Them

When we ran our first tests, the diff folders immediately got clogged with false positives. Images kept flashing due to network latency, dynamic web fonts, and other flaky behavior. Here is how we tackled these pain points:

  1. Network Chaos in Galleries. Our snippets feature real estate image galleries. Initially, Playwright would snap screenshots before heavy images could finish loading over the wire. We decided to ditch real network images entirely during tests. Using page.evaluate, we find all gallery containers and wipe out the src and srcset attributes from img and source tags. This forces components to instantly render lightweight fallback placeholders hardcoded into the app. Test execution speed jumped by almost 2x!
  2. Full-page Screenshots and Extra Padding. Initially, we tried taking screenshots of the standard #storybook-root container. In practice, this caused a lot of headaches: the Storybook wrapper had fixed styles like height: 100vh; padding: 16px;. As a result, while the product card itself looked fine, it was surrounded by massive areas of empty white space.We ditched the idea of photographing the entire page and targeted the inner content instead—the specific snippet with the [data-test="product-snippet"] attribute. We relied on the native elementHandle.screenshot() method. Playwright automatically isolates this DOM element, calculates its pixel boundaries, and crops the shot exactly to its outline, completely ignoring everything else on the page. To give the images some breathing room and prevent the card borders from looking too cramped, we used JS in the very same step to inject a clean 16px white padding around the element.
  3. Browser Micro-noise and Font Anti-aliasing. Even after isolating the snippet, wiping out gallery images, and agreeing to run tests strictly locally on dev machines, our screenshots still managed to occasionally flake. The culprit? Headless browsers are notoriously finicky. If the OS spikes the CPU even slightly during a test run, Chromium might delay subpixel text anti-aliasing by a fraction of a millisecond, or cause a barely perceptible micro-shift in how shadows and rounded corners are rendered. To avoid re-generating golden master snapshots after every random system hiccup, we configured three "golden" sensitivity parameters in jest-image-snapshot. This taught the tool to ignore digital noise invisible to the human eye:
  • customDiffConfig: { threshold: 0.1 } — This controls the color sensitivity threshold for individual pixels. Setting it to 0.1 means that if a pixel's color deviates from the baseline by less than 10% (often caused by rendering quirks in semi-transparent elements or gradients), Jest won't flag that pixel as changed at all.
  • blur: 1 — This applies a subtle 1-pixel blur to the images before comparing them. It is the easiest and most effective way to eliminate flaky font anti-aliasing. The faint gray pixels on the edges of letters get slightly softened and blend into the background, preventing false negatives caused by the browser's text engine rendering a character a fraction of a pixel to the right.
  • failureThreshold: 0.003 and failureThresholdType: 'percent' — This is our main safety net, defining the total error tolerance. A value of 0.003 allows up to 0.3% of the entire snippet's area to change without failing the test.

The Final Code for Our test-runner.ts

Here is the optimized configuration we ultimately settled on:

import { TestRunnerConfig } from '@storybook/test-runner';
import { toMatchImageSnapshot } from 'jest-image-snapshot';

const config: TestRunnerConfig = {
  setup() {
    expect.extend({ toMatchImageSnapshot });
  },
  async postVisit(page, context) {
    await page.waitForSelector('#storybook-root');
    await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {
      console.warn(`Timeout waiting for networkidle in ${context.id}`);
    });

    await page.evaluate(() => document.fonts.ready);

    const snippetSelector = '[data-test="product-snippet"]';

    await page.evaluate((selector) => {
      const galleryContainers = document.querySelectorAll('[class*="gallery"]');
      galleryContainers.forEach((container) => {
        const images = container.querySelectorAll('img, source');
        images.forEach((img) => {
          if (img instanceof HTMLImageElement) {
            img.src = '';
            img.srcset = '';
          } else if (img instanceof HTMLSourceElement) {
            img.srcset = '';
          }
        });
      });

      const element = document.querySelector(selector) as HTMLElement;
      if (element) {
        element.style.padding = '16px';
        element.style.boxSizing = 'border-box';
        element.style.background = '#ffffff';
      }
    }, snippetSelector);

    await page.waitForTimeout(300);

    const elementHandle = await page.$(snippetSelector);

    if (elementHandle) {
      const screenshot = await elementHandle.screenshot({
        animations: 'disabled',
        caret: 'hide',
      });

      expect(screenshot).toMatchImageSnapshot({
        customSnapshotIdentifier: context.id,
        customSnapshotsDir: 'screenshots/reference',
        customDiffDir: 'screenshots/diff',
        customDiffConfig: { threshold: 0.1 },
        blur: 1,
        failureThreshold: 0.003,
        failureThresholdType: 'percent',
        allowSizeMismatch: true
      });
    }
  },
};

export default config;

Enter fullscreen mode Exit fullscreen mode

The Bottom Line

Visual regression testing addressed a major pain point in our component library: catching UI breakages after global style or core component updates. Here is what we gained:

  • Confidence in Refactoring. Changes to global styles, design tokens, or core components no longer require manual QA sweeps across dozens of variations. We now instantly see how a modification impacts every single snippet type across all viewports. UI bugs are caught during development, long before making it to production. A developer just runs a single command, and the runner tests over 40 snippet variants in 60 to 90 seconds, flagging only real regressions.
  • Fewer Visual Regressions. Screenshot testing catches the kind of UI papercuts that easily slip through standard code reviews: broken margins, shifting elements, unexpected text wrapping, or hidden regressions caused by upgrading upstream design system packages. Even a tiny 1px misalignment is caught right away.
  • Fast and Stable Pipeline. We made the tests reliable enough for devs to actually trust the green checkmark. To pull this off, we completely cut out network-dependent images, tweaked diff thresholds to absorb flaky browser rendering noise, and capped the concurrency workers to keep Chromium running smoothly.

Conclusion

Screenshot testing doesn't replace functional or integration tests, but it is the perfect tool for locking down visual consistency. If you are maintaining a large UI library, automated visual testing is hands down one of the most effective ways to shield your application from UI regressions and ship updates with ultimate peace of mind.

Top comments (0)