Performance 13 min read

Automated Image QA and Testing: Catch Issues Before Production

Implement automated quality assurance for images in your development workflow. Learn visual regression testing, performance validation, and accessibility checks.

By ImageGuide Team ยท Published January 19, 2026 ยท Updated January 19, 2026
QAtestingvisual regressionautomationCI/CD

Automated image testing catches quality issues, performance regressions, and accessibility problems before they reach production. This guide covers testing strategies, tools, and implementation patterns for comprehensive image QA.

Why Automate Image QA?

IssueManual DetectionAutomated Detection
Oversized filesSlow, inconsistentInstant, every commit
Missing alt textOften missed100% coverage
Broken imagesFound by usersCaught in CI
Visual regressionsSubjectivePixel-precise
Format issuesEasy to overlookEnforced standards

Image Validation Testing

File Size Validation

// tests/image-size.test.js
const fs = require('fs').promises;
const path = require('path');
const { glob } = require('glob');

const SIZE_LIMITS = {
  thumbnail: 50 * 1024,    // 50KB
  standard: 200 * 1024,    // 200KB
  hero: 500 * 1024,        // 500KB
  maximum: 1024 * 1024     // 1MB absolute max
};

describe('Image File Sizes', () => {
  let images;

  beforeAll(async () => {
    images = await glob('public/images/**/*.{jpg,jpeg,png,webp,avif}');
  });

  test('all images under maximum size limit', async () => {
    const oversized = [];

    for (const image of images) {
      const stats = await fs.stat(image);
      if (stats.size > SIZE_LIMITS.maximum) {
        oversized.push({
          file: image,
          size: stats.size,
          limit: SIZE_LIMITS.maximum
        });
      }
    }

    if (oversized.length > 0) {
      console.error('Oversized images:');
      oversized.forEach(({ file, size, limit }) => {
        console.error(`  ${file}: ${(size / 1024).toFixed(0)}KB (limit: ${limit / 1024}KB)`);
      });
    }

    expect(oversized).toHaveLength(0);
  });

  test('thumbnails under thumbnail limit', async () => {
    const thumbnails = images.filter(img => img.includes('/thumbs/') || img.includes('-thumb'));

    for (const thumb of thumbnails) {
      const stats = await fs.stat(thumb);
      expect(stats.size).toBeLessThanOrEqual(SIZE_LIMITS.thumbnail);
    }
  });

  test('hero images under hero limit', async () => {
    const heroes = images.filter(img => img.includes('/hero') || img.includes('-hero'));

    for (const hero of heroes) {
      const stats = await fs.stat(hero);
      expect(stats.size).toBeLessThanOrEqual(SIZE_LIMITS.hero);
    }
  });
});

Dimension Validation

// tests/image-dimensions.test.js
const sharp = require('sharp');
const { glob } = require('glob');

const DIMENSION_RULES = {
  maxWidth: 4096,
  maxHeight: 4096,
  productImage: { width: 2048, height: 2048 },
  thumbnail: { width: 400, height: 400 },
  hero: { width: 1920, height: 1080 }
};

describe('Image Dimensions', () => {
  test('no images exceed maximum dimensions', async () => {
    const images = await glob('public/images/**/*.{jpg,jpeg,png,webp}');
    const violations = [];

    for (const image of images) {
      const metadata = await sharp(image).metadata();

      if (metadata.width > DIMENSION_RULES.maxWidth ||
          metadata.height > DIMENSION_RULES.maxHeight) {
        violations.push({
          file: image,
          dimensions: `${metadata.width}x${metadata.height}`,
          limit: `${DIMENSION_RULES.maxWidth}x${DIMENSION_RULES.maxHeight}`
        });
      }
    }

    expect(violations).toHaveLength(0);
  });

  test('product images are square', async () => {
    const products = await glob('public/images/products/**/*.{jpg,png,webp}');

    for (const product of products) {
      const metadata = await sharp(product).metadata();
      expect(metadata.width).toBe(metadata.height);
    }
  });

  test('thumbnails are correct size', async () => {
    const thumbnails = await glob('public/images/**/thumb-*.{jpg,png,webp}');

    for (const thumb of thumbnails) {
      const metadata = await sharp(thumb).metadata();
      expect(metadata.width).toBeLessThanOrEqual(DIMENSION_RULES.thumbnail.width);
      expect(metadata.height).toBeLessThanOrEqual(DIMENSION_RULES.thumbnail.height);
    }
  });
});

Format Validation

// tests/image-formats.test.js
const sharp = require('sharp');
const { glob } = require('glob');
const path = require('path');

describe('Image Formats', () => {
  test('all JPEGs have WebP variants', async () => {
    const jpegs = await glob('public/images/**/*.{jpg,jpeg}');
    const missing = [];

    for (const jpeg of jpegs) {
      const webpPath = jpeg.replace(/\.(jpg|jpeg)$/i, '.webp');
      const exists = await glob(webpPath);

      if (exists.length === 0) {
        missing.push(jpeg);
      }
    }

    if (missing.length > 0) {
      console.warn('Missing WebP variants:', missing);
    }

    expect(missing).toHaveLength(0);
  });

  test('no BMP or TIFF files in production', async () => {
    const forbidden = await glob('public/images/**/*.{bmp,tiff,tif}');
    expect(forbidden).toHaveLength(0);
  });

  test('SVGs are optimized', async () => {
    const svgs = await glob('public/images/**/*.svg');

    for (const svg of svgs) {
      const content = await fs.readFile(svg, 'utf8');

      // Check for common unoptimized patterns
      expect(content).not.toMatch(/<!--.*-->/); // No comments
      expect(content).not.toMatch(/\s{2,}/); // No excessive whitespace
    }
  });
});

Visual Regression Testing

Using Playwright

// tests/visual-regression.spec.js
const { test, expect } = require('@playwright/test');

test.describe('Image Visual Regression', () => {
  test('homepage hero image', async ({ page }) => {
    await page.goto('/');

    const hero = page.locator('.hero-image');
    await expect(hero).toHaveScreenshot('hero-image.png', {
      maxDiffPixels: 100
    });
  });

  test('product gallery', async ({ page }) => {
    await page.goto('/products/sample-product');

    const gallery = page.locator('.product-gallery');
    await expect(gallery).toHaveScreenshot('product-gallery.png', {
      threshold: 0.1
    });
  });

  test('responsive images at different viewports', async ({ page }) => {
    const viewports = [
      { width: 375, height: 667, name: 'mobile' },
      { width: 768, height: 1024, name: 'tablet' },
      { width: 1440, height: 900, name: 'desktop' }
    ];

    for (const viewport of viewports) {
      await page.setViewportSize({ width: viewport.width, height: viewport.height });
      await page.goto('/');

      await expect(page.locator('.hero-image')).toHaveScreenshot(
        `hero-${viewport.name}.png`
      );
    }
  });
});

Playwright Configuration

// playwright.config.js
const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests',
  snapshotDir: './tests/snapshots',
  updateSnapshots: 'missing',

  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 50,
      threshold: 0.2,
      animations: 'disabled'
    }
  },

  use: {
    baseURL: 'http://localhost:3000',
    screenshot: 'only-on-failure'
  },

  projects: [
    {
      name: 'chromium',
      use: { browserName: 'chromium' }
    },
    {
      name: 'webkit',
      use: { browserName: 'webkit' }
    }
  ]
});

Accessibility Testing

Alt Text Validation

// tests/image-accessibility.test.js
const { JSDOM } = require('jsdom');
const fs = require('fs').promises;
const { glob } = require('glob');

describe('Image Accessibility', () => {
  let htmlFiles;

  beforeAll(async () => {
    htmlFiles = await glob('dist/**/*.html');
  });

  test('all content images have alt text', async () => {
    const violations = [];

    for (const file of htmlFiles) {
      const html = await fs.readFile(file, 'utf8');
      const dom = new JSDOM(html);
      const document = dom.window.document;

      const images = document.querySelectorAll('img');

      images.forEach((img, index) => {
        // Skip decorative images (empty alt is intentional)
        if (img.getAttribute('role') === 'presentation') return;
        if (img.getAttribute('aria-hidden') === 'true') return;

        const alt = img.getAttribute('alt');

        if (alt === null) {
          violations.push({
            file,
            src: img.getAttribute('src'),
            issue: 'Missing alt attribute'
          });
        } else if (alt.trim() === '' && !isDecorativeContext(img)) {
          violations.push({
            file,
            src: img.getAttribute('src'),
            issue: 'Empty alt on non-decorative image'
          });
        }
      });
    }

    if (violations.length > 0) {
      console.error('Accessibility violations:');
      violations.forEach(v => console.error(`  ${v.file}: ${v.src} - ${v.issue}`));
    }

    expect(violations).toHaveLength(0);
  });

  test('alt text is not too long', async () => {
    const MAX_ALT_LENGTH = 125;
    const violations = [];

    for (const file of htmlFiles) {
      const html = await fs.readFile(file, 'utf8');
      const dom = new JSDOM(html);
      const images = dom.window.document.querySelectorAll('img[alt]');

      images.forEach(img => {
        const alt = img.getAttribute('alt');
        if (alt && alt.length > MAX_ALT_LENGTH) {
          violations.push({
            file,
            src: img.getAttribute('src'),
            length: alt.length,
            alt: alt.substring(0, 50) + '...'
          });
        }
      });
    }

    expect(violations).toHaveLength(0);
  });

  test('linked images have descriptive alt', async () => {
    for (const file of htmlFiles) {
      const html = await fs.readFile(file, 'utf8');
      const dom = new JSDOM(html);
      const linkedImages = dom.window.document.querySelectorAll('a > img');

      linkedImages.forEach(img => {
        const alt = img.getAttribute('alt');
        expect(alt).not.toBe('');
        expect(alt).not.toMatch(/^(image|photo|picture|click here)$/i);
      });
    }
  });
});

function isDecorativeContext(img) {
  // Check if image is likely decorative based on context
  const parent = img.parentElement;
  if (parent?.classList.contains('decorative')) return true;
  if (img.classList.contains('icon')) return true;
  return false;
}

Axe Integration

// tests/axe-images.test.js
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;

test.describe('Image Accessibility (Axe)', () => {
  test('homepage passes image accessibility checks', async ({ page }) => {
    await page.goto('/');

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa'])
      .include('img')
      .analyze();

    expect(results.violations).toHaveLength(0);
  });

  test('product page passes image checks', async ({ page }) => {
    await page.goto('/products/sample');

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa'])
      .include('.product-images')
      .analyze();

    if (results.violations.length > 0) {
      console.error('Violations:', JSON.stringify(results.violations, null, 2));
    }

    expect(results.violations).toHaveLength(0);
  });
});

Performance Testing

Image Loading Performance

// tests/image-performance.test.js
const { test, expect } = require('@playwright/test');

test.describe('Image Performance', () => {
  test('hero image loads within LCP budget', async ({ page }) => {
    await page.goto('/');

    const lcp = await page.evaluate(() => {
      return new Promise((resolve) => {
        new PerformanceObserver((list) => {
          const entries = list.getEntries();
          const lastEntry = entries[entries.length - 1];
          resolve(lastEntry.startTime);
        }).observe({ type: 'largest-contentful-paint', buffered: true });
      });
    });

    expect(lcp).toBeLessThan(2500); // 2.5s LCP budget
  });

  test('lazy loaded images have loading attribute', async ({ page }) => {
    await page.goto('/');

    const belowFoldImages = await page.evaluate(() => {
      const viewportHeight = window.innerHeight;
      const images = Array.from(document.querySelectorAll('img'));

      return images
        .filter(img => img.getBoundingClientRect().top > viewportHeight)
        .map(img => ({
          src: img.src,
          loading: img.getAttribute('loading')
        }));
    });

    belowFoldImages.forEach(img => {
      expect(img.loading).toBe('lazy');
    });
  });

  test('above fold images are NOT lazy loaded', async ({ page }) => {
    await page.goto('/');

    const aboveFoldImages = await page.evaluate(() => {
      const viewportHeight = window.innerHeight;
      const images = Array.from(document.querySelectorAll('img'));

      return images
        .filter(img => img.getBoundingClientRect().top < viewportHeight)
        .map(img => ({
          src: img.src,
          loading: img.getAttribute('loading')
        }));
    });

    aboveFoldImages.forEach(img => {
      expect(img.loading).not.toBe('lazy');
    });
  });

  test('images have width and height attributes', async ({ page }) => {
    await page.goto('/');

    const imagesWithoutDimensions = await page.evaluate(() => {
      return Array.from(document.querySelectorAll('img'))
        .filter(img => !img.width || !img.height)
        .map(img => img.src);
    });

    expect(imagesWithoutDimensions).toHaveLength(0);
  });
});

Network Request Analysis

// tests/image-network.test.js
const { test, expect } = require('@playwright/test');

test.describe('Image Network Requests', () => {
  test('images served with correct headers', async ({ page }) => {
    const imageRequests = [];

    page.on('response', response => {
      if (response.request().resourceType() === 'image') {
        imageRequests.push({
          url: response.url(),
          status: response.status(),
          headers: response.headers()
        });
      }
    });

    await page.goto('/');
    await page.waitForLoadState('networkidle');

    imageRequests.forEach(req => {
      // Check caching headers
      expect(req.headers['cache-control']).toBeDefined();

      // Check content type
      expect(req.headers['content-type']).toMatch(/^image\//);

      // Check successful response
      expect(req.status).toBe(200);
    });
  });

  test('modern formats are served when supported', async ({ page, browserName }) => {
    if (browserName !== 'chromium') return; // Skip for browsers that might not support AVIF

    const imageFormats = [];

    page.on('response', response => {
      if (response.request().resourceType() === 'image') {
        imageFormats.push({
          url: response.url(),
          contentType: response.headers()['content-type']
        });
      }
    });

    await page.goto('/');
    await page.waitForLoadState('networkidle');

    const modernFormatCount = imageFormats.filter(
      img => img.contentType?.includes('webp') || img.contentType?.includes('avif')
    ).length;

    // Expect at least 50% of images to be modern formats
    expect(modernFormatCount / imageFormats.length).toBeGreaterThan(0.5);
  });
});

CI Integration

GitHub Actions Workflow

# .github/workflows/image-qa.yml
name: Image QA

on:
  pull_request:
    paths:
      - 'public/images/**'
      - 'src/images/**'

jobs:
  validation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Run image validation tests
        run: npm test -- tests/image-*.test.js

  visual-regression:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Build site
        run: npm run build

      - name: Start server
        run: npm run preview &

      - name: Run visual regression tests
        run: npx playwright test tests/visual-regression.spec.js

      - name: Upload snapshots on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-regression-results
          path: tests/snapshots/

  accessibility:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Build site
        run: npm run build

      - name: Run accessibility tests
        run: npm test -- tests/image-accessibility.test.js

Using CDN for Consistent Quality

Instead of testing generated images, use an image CDN like Sirv that guarantees consistent optimization:

// tests/sirv-images.test.js
test('Sirv URLs are correctly formatted', async ({ page }) => {
  await page.goto('/');

  const sirvImages = await page.evaluate(() => {
    return Array.from(document.querySelectorAll('img'))
      .filter(img => img.src.includes('sirv.com'))
      .map(img => img.src);
  });

  sirvImages.forEach(url => {
    // Verify optimization parameters
    expect(url).toMatch(/format=(optimal|webp|avif)/);
    expect(url).toMatch(/w=\d+/);
  });
});

Conclusion

Comprehensive image QA requires testing:

  1. File properties - Size, dimensions, format
  2. Visual quality - Regression testing
  3. Accessibility - Alt text, ARIA
  4. Performance - Loading behavior, caching
  5. Network - Headers, formats served

Automate these tests in CI to catch issues before they reach production.

Related Resources

Format References

Ready to optimize your images?

Sirv automatically optimizes, resizes, and converts your images. Try it free.

Start Free Trial