Writing Visual Regression Tests for Layer Order

Layer order breaks silently: a bundler hoists an import, a dependency registers a name, and one component starts rendering with the wrong padding on one route. The suite that catches this is smaller than most teams expect — one fixture page and a handful of assertions do most of the work, with screenshots reserved for the failures that only pixels reveal. This implements the testing strategy from testing cascade layers across browsers, in the browser support, compatibility and migration reference.

Prerequisites

A built site served over HTTP (not the dev server — layer order is a property of the bundle), and Playwright installed:

npm install --save-dev @playwright/test
npx playwright install --with-deps chromium firefox webkit

Step 1: Build the conflict fixture

<!-- fixtures/layer-conflict.html — WHY a dedicated page: production pages
     rarely contest every boundary, so a real regression can hide. Here every
     layer competes for a different property, and each property's computed
     value is a direct readout of one boundary. -->
<!DOCTYPE html>
<html lang="en">
<head>
  <link rel="stylesheet" href="/assets/index.css">
  <link rel="stylesheet" href="/fixtures/layer-conflict.css">
</head>
<body>
  <div class="probe" data-testid="probe">probe</div>
</body>
</html>
/* fixtures/layer-conflict.css
   WHY one property per boundary: when an assertion fails, the property that
   failed names the layer that moved. A shared property would only tell you
   that something, somewhere, changed. */
@layer reset      { .probe { border-width: 1px; } }
@layer base       { .probe { letter-spacing: 1px; } }
@layer theme      { .probe { color: rgb(51, 51, 51); } }
@layer components { .probe { padding: 4px; } }
@layer utilities  { .probe { margin: 5px; } }

/* Each layer ALSO sets padding, so the padding assertion proves the whole
   stack resolves top-down as declared. */
@layer reset      { .probe { padding: 1px; } }
@layer base       { .probe { padding: 2px; } }
@layer theme      { .probe { padding: 3px; } }

What this does: creates a page whose computed styles encode the entire manifest. padding must be 4px — if any layer above components starts winning, or components sinks below theme, the number changes.

How the conflict fixture encodes the layer stack Five layers each own a distinct property whose presence proves that layer is applied. A shared padding property is set by four of the layers with different values, so its computed result of four pixels proves the whole ordering resolves as declared. layer owns (proves it applies) contested padding reset border-width: 1px padding: 1px (loses) base letter-spacing: 1px padding: 2px (loses) theme color: rgb(51,51,51) padding: 3px (loses) components padding: 4px padding: 4px ← WINS utilities margin: 5px — not set here A failing property names the boundary that moved — which is why each layer owns a different one.

Step 2: Assert computed values before taking any screenshot

// tests/layer-order.spec.js
import { test, expect } from '@playwright/test';

// WHY these exact values: they are derived from the fixture's declared order.
// If the manifest is reordered, or a rule lands in the wrong layer, at least
// one of these changes — and the property that failed names the boundary.
const EXPECTED = {
  paddingTop:    '4px',              // components wins the contest
  borderTopWidth:'1px',              // reset applies at all
  letterSpacing: '1px',              // base applies at all
  color:         'rgb(51, 51, 51)',  // theme applies at all
  marginTop:     '5px',              // utilities applies at all
};

test('layer stack resolves as declared', async ({ page }) => {
  await page.goto('/fixtures/layer-conflict.html');
  const computed = await page.getByTestId('probe').evaluate((el) => {
    const s = getComputedStyle(el);
    // WHY longhands: shorthand serialisation differs between engines, so a
    // shorthand assertion fails for reasons unrelated to layers.
    return {
      paddingTop: s.paddingTop,
      borderTopWidth: s.borderTopWidth,
      letterSpacing: s.letterSpacing,
      color: s.color,
      marginTop: s.marginTop,
    };
  });
  expect(computed).toEqual(EXPECTED);
});

What this does: covers the manifest order, the rule placement and the engine resolution in one fast, deterministic test. Everything after this is for the failures pixels catch and numbers do not.

Step 3: Screenshot component states, not pages

// WHY per-state: layer regressions concentrate in states only one layer
// styles — hover, focus-visible, disabled, selected. Those are exactly the
// states a full-page snapshot never enters.
const STATES = [
  ['default',  async () => {}],
  ['hover',    async (el) => el.hover()],
  ['focus',    async (el) => el.focus()],
];

for (const [name, activate] of STATES) {
  test(`ghost button — ${name}`, async ({ page }) => {
    await page.goto('/fixtures/buttons.html');
    const btn = page.getByTestId('btn-ghost');
    await activate(btn);
    await expect(btn).toHaveScreenshot(`btn-ghost-${name}.png`, {
      maxDiffPixelRatio: 0.01,
    });
  });
}

What this does: produces diffs a reviewer can actually read. A 40-pixel image of a button in its hover state either changed or it did not.

Step 4: Stabilise the rendering

// playwright.config.js
export default {
  webServer: {
    // WHY the built output, not the dev server: import hoisting and chunk
    // splitting — the causes of real layer regressions — only exist in a build.
    command: 'npm run build && npx serve dist -l 4173',
    url: 'http://localhost:4173',
    reuseExistingServer: !process.env.CI,
  },
  use: {
    baseURL: 'http://localhost:4173',
    viewport: { width: 1280, height: 720 },
    deviceScaleFactor: 1,
    // WHY: a transition mid-capture is the single largest source of flake.
    launchOptions: { args: ['--force-prefers-reduced-motion'] },
  },
  expect: { toHaveScreenshot: { animations: 'disabled', caret: 'hide' } },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox',  use: { browserName: 'firefox'  } },
    { name: 'webkit',   use: { browserName: 'webkit'   } },
  ],
};

What this does: removes the four usual flake sources — animation, caret blink, viewport drift and device scale. Fonts are the fifth: self-host them, or the first CI run on a fresh image will diff every glyph.

Cost and coverage of each test type Three test types compared. Computed-style assertions are fastest and catch ordering changes exactly. Component-state screenshots are moderate cost and catch stacking and overflow. Full-page screenshots are slowest, noisiest and are marked as not recommended for layer testing. test type runtime catches computed-style assertions run these first, always ~1s any ordering change, exactly located misses stacking and overflow effects component-state screenshots hover · focus · disabled ~10s stacking, overflow, visual state loss diff small enough to be reviewed full-page screenshots not recommended here minutes everything, indistinctly approved without reading Put the failing weight on the assertions; use pixels only where pixels are the evidence.

Verification

  1. Reorder two layers in the fixture CSS on a scratch branch. The paddingTop assertion must fail in all three engines.
  2. Move a rule from components to an undeclared layer name. The assertion must fail again — this proves the suite catches an implicitly created layer.
  3. Confirm the suite runs against the build:
npm run build && npx playwright test --reporter=line
  1. Check the snapshot directory is committed and that a fresh clone reproduces the baselines without regenerating them.

Troubleshooting

Screenshots differ between local and CI. : Font rendering. Self-host the fonts, commit them, and generate the baselines inside the same container image CI uses.

Five sources of screenshot flake Five causes of unstable visual comparisons: running animations, a blinking caret, viewport or scale drift, remotely fetched fonts, and dynamic content such as timestamps. running animations and transitions disable them in the expect configuration a blinking text caret hide it explicitly when capturing viewport or device-scale drift pin both in the project configuration remotely fetched fonts self-host and commit them, then regenerate baselines timestamps and randomised content mask those regions

A test passes in Chromium and fails in WebKit. : Check what is being asserted. Native control metrics and default fonts differ between engines, so an assertion on a <button>'s padding fails for reasons unrelated to layers. Assert on plain <div> probes in the fixture.

The suite is green but production broke. : The fixture is served by a different pipeline than production, or the regression is in a runtime-injected stylesheet the fixture does not load. Serve the real build output, and add a CSSOM order assertion on a production route.

Every snapshot fails after a design token change. : Expected — token changes are visual changes. Regenerate baselines in the same commit as the token change so the diff is reviewed once, deliberately.

Diffs report a one-pixel shift on hover. : A transform or transition is still running at capture time. Confirm animations: 'disabled' is in the expect config and that no animation is driven by JavaScript, which Playwright cannot freeze.

Complete working example

// tests/layer-order.spec.js — the complete suite.
import { test, expect } from '@playwright/test';

const EXPECTED = {
  paddingTop: '4px',
  borderTopWidth: '1px',
  letterSpacing: '1px',
  color: 'rgb(51, 51, 51)',
  marginTop: '5px',
};

test.describe('cascade layer contract', () => {
  test('computed values match the declared stack', async ({ page }) => {
    await page.goto('/fixtures/layer-conflict.html');
    const got = await page.getByTestId('probe').evaluate((el) => {
      const s = getComputedStyle(el);
      return {
        paddingTop: s.paddingTop,
        borderTopWidth: s.borderTopWidth,
        letterSpacing: s.letterSpacing,
        color: s.color,
        marginTop: s.marginTop,
      };
    });
    expect(got).toEqual(EXPECTED);
  });

  test('the bundle registers the manifest in order', async ({ page }) => {
    await page.goto('/fixtures/layer-conflict.html');
    const order = await page.evaluate(() => {
      const names = [];
      const walk = (rules) => {
        for (const r of rules) {
          if (r.constructor.name === 'CSSLayerStatementRule') {
            names.push(...r.nameList);
          } else if (r.constructor.name === 'CSSLayerBlockRule') {
            if (r.name) names.push(r.name);
            walk(r.cssRules);
          } else if (r.cssRules) {
            walk(r.cssRules);
          }
        }
      };
      for (const sheet of document.styleSheets) {
        // WHY try/catch: a cross-origin sheet throws on cssRules access.
        try { walk(sheet.cssRules); } catch { /* not ours to assert on */ }
      }
      return [...new Set(names)];
    });
    expect(order.slice(0, 5)).toEqual([
      'reset', 'base', 'theme', 'components', 'utilities',
    ]);
  });

  // WHY state-scoped: a layer that only styles :hover is invisible to a
  // default-state snapshot, and that is precisely where utilities live.
  for (const [name, activate] of [
    ['default', async () => {}],
    ['hover',   async (el) => el.hover()],
    ['focus',   async (el) => el.focus()],
  ]) {
    test(`ghost button renders correctly — ${name}`, async ({ page }) => {
      await page.goto('/fixtures/buttons.html');
      const btn = page.getByTestId('btn-ghost');
      await activate(btn);
      await expect(btn).toHaveScreenshot(`btn-ghost-${name}.png`, {
        maxDiffPixelRatio: 0.01,
      });
    });
  }
});

Frequently Asked Questions

Why not screenshot whole pages?

Because the diff becomes unreadable. A full-page image mixes the layer regression you are hunting with every unrelated content and layout difference, and a diff nobody can interpret gets approved rather than investigated. A component in a single state produces an image small enough that the reviewer either sees the change or does not — which is the only property that makes a visual suite worth maintaining.

How do you stop screenshot tests from being flaky?

Five causes account for nearly all of it: running animations, a blinking caret, viewport or device-scale drift, remotely fetched fonts, and dynamic content such as timestamps. Disable animations and the caret in the expect config, pin the viewport and scale, self-host and commit the fonts, and mask the dynamic regions. Generating baselines in the same container image CI uses removes the last of it.

Should visual tests replace computed-style assertions?

They cover different failures, so neither replaces the other. A reordered layer that shifts padding by two pixels is invisible in a screenshot and unmissable in a computed value; a stacking-context or overflow regression is the reverse. Run the assertions first — they are an order of magnitude faster and their failure messages name the exact property — and let the screenshots cover what pixels alone reveal.

Up: Testing Layers Across BrowsersBrowser Support, Compatibility & Migration