Image Processing Testing: Resize, Convert, Compress, and Validate with Sharp
Image processing is a common source of production bugs: wrong dimensions, incorrect formats, quality degradation, metadata exposure (EXIF with GPS coordinates), and broken pipelines. Testing it requires generating test images programmatically and asserting on processed output.
Generating Test Images with Sharp
Don't commit test images to version control. Generate them programmatically:
// test/helpers/image-factory.js
import sharp from 'sharp'
export async function createTestImage({ width = 1200, height = 800, format = 'jpeg', color = { r: 128, g: 64, b: 192 } } = {}) {
return sharp({
create: {
width,
height,
channels: 3,
background: color,
}
})[format]().toBuffer()
}
export async function createTestImageWithExif({ width = 800, height = 600 } = {}) {
// Create image with GPS EXIF metadata
return sharp({
create: { width, height, channels: 3, background: { r: 100, g: 200, b: 100 } }
})
.withMetadata({
exif: {
IFD0: { Copyright: 'Test User' },
GPS: {
GPSLatitude: [40, 41, 0],
GPSLongitude: [74, 0, 0],
}
}
})
.jpeg()
.toBuffer()
}Testing Resize Operations
// services/image-service.js
import sharp from 'sharp'
export async function resizeThumbnail(inputBuffer, { width = 200, height = 200 } = {}) {
return sharp(inputBuffer)
.resize(width, height, { fit: 'cover', position: 'center' })
.jpeg({ quality: 85 })
.toBuffer()
}
export async function resizeFit(inputBuffer, maxWidth, maxHeight) {
return sharp(inputBuffer)
.resize(maxWidth, maxHeight, { fit: 'inside', withoutEnlargement: true })
.toBuffer()
}// services/image-service.test.js
import sharp from 'sharp'
import { createTestImage } from '../test/helpers/image-factory'
import { resizeThumbnail, resizeFit } from './image-service'
test('thumbnail is exact 200x200 crop', async () => {
const input = await createTestImage({ width: 1920, height: 1080 })
const thumb = await resizeThumbnail(input)
const metadata = await sharp(thumb).metadata()
expect(metadata.width).toBe(200)
expect(metadata.height).toBe(200)
})
test('thumbnail output is JPEG', async () => {
const input = await createTestImage({ format: 'png' })
const thumb = await resizeThumbnail(input)
const metadata = await sharp(thumb).metadata()
expect(metadata.format).toBe('jpeg')
})
test('fit resize preserves aspect ratio', async () => {
const input = await createTestImage({ width: 2000, height: 1000 })
const resized = await resizeFit(input, 800, 800)
const metadata = await sharp(resized).metadata()
expect(metadata.width).toBe(800)
expect(metadata.height).toBe(400) // Proportional: 2000:1000 → 800:400
})
test('small images are not enlarged', async () => {
const input = await createTestImage({ width: 100, height: 100 })
const resized = await resizeFit(input, 800, 800)
const metadata = await sharp(resized).metadata()
expect(metadata.width).toBe(100) // Not enlarged
expect(metadata.height).toBe(100)
})Testing EXIF Stripping
GPS coordinates in user-uploaded photos are a privacy risk. Test that you strip them:
// services/image-service.js
export async function sanitizeImage(inputBuffer) {
return sharp(inputBuffer)
.rotate() // Auto-rotate based on EXIF orientation, then strip
.withMetadata({
exif: {}, // Clear EXIF
icc: false, // Strip ICC profile
})
.toBuffer()
}test('strips GPS data from uploaded images', async () => {
const input = await createTestImageWithExif()
// Verify input has GPS data
const inputMeta = await sharp(input).metadata()
expect(inputMeta.exif).toBeDefined()
const sanitized = await sanitizeImage(input)
const outputMeta = await sharp(sanitized).metadata()
// GPS should be gone
expect(outputMeta.exif).toBeUndefined()
})
test('preserves image orientation when stripping EXIF', async () => {
// Create portrait image that would be landscape without EXIF orientation
const input = await createRotatedTestImage(90)
const sanitized = await sanitizeImage(input)
const metadata = await sharp(sanitized).metadata()
// Should be correctly oriented (EXIF rotation applied, then stripped)
expect(metadata.width).toBeLessThan(metadata.height) // Still portrait
})Testing Format Conversion
// services/image-service.js
export async function convertToWebP(inputBuffer, quality = 80) {
return sharp(inputBuffer)
.webp({ quality })
.toBuffer()
}
export async function convertToAVIF(inputBuffer, quality = 65) {
return sharp(inputBuffer)
.avif({ quality })
.toBuffer()
}test('converts JPEG to WebP', async () => {
const input = await createTestImage({ format: 'jpeg' })
const webp = await convertToWebP(input)
const metadata = await sharp(webp).metadata()
expect(metadata.format).toBe('webp')
})
test('WebP is smaller than original JPEG for photos', async () => {
const original = await createTestImage({ width: 1200, height: 800 })
const webp = await convertToWebP(original)
// WebP should be meaningfully smaller
expect(webp.length).toBeLessThan(original.length * 0.9)
})
test('converts to AVIF with acceptable quality', async () => {
const input = await createTestImage()
const avif = await convertToAVIF(input)
const metadata = await sharp(avif).metadata()
expect(metadata.format).toBe('heif') // Sharp reports AVIF as heif
})Testing Image Validation (Magic Bytes)
Don't trust file extensions. Validate actual image content:
// services/image-validator.js
const IMAGE_MAGIC_BYTES = {
jpeg: [0xFF, 0xD8, 0xFF],
png: [0x89, 0x50, 0x4E, 0x47],
gif: [0x47, 0x49, 0x46],
webp: [0x52, 0x49, 0x46, 0x46], // RIFF header (WebP)
}
export function detectImageType(buffer) {
const bytes = Array.from(buffer.subarray(0, 8))
if (IMAGE_MAGIC_BYTES.jpeg.every((b, i) => bytes[i] === b)) return 'jpeg'
if (IMAGE_MAGIC_BYTES.png.every((b, i) => bytes[i] === b)) return 'png'
if (IMAGE_MAGIC_BYTES.gif.every((b, i) => bytes[i] === b)) return 'gif'
if (IMAGE_MAGIC_BYTES.webp.every((b, i) => bytes[i] === b)) return 'webp'
return null
}
export function isValidImage(buffer) {
return detectImageType(buffer) !== null
}test('detects JPEG by magic bytes', async () => {
const jpeg = await createTestImage({ format: 'jpeg' })
expect(detectImageType(jpeg)).toBe('jpeg')
})
test('rejects PHP file disguised as JPEG', () => {
const phpContent = Buffer.from('<?php echo "hack"; ?>')
expect(isValidImage(phpContent)).toBe(false)
})
test('rejects SVG (potential XSS vector)', () => {
const svg = Buffer.from('<svg><script>alert(1)</script></svg>')
expect(isValidImage(svg)).toBe(false)
})Testing Responsive Image Sets
test('generates multiple sizes for responsive images', async () => {
const input = await createTestImage({ width: 2000, height: 1500 })
const sizes = await generateResponsiveSizes(input, [320, 640, 1280])
expect(sizes).toHaveLength(3)
const metadata = await Promise.all(sizes.map(buf => sharp(buf).metadata()))
expect(metadata[0].width).toBe(320)
expect(metadata[1].width).toBe(640)
expect(metadata[2].width).toBe(1280)
})Summary
Image processing tests should:
- Generate test images programmatically — use Sharp's
createto build synthetic test images, no fixtures in git - Assert on output dimensions — width, height, aspect ratio
- Assert on output format — JPEG, WebP, AVIF
- Validate EXIF stripping — GPS coordinates must be removed from user uploads
- Validate magic bytes — reject non-image content regardless of extension
- Test compression ratios — verify WebP/AVIF are actually smaller than JPEG for representative content