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 webkitStep 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.
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.
Verification
- Reorder two layers in the fixture CSS on a scratch branch. The
paddingTopassertion must fail in all three engines. - Move a rule from
componentsto an undeclared layer name. The assertion must fail again — this proves the suite catches an implicitly created layer. - Confirm the suite runs against the build:
npm run build && npx playwright test --reporter=line- 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.
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.
Related
- Testing Cascade Layers Across Browsers — the strategy this suite implements
- Checking Layer Support With CSS.supports in JavaScript — runtime detection for the engines your suite does not cover
- Debugging Layer Differences Between Chrome, Firefox and Safari — what to do when one engine fails
- Auditing the Overrides Layer in CI — the static companion to this runtime suite
Up: Testing Layers Across Browsers → Browser Support, Compatibility & Migration