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?
| Issue | Manual Detection | Automated Detection |
|---|---|---|
| Oversized files | Slow, inconsistent | Instant, every commit |
| Missing alt text | Often missed | 100% coverage |
| Broken images | Found by users | Caught in CI |
| Visual regressions | Subjective | Pixel-precise |
| Format issues | Easy to overlook | Enforced 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:
- File properties - Size, dimensions, format
- Visual quality - Regression testing
- Accessibility - Alt text, ARIA
- Performance - Loading behavior, caching
- Network - Headers, formats served
Automate these tests in CI to catch issues before they reach production.