Image Processing Testing: Sharp, Cloudinary SDK, and Image Optimization Pipelines

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 sharp for test fixturessharp({ create: { ... } }) generates test images programmatically
  • Assert on metadata, not bytessharp(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

Read more