StyleLint + Visual Regression Testing for CSS in CI

StyleLint + Visual Regression Testing for CSS in CI

StyleLint enforces CSS code quality standards; visual regression tests catch the layout and styling bugs that StyleLint can't — the ones that only show up when you look at a browser. Combined in CI, they form a complete CSS quality gate.


Why CSS Needs Two Types of Testing

StyleLint checks your CSS code structure: naming conventions, property order, unit consistency, selector specificity. It finds code quality issues before they reach the browser.

Visual regression testing checks what the browser actually renders. A CSS rule can be syntactically valid and pass all linting rules but still break a component's layout — a wrong margin, an off-by-one in padding, a z-index that creates an overlap.

You need both.


StyleLint Setup

Installation

npm install --save-dev stylelint stylelint-config-standard stylelint-order

For SCSS:

npm install --save-dev stylelint stylelint-config-standard-scss

Configuration

// .stylelintrc.json
{
  "extends": [
    "stylelint-config-standard",
    "stylelint-config-standard-scss"
  ],
  "plugins": [
    "stylelint-order"
  ],
  "rules": {
    // Property order enforcement
    "order/properties-alphabetical-order": true,
    
    // Naming conventions
    "selector-class-pattern": "^[a-z][a-z0-9-]*(__[a-z0-9-]+)?(--[a-z0-9-]+)?$",
    // BEM pattern: block, block__element, block--modifier
    
    // Color format consistency
    "color-hex-length": "long",
    "color-named": "never",
    
    // Unit enforcement
    "unit-allowed-list": ["px", "em", "rem", "%", "vh", "vw", "deg", "ms", "s"],
    
    // No magic numbers — use CSS custom properties
    "declaration-no-important": true,
    
    // Specificity limits
    "selector-max-id": 0,              // No ID selectors
    "selector-max-universal": 1,       // Limit * selector usage
    "selector-max-compound-selectors": 3,
    
    // SCSS-specific
    "scss/at-rule-no-unknown": true,
    "scss/selector-no-redundant-nesting-selector": true
  }
}

Running StyleLint

// package.json
{
  "scripts": {
    "lint:css": "stylelint 'src/**/*.{css,scss}'",
    "lint:css:fix": "stylelint 'src/**/*.{css,scss}' --fix",
    "lint:css:ci": "stylelint 'src/**/*.{css,scss}' --formatter=github"
  }
}

--formatter=github outputs GitHub Actions annotations format — ESLint errors appear as inline comments on the PR diff.


Common StyleLint Rules and Why They Matter

Selector Specificity Rules

/* Bad: ID selector — high specificity, hard to override */
#header .nav-link { color: blue; }

/* Good: class selector */
.header__nav-link { color: blue; }
"selector-max-id": 0  // Disallow ID selectors entirely

Property Order

Consistent property order makes CSS scannable and prevents omitting properties. The common ordering: positioning → box model → typography → visual → miscellaneous.

"order/properties-order": [
  "position", "top", "right", "bottom", "left", "z-index",
  "display", "width", "height", "margin", "padding",
  "font-size", "font-weight", "line-height", "color",
  "background", "border", "border-radius",
  "opacity", "transition", "animation"
]

No !important

"declaration-no-important": true

!important is a specificity hack. It indicates a broken cascade, not a solution.


Visual Regression Testing

Option 1: Playwright Screenshots + pixelmatch

Self-hosted, free, and flexible:

npm install --save-dev @playwright/test pixelmatch pngjs
// tests/visual/homepage.visual.ts
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';

const SNAPSHOTS_DIR = path.join(__dirname, 'snapshots');
const THRESHOLD = 0.1;  // 10% pixel difference allowed

async function compareScreenshot(
  page: any, 
  name: string
): Promise<{ passed: boolean; diffPixels: number }> {
  const currentPath = path.join(SNAPSHOTS_DIR, `${name}-current.png`);
  const baselinePath = path.join(SNAPSHOTS_DIR, `${name}-baseline.png`);
  const diffPath = path.join(SNAPSHOTS_DIR, `${name}-diff.png`);
  
  await page.screenshot({ path: currentPath, fullPage: true });
  
  // First run: create baseline
  if (!fs.existsSync(baselinePath)) {
    fs.copyFileSync(currentPath, baselinePath);
    return { passed: true, diffPixels: 0 };
  }
  
  // Compare
  const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
  const current = PNG.sync.read(fs.readFileSync(currentPath));
  const diff = new PNG({ width: baseline.width, height: baseline.height });
  
  const diffPixels = pixelmatch(
    baseline.data, current.data, diff.data,
    baseline.width, baseline.height,
    { threshold: THRESHOLD }
  );
  
  const totalPixels = baseline.width * baseline.height;
  const diffPercentage = diffPixels / totalPixels;
  
  if (diffPixels > 0) {
    fs.writeFileSync(diffPath, PNG.sync.write(diff));
  }
  
  return { 
    passed: diffPercentage < 0.01,  // Less than 1% of pixels changed
    diffPixels 
  };
}


test('homepage visual regression', async ({ page }) => {
  await page.goto('http://localhost:3000');
  await page.waitForLoadState('networkidle');
  
  const { passed, diffPixels } = await compareScreenshot(page, 'homepage');
  
  expect(passed, `Homepage has ${diffPixels} changed pixels`).toBeTruthy();
});


test('navigation component visual regression', async ({ page }) => {
  await page.goto('http://localhost:3000');
  
  // Isolate the component
  const nav = page.locator('nav.main-navigation');
  await nav.screenshot({ path: '/tmp/nav-current.png' });
  
  // Use Playwright's built-in snapshot comparison
  await expect(nav).toHaveScreenshot('navigation.png', {
    maxDiffPixelRatio: 0.01
  });
});

Option 2: Percy (Hosted)

Percy provides hosted visual diffing with a UI for reviewing changes:

npm install --save-dev @percy/cli @percy/playwright
// tests/visual/percy-visual.ts
import { test } from '@playwright/test';
import percySnapshot from '@percy/playwright';

test('homepage', async ({ page }) => {
  await page.goto('http://localhost:3000');
  await page.waitForLoadState('networkidle');
  await percySnapshot(page, 'Homepage');
});

test('mobile navigation', async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 812 });
  await page.goto('http://localhost:3000');
  await page.click('[data-testid="mobile-menu-trigger"]');
  await percySnapshot(page, 'Mobile Navigation - Open');
});
# In CI: wrap with percy exec
- name: Run visual tests
  run: npx percy exec -- npx playwright test tests/visual/
  env:
    PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}

Percy shows visual diffs in a web UI, requiring human approval before the CI check passes.


Combining StyleLint + Visual Regression in CI

name: CSS Quality Gates
on:
  push:
    branches: [main]
  pull_request:

jobs:
  stylelint:
    name: StyleLint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Run StyleLint
        run: npm run lint:css:ci
        # --formatter=github produces inline PR annotations

  visual-regression:
    name: Visual Regression Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium
      
      - name: Build application
        run: npm run build
      
      - name: Start application
        run: npm run start &
        # Run server in background
      
      - name: Wait for application
        run: npx wait-on http://localhost:3000 --timeout 30000
      
      - name: Download visual baselines
        uses: actions/cache@v4
        with:
          path: tests/visual/snapshots/
          key: visual-baselines-${{ github.base_ref || 'main' }}
          restore-keys: visual-baselines-
      
      - name: Run visual regression tests
        run: npx playwright test tests/visual/
      
      - name: Upload diff images on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diffs
          path: tests/visual/snapshots/*-diff.png
      
      - name: Update baselines cache
        if: github.ref == 'refs/heads/main'
        uses: actions/cache/save@v4
        with:
          path: tests/visual/snapshots/
          key: visual-baselines-main-${{ github.sha }}

Updating Baselines

When a visual change is intentional (a new design or planned update), baselines need updating:

# Update all baselines locally
npx playwright <span class="hljs-built_in">test tests/visual/ --update-snapshots

<span class="hljs-comment"># Commit updated baselines
git add tests/visual/snapshots/
git commit -m <span class="hljs-string">"chore: update visual regression baselines for new nav design"

Or trigger a baseline update from CI via a special label on the PR:

- name: Update visual baselines
  if: contains(github.event.pull_request.labels.*.name, 'update-snapshots')
  run: npx playwright test tests/visual/ --update-snapshots
  
- name: Commit updated baselines
  if: contains(github.event.pull_request.labels.*.name, 'update-snapshots')
  uses: stefanzweifel/git-auto-commit-action@v5
  with:
    commit_message: "chore: update visual regression baselines [skip ci]"

Summary

CSS quality testing has two complementary layers:

  1. StyleLint — enforces code standards: naming conventions, specificity limits, property order, forbidden patterns. Fast, runs in seconds, catches structural issues before they reach the browser.
  2. Visual regression — catches what linting can't: layout breaks, spacing regressions, responsive behavior. Slower but catches the bugs users actually see.

In CI:

  • StyleLint runs first (fast, blocks immediately on violations)
  • Visual tests run after build (slower, requires a running application)
  • Baselines are cached and updated on intentional changes

The combination blocks CSS regressions at both the code level (StyleLint) and the rendered output level (visual diffing) without requiring manual review of every CSS change.

Read more