Image Processing Testing: Sharp, Cloudinary SDK, and Image Optimization Pipelines
Image processing pipelines are tricky to test because they produce binary output that's hard to assert on directly. The key is extracting metadata and dimensions rather than comparing raw bytes, and separating the pipeline logic from the cloud SDK calls. This guide covers testing Sharp transformations, Cloudinary integrations, and image optimization pipelines.
Testing Sharp Transformations
Sharp is the most widely used Node.js image processing library. Test it by reading the metadata of the output buffer:
// services/imageProcessor.ts
import sharp from 'sharp'
export interface ResizeOptions {
width: number
height: number
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside'
format?: 'jpeg' | 'png' | 'webp' | 'avif'
quality?: number
}
export async function resizeImage(
inputBuffer: Buffer,
options: ResizeOptions
): Promise<Buffer> {
let pipeline = sharp(inputBuffer)
.resize(options.width, options.height, { fit: options.fit ?? 'cover' })
if (options.format) {
pipeline = pipeline.toFormat(options.format, { quality: options.quality ?? 80 })
}
return pipeline.toBuffer()
}
export async function generateThumbnail(inputBuffer: Buffer): Promise<Buffer> {
return sharp(inputBuffer)
.resize(200, 200, { fit: 'cover' })
.webp({ quality: 75 })
.toBuffer()
}
export async function optimizeForWeb(inputBuffer: Buffer): Promise<{
buffer: Buffer
format: string
width: number
height: number
size: number
}> {
const { data, info } = await sharp(inputBuffer)
.resize({ width: 1200, withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer({ resolveWithObject: true })
return {
buffer: data,
format: info.format,
width: info.width,
height: info.height,
size: info.size,
}
}// services/imageProcessor.test.ts
import sharp from 'sharp'
import { resizeImage, generateThumbnail, optimizeForWeb } from './imageProcessor'
// Helper: create a test image buffer
async function createTestImage(width = 800, height = 600): Promise<Buffer> {
return sharp({
create: {
width,
height,
channels: 3,
background: { r: 255, g: 128, b: 0 },
},
})
.jpeg()
.toBuffer()
}
describe('resizeImage', () => {
let testImageBuffer: Buffer
beforeAll(async () => {
testImageBuffer = await createTestImage(800, 600)
})
it('resizes image to specified dimensions', async () => {
const output = await resizeImage(testImageBuffer, { width: 400, height: 300 })
const metadata = await sharp(output).metadata()
expect(metadata.width).toBe(400)
expect(metadata.height).toBe(300)
})
it('converts to JPEG format', async () => {
const output = await resizeImage(testImageBuffer, {
width: 200,
height: 200,
format: 'jpeg',
})
const metadata = await sharp(output).metadata()
expect(metadata.format).toBe('jpeg')
})
it('converts to WebP format', async () => {
const output = await resizeImage(testImageBuffer, {
width: 200,
height: 200,
format: 'webp',
})
const metadata = await sharp(output).metadata()
expect(metadata.format).toBe('webp')
})
it('applies cover fit by default (crops to exact dimensions)', async () => {
// Input is 800x600 (4:3), output is 200x200 (1:1)
const output = await resizeImage(testImageBuffer, { width: 200, height: 200 })
const metadata = await sharp(output).metadata()
expect(metadata.width).toBe(200)
expect(metadata.height).toBe(200)
})
it('quality setting reduces file size', async () => {
const highQuality = await resizeImage(testImageBuffer, {
width: 400,
height: 300,
format: 'jpeg',
quality: 95,
})
const lowQuality = await resizeImage(testImageBuffer, {
width: 400,
height: 300,
format: 'jpeg',
quality: 20,
})
expect(lowQuality.byteLength).toBeLessThan(highQuality.byteLength)
})
})
describe('generateThumbnail', () => {
it('produces a 200x200 WebP thumbnail', async () => {
const input = await createTestImage(1000, 750)
const thumbnail = await generateThumbnail(input)
const metadata = await sharp(thumbnail).metadata()
expect(metadata.width).toBe(200)
expect(metadata.height).toBe(200)
expect(metadata.format).toBe('webp')
})
it('thumbnail is significantly smaller than original', async () => {
const input = await createTestImage(2000, 1500)
const thumbnail = await generateThumbnail(input)
expect(thumbnail.byteLength).toBeLessThan(input.byteLength * 0.1)
})
})
describe('optimizeForWeb', () => {
it('returns metadata along with the buffer', async () => {
const input = await createTestImage(2400, 1800)
const result = await optimizeForWeb(input)
expect(result.format).toBe('webp')
expect(result.width).toBeLessThanOrEqual(1200)
expect(result.height).toBeGreaterThan(0)
expect(result.size).toBeGreaterThan(0)
expect(Buffer.isBuffer(result.buffer)).toBe(true)
})
it('does not enlarge images smaller than 1200px', async () => {
const input = await createTestImage(800, 600)
const result = await optimizeForWeb(input)
expect(result.width).toBe(800) // not enlarged
})
})Testing Cloudinary SDK Integration
// services/cloudinaryService.ts
import { v2 as cloudinary } from 'cloudinary'
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
})
export async function uploadToCloudinary(
buffer: Buffer,
options: { folder: string; publicId?: string; transformation?: object[] }
): Promise<{ url: string; publicId: string; width: number; height: number }> {
return new Promise((resolve, reject) => {
cloudinary.uploader.upload_stream(
{
folder: options.folder,
public_id: options.publicId,
transformation: options.transformation,
format: 'webp',
quality: 'auto:good',
},
(error, result) => {
if (error || !result) return reject(error)
resolve({
url: result.secure_url,
publicId: result.public_id,
width: result.width,
height: result.height,
})
}
).end(buffer)
})
}// services/cloudinaryService.test.ts
import { v2 as cloudinary } from 'cloudinary'
import { uploadToCloudinary } from './cloudinaryService'
jest.mock('cloudinary', () => ({
v2: {
config: jest.fn(),
uploader: {
upload_stream: jest.fn(),
},
},
}))
const mockCloudinary = cloudinary as jest.Mocked<typeof cloudinary>
describe('uploadToCloudinary', () => {
it('uploads buffer and returns URL and dimensions', async () => {
const fakeResult = {
secure_url: 'https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.webp',
public_id: 'avatars/user-1',
width: 800,
height: 600,
}
mockCloudinary.uploader.upload_stream.mockImplementation(
(options: any, callback: any) => {
const stream = { end: (buffer: Buffer) => callback(null, fakeResult) }
return stream as any
}
)
const result = await uploadToCloudinary(Buffer.from('image'), {
folder: 'avatars',
publicId: 'user-1',
})
expect(result.url).toContain('cloudinary.com')
expect(result.publicId).toBe('avatars/user-1')
expect(result.width).toBe(800)
expect(result.height).toBe(600)
})
it('propagates Cloudinary errors', async () => {
mockCloudinary.uploader.upload_stream.mockImplementation(
(options: any, callback: any) => {
const stream = { end: () => callback(new Error('Invalid image format'), null) }
return stream as any
}
)
await expect(
uploadToCloudinary(Buffer.from('bad data'), { folder: 'test' })
).rejects.toThrow('Invalid image format')
})
})Testing Format Conversion Pipeline
// pipelines/avatarPipeline.ts
import { resizeImage } from '../services/imageProcessor'
import { uploadToCloudinary } from '../services/cloudinaryService'
export async function processAvatar(inputBuffer: Buffer, userId: string) {
// Validate input is an image
const metadata = await sharp(inputBuffer).metadata()
if (!['jpeg', 'png', 'webp', 'gif'].includes(metadata.format ?? '')) {
throw new Error(`Unsupported format: ${metadata.format}`)
}
// Generate sizes
const [full, thumbnail] = await Promise.all([
resizeImage(inputBuffer, { width: 400, height: 400, format: 'webp', quality: 85 }),
resizeImage(inputBuffer, { width: 80, height: 80, format: 'webp', quality: 75 }),
])
// Upload both
const [fullResult, thumbResult] = await Promise.all([
uploadToCloudinary(full, { folder: 'avatars', publicId: `${userId}/full` }),
uploadToCloudinary(thumbnail, { folder: 'avatars', publicId: `${userId}/thumb` }),
])
return {
fullUrl: fullResult.url,
thumbnailUrl: thumbResult.url,
}
}// pipelines/avatarPipeline.test.ts
import { processAvatar } from './avatarPipeline'
import * as imageProcessor from '../services/imageProcessor'
import * as cloudinaryService from '../services/cloudinaryService'
jest.mock('../services/imageProcessor')
jest.mock('../services/cloudinaryService')
const mockResize = imageProcessor.resizeImage as jest.Mock
const mockUpload = cloudinaryService.uploadToCloudinary as jest.Mock
describe('processAvatar', () => {
beforeEach(() => {
mockResize.mockResolvedValue(Buffer.from('resized'))
mockUpload
.mockResolvedValueOnce({ url: 'https://cdn.example.com/avatar/full.webp', publicId: 'u1/full' })
.mockResolvedValueOnce({ url: 'https://cdn.example.com/avatar/thumb.webp', publicId: 'u1/thumb' })
})
it('returns full and thumbnail URLs', async () => {
const input = await createTestImage()
const result = await processAvatar(input, 'user-1')
expect(result.fullUrl).toContain('full.webp')
expect(result.thumbnailUrl).toContain('thumb.webp')
})
it('generates both sizes in parallel', async () => {
const input = await createTestImage()
await processAvatar(input, 'user-1')
expect(mockResize).toHaveBeenCalledTimes(2)
expect(mockUpload).toHaveBeenCalledTimes(2)
})
})What Automated Tests Miss
Sharp and Cloudinary tests cover logic but won't catch:
- Corrupt image files from user uploads that crash sharp with a segfault
- EXIF data leakage — GPS coordinates in user-uploaded images exposed via CDN
- CDN cache headers — transformed images cached with wrong
Cache-Control - Color profile stripping — professional images with ICC profiles losing color accuracy
HelpMeTest runs scheduled tests that upload real images through your staging environment and verify they appear correctly in the browser. Pro plan at $100/month.
Summary
Testing image processing:
- Use
sharpfor test fixtures —sharp({ create: { ... } })generates test images programmatically - Assert on metadata, not bytes —
sharp(output).metadata()gives format, width, height - Test quality tradeoffs — verify low-quality output is smaller than high-quality
- Mock Cloudinary's
upload_stream— the callback-based API needs careful mock setup - Test pipeline composition — verify parallel processing and correct option passing