Module 10: Tooling & Testing
Testing CSS: Visual Regression, Computed Styles and Cross-Browser Checks
What can and cannot be tested automatically in CSS, with working Playwright examples for visual snapshots, computed styles and responsive behavior, plus a practical manual checklist.
CSS is hard to test because its output is visual. A unit test cannot say a page "looks right". That does not mean nothing can be automated. You can test specific facts about your styles, and you can compare screenshots to catch unintended changes, and you can reserve human attention for the parts that need judgment.
What can be tested#
| What | How | Good for |
|---|---|---|
| A specific computed style | assert on getComputedStyle | "the button is the brand color", "the menu is hidden on mobile" |
| Layout facts | assert on bounding boxes | "the sidebar is 240px", "no horizontal overflow" |
| Whole-page appearance | screenshot comparison | catching unintended visual changes |
| Accessibility rules | automated checks like axe | contrast, labels, focus issues |
| Feature support | @supports and manual browsers | fallbacks |
Why jsdom is the wrong tool#
Unit-test environments such as Jest with jsdom do not do layout. There is no rendering engine, so getBoundingClientRect() returns zeros and media queries and flexbox do nothing. Do not test CSS there. Use a real browser. Playwright, Cypress and WebdriverIO all drive real browsers, and Playwright runs Chromium, Firefox and WebKit, which covers the three main rendering engines.
Testing computed styles with Playwright#
npm init playwright@latest
// tests/button.spec.js
import { test, expect } from "@playwright/test";
test("primary button uses brand color and is tappable", async ({ page }) => {
await page.goto("http://localhost:5173/");
const btn = page.locator(".btn").first();
await expect(btn).toHaveCSS("background-color", "rgb(11, 95, 255)");
await expect(btn).toHaveCSS("border-radius", "8px");
const box = await btn.boundingBox();
expect(box.height).toBeGreaterThanOrEqual(44);
});
toHaveCSS reads the computed value, so colors come back in rgb() form and sizes in pixels, even if you wrote hex or rem. Write expected values in that form.
Testing responsive behavior#
Set the viewport, then check what should change:
test("menu collapses on mobile", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 });
await page.goto("http://localhost:5173/");
await expect(page.locator("#menu")).toBeHidden();
await page.getByRole("button", { name: "Menu" }).click();
await expect(page.locator("#menu")).toBeVisible();
});
test("desktop shows links inline", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("http://localhost:5173/");
await expect(page.locator("#menu")).toBeVisible();
});
A valuable generic check is that the page never scrolls sideways, at any width:
for (const width of [320, 375, 768, 1024, 1440]) {
test(`no horizontal overflow at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto("http://localhost:5173/");
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
);
expect(overflow).toBe(false);
});
}
Run it across several widths and one test catches a whole class of bug.
Visual regression testing#
A visual regression test saves a screenshot the first time it runs, and on later runs compares new screenshots to the saved one.
test("home page looks the same", async ({ page }) => {
await page.goto("http://localhost:5173/");
await expect(page).toHaveScreenshot("home.png", {
fullPage: true,
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
});
test("card component", async ({ page }) => {
await page.goto("http://localhost:5173/components/card");
await expect(page.locator(".card").first()).toHaveScreenshot("card.png");
});
npx playwright test --update-snapshots # accept new screenshots as the baseline
When a test fails, Playwright produces an image showing expected, actual and the difference, and you decide whether the change was a bug or an intended update.
What makes snapshot tests flaky, and how to prevent it:
- Animations and transitions. Use
animations: "disabled". - Fonts loading late. Wait for
document.fonts.ready, or self-host the fonts. - Dynamic content like dates and random images. Mock them, or mask those regions.
- Different operating systems render text differently, so a screenshot taken on macOS fails on a Linux CI server. Generate baselines in the same environment that runs the tests, usually a Docker container or the CI itself.
- Too-strict thresholds. A little tolerance (
maxDiffPixelRatio) absorbs anti-aliasing noise.
Visual tests are best for components and key pages, in a few viewports and themes. Do not snapshot everything. A large set of noisy tests gets ignored.
Component-level testing with Storybook#
A tool like Storybook renders each component in isolation, in each state: default, hover, disabled, error, long text, dark theme. You can look at it, run accessibility checks against it, and connect it to visual testing services. Because every state is on its own page, regression tests are more stable and failures easier to understand.
Accessibility checks#
import AxeBuilder from "@axe-core/playwright";
test("home page has no detectable accessibility violations", async ({ page }) => {
await page.goto("http://localhost:5173/");
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
Axe catches colors that fail contrast, missing labels, and bad ARIA. Automated checks find only part of the issues, often estimated at a third to half, so they do not replace keyboard and screen reader testing. They are still useful to run every time.
Testing browser support#
You rarely need to test in every browser. A reasonable approach:
- Choose a support policy, such as "the latest two versions of Chrome, Firefox, Safari and Edge".
- Run automated tests on all three engines (Chromium, Firefox, WebKit).
- Check feature support on MDN or caniuse before using something new.
- Test manually on a real iPhone and a real Android phone at least before big releases. Mobile Safari has unique behavior for viewport units, input zoom, sticky positioning and the address bar.
Feature queries let your CSS test itself in the browser:
@supports not (container-type: inline-size) {
.card { grid-template-columns: 1fr; } /* simple stacked fallback */
}
A manual checklist before shipping#
- Resize from 320px to 1920px with no horizontal scrollbar or broken layout.
- Zoom to 200% and check that text reflows.
- Tab through the page and confirm that focus is always visible and the order makes sense.
- Test dark mode, reduced motion and print preview.
- Try very long names, very short content, an empty state and a failed image.
- Check right-to-left if you support it, by adding
dir="rtl"tohtml. - Look at it on at least one real phone.
Common mistakes#
- Testing CSS with jsdom.
- Screenshot tests with animations enabled, so they flake.
- Baselines created on one OS and run on another.
- Snapshotting the entire site and drowning in diffs.
- Trusting only automated accessibility checks.
- Testing only the happy path content: a title that fits, an image that loads.
- Only testing in Chrome.
- Writing
hexcolors intoHaveCSSassertions, when computed values arergb().
Practice#
- Install Playwright and write a test that checks one component's computed color and minimum height.
- Write the multi-width overflow test and run it against your own project. Fix any failure.
- Take a screenshot baseline of a component, then change its padding slightly and read the diff image.
- Run the whole suite in Chromium, Firefox and WebKit, and investigate any browser-specific difference.