Video Transcoding and Processing Testing: FFmpeg, AWS MediaConvert, and Mux

Video Transcoding and Processing Testing: FFmpeg, AWS MediaConvert, and Mux

Video transcoding is inherently async, slow, and hard to test in unit tests. The processing takes minutes, produces binary output, and depends on external services like AWS MediaConvert or Mux. This guide shows how to test video processing pipelines by separating fast unit tests (job orchestration, webhook handling) from integration tests that validate actual output.

Architecture: What to Test Where

Video processing has three testable layers:

  1. Job submission — does your API trigger the transcoding job with correct parameters?
  2. Webhook handling — does your server correctly handle job completion/failure events?
  3. Output quality — does the transcoded video meet specification? (slower, integration only)

Testing FFmpeg Job Invocation

// services/ffmpegService.ts
import { spawn } from 'child_process'
import path from 'path'

export interface TranscodeOptions {
  inputPath: string
  outputPath: string
  codec: 'h264' | 'vp9' | 'hevc'
  bitrate: string      // e.g. '2000k'
  resolution: string   // e.g. '1280x720'
  preset: 'ultrafast' | 'medium' | 'slow'
}

export async function transcodeVideo(options: TranscodeOptions): Promise<void> {
  const args = [
    '-i', options.inputPath,
    '-vcodec', codecMap[options.codec],
    '-b:v', options.bitrate,
    '-vf', `scale=${options.resolution}`,
    '-preset', options.preset,
    '-y', // overwrite output
    options.outputPath,
  ]

  return new Promise((resolve, reject) => {
    const proc = spawn('ffmpeg', args)
    const stderr: string[] = []

    proc.stderr.on('data', (chunk) => stderr.push(chunk.toString()))
    proc.on('close', (code) => {
      if (code === 0) resolve()
      else reject(new Error(`FFmpeg failed (exit ${code}): ${stderr.join('')}`))
    })
  })
}

const codecMap = { h264: 'libx264', vp9: 'libvpx-vp9', hevc: 'libx265' }
// services/ffmpegService.test.ts
import { spawn } from 'child_process'
import { EventEmitter } from 'events'
import { transcodeVideo } from './ffmpegService'

jest.mock('child_process')

const mockSpawn = spawn as jest.Mock

function makeMockProcess(exitCode: number) {
  const proc = new EventEmitter() as any
  proc.stderr = new EventEmitter()
  proc.stdout = new EventEmitter()

  // Emit close asynchronously
  setImmediate(() => proc.emit('close', exitCode))
  return proc
}

describe('transcodeVideo', () => {
  it('invokes ffmpeg with correct arguments for h264', async () => {
    const mockProc = makeMockProcess(0)
    mockSpawn.mockReturnValue(mockProc)

    await transcodeVideo({
      inputPath: '/tmp/input.mp4',
      outputPath: '/tmp/output.mp4',
      codec: 'h264',
      bitrate: '2000k',
      resolution: '1280x720',
      preset: 'medium',
    })

    expect(mockSpawn).toHaveBeenCalledWith(
      'ffmpeg',
      expect.arrayContaining([
        '-i', '/tmp/input.mp4',
        '-vcodec', 'libx264',
        '-b:v', '2000k',
        '-vf', 'scale=1280x720',
        '-preset', 'medium',
        '/tmp/output.mp4',
      ])
    )
  })

  it('resolves on exit code 0', async () => {
    mockSpawn.mockReturnValue(makeMockProcess(0))
    await expect(
      transcodeVideo({ inputPath: 'in.mp4', outputPath: 'out.mp4', codec: 'h264', bitrate: '1000k', resolution: '640x480', preset: 'ultrafast' })
    ).resolves.toBeUndefined()
  })

  it('rejects on non-zero exit code', async () => {
    mockSpawn.mockReturnValue(makeMockProcess(1))
    await expect(
      transcodeVideo({ inputPath: 'bad.mp4', outputPath: 'out.mp4', codec: 'h264', bitrate: '1000k', resolution: '640x480', preset: 'ultrafast' })
    ).rejects.toThrow('FFmpeg failed')
  })
})

Testing AWS MediaConvert Job Submission

// services/mediaConvertService.ts
import { MediaConvertClient, CreateJobCommand } from '@aws-sdk/client-mediaconvert'

const client = new MediaConvertClient({ endpoint: process.env.MEDIACONVERT_ENDPOINT })

export async function submitTranscodeJob(params: {
  inputS3Uri: string
  outputS3Prefix: string
  jobTemplate: string
}): Promise<{ jobId: string }> {
  const command = new CreateJobCommand({
    Role: process.env.MEDIACONVERT_ROLE_ARN,
    JobTemplate: params.jobTemplate,
    Settings: {
      Inputs: [{ FileInput: params.inputS3Uri }],
      OutputGroups: [{
        OutputGroupSettings: {
          Type: 'FILE_GROUP_SETTINGS',
          FileGroupSettings: { Destination: params.outputS3Prefix },
        },
      }],
    },
  })

  const response = await client.send(command)
  const jobId = response.Job?.Id

  if (!jobId) throw new Error('MediaConvert did not return a job ID')
  return { jobId }
}
// services/mediaConvertService.test.ts
import { MediaConvertClient, CreateJobCommand } from '@aws-sdk/client-mediaconvert'
import { mockClient } from 'aws-sdk-client-mock'
import { submitTranscodeJob } from './mediaConvertService'

const mediaConvertMock = mockClient(MediaConvertClient)

describe('submitTranscodeJob', () => {
  beforeEach(() => mediaConvertMock.reset())

  it('submits a job and returns the job ID', async () => {
    mediaConvertMock.on(CreateJobCommand).resolves({
      Job: { Id: 'job-abc-123', Status: 'SUBMITTED' },
    })

    const result = await submitTranscodeJob({
      inputS3Uri: 's3://bucket/input/video.mp4',
      outputS3Prefix: 's3://bucket/output/',
      jobTemplate: 'MyTemplate',
    })

    expect(result.jobId).toBe('job-abc-123')
  })

  it('includes the input URI in the job settings', async () => {
    mediaConvertMock.on(CreateJobCommand).resolves({
      Job: { Id: 'job-xyz' },
    })

    await submitTranscodeJob({
      inputS3Uri: 's3://bucket/input/video.mp4',
      outputS3Prefix: 's3://bucket/output/',
      jobTemplate: 'MyTemplate',
    })

    const call = mediaConvertMock.commandCalls(CreateJobCommand)[0]
    expect(call.args[0].input.Settings?.Inputs?.[0]?.FileInput).toBe('s3://bucket/input/video.mp4')
  })

  it('throws when MediaConvert returns no job ID', async () => {
    mediaConvertMock.on(CreateJobCommand).resolves({ Job: {} })

    await expect(
      submitTranscodeJob({ inputS3Uri: 's3://bucket/in.mp4', outputS3Prefix: 's3://out/', jobTemplate: 'T' })
    ).rejects.toThrow('did not return a job ID')
  })
})

Testing Webhook Handling (MediaConvert, Mux)

// webhooks/mediaConvertWebhook.ts
import { db } from '../db'
import { notifyUser } from '../services/notifications'

export async function handleMediaConvertEvent(event: any): Promise<void> {
  if (event.source !== 'aws.mediaconvert') return

  const jobId = event.detail.jobId
  const status = event.detail.status

  if (status === 'COMPLETE') {
    const outputUri = event.detail.outputGroupDetails?.[0]?.outputDetails?.[0]?.outputFilePaths?.[0]
    await db.videos.update(jobId, { status: 'ready', outputUri })
    await notifyUser(jobId, 'Your video is ready!')
  } else if (status === 'ERROR') {
    const errorMessage = event.detail.errorMessage
    await db.videos.update(jobId, { status: 'failed', errorMessage })
    await notifyUser(jobId, 'Video processing failed.')
  }
}
// webhooks/mediaConvertWebhook.test.ts
import { handleMediaConvertEvent } from './mediaConvertWebhook'
import { db } from '../db'
import { notifyUser } from '../services/notifications'

jest.mock('../db')
jest.mock('../services/notifications')

describe('handleMediaConvertEvent', () => {
  const mockDb = db as jest.Mocked<typeof db>
  const mockNotify = notifyUser as jest.Mock

  beforeEach(() => jest.clearAllMocks())

  it('updates video to ready on COMPLETE', async () => {
    mockDb.videos.update.mockResolvedValue(undefined)
    mockNotify.mockResolvedValue(undefined)

    await handleMediaConvertEvent({
      source: 'aws.mediaconvert',
      detail: {
        jobId: 'job-1',
        status: 'COMPLETE',
        outputGroupDetails: [{
          outputDetails: [{ outputFilePaths: ['s3://bucket/output/video.mp4'] }],
        }],
      },
    })

    expect(mockDb.videos.update).toHaveBeenCalledWith('job-1', {
      status: 'ready',
      outputUri: 's3://bucket/output/video.mp4',
    })
    expect(mockNotify).toHaveBeenCalledWith('job-1', expect.stringContaining('ready'))
  })

  it('updates video to failed on ERROR', async () => {
    mockDb.videos.update.mockResolvedValue(undefined)
    mockNotify.mockResolvedValue(undefined)

    await handleMediaConvertEvent({
      source: 'aws.mediaconvert',
      detail: {
        jobId: 'job-2',
        status: 'ERROR',
        errorMessage: 'Unsupported codec in input file',
      },
    })

    expect(mockDb.videos.update).toHaveBeenCalledWith('job-2', {
      status: 'failed',
      errorMessage: 'Unsupported codec in input file',
    })
  })

  it('ignores events from other sources', async () => {
    await handleMediaConvertEvent({ source: 'aws.s3', detail: {} })
    expect(mockDb.videos.update).not.toHaveBeenCalled()
  })
})

Integration Test: FFmpeg Output Validation

For integration tests that run real FFmpeg, validate the output metadata:

// tests/integration/ffmpegOutput.test.ts
import { execa } from 'execa'
import path from 'path'
import os from 'os'
import fs from 'fs/promises'
import { transcodeVideo } from '../../services/ffmpegService'

async function getVideoMetadata(filePath: string) {
  const { stdout } = await execa('ffprobe', [
    '-v', 'quiet',
    '-print_format', 'json',
    '-show_streams', '-show_format',
    filePath,
  ])
  return JSON.parse(stdout)
}

describe('FFmpeg output quality', () => {
  const testInputPath = path.join(__dirname, 'fixtures/test-video.mp4')
  let outputPath: string

  beforeEach(async () => {
    outputPath = path.join(os.tmpdir(), `test-output-${Date.now()}.mp4`)
  })

  afterEach(async () => {
    await fs.rm(outputPath, { force: true })
  })

  it('produces a 720p H.264 video', async () => {
    await transcodeVideo({
      inputPath: testInputPath,
      outputPath,
      codec: 'h264',
      bitrate: '2000k',
      resolution: '1280x720',
      preset: 'ultrafast',
    })

    const metadata = await getVideoMetadata(outputPath)
    const videoStream = metadata.streams.find((s: any) => s.codec_type === 'video')

    expect(videoStream.codec_name).toBe('h264')
    expect(videoStream.width).toBe(1280)
    expect(videoStream.height).toBe(720)
  }, 60000) // allow up to 60s for real transcode
})

What Automated Tests Miss

Mock-based tests cover job logic but won't catch:

  • Codec compatibility issues with specific input file variants
  • Memory exhaustion during transcoding of 4K source files
  • Mux playback compatibility — a video that transcodes successfully but stutters on mobile Safari
  • CDN delivery failures — correctly transcoded video that isn't accessible via CDN URL

HelpMeTest runs scheduled E2E tests that upload a video, wait for processing, and play it back in a real browser — catching the full pipeline end-to-end. Pro plan at $100/month.

Summary

Testing video processing pipelines:

  • Mock FFmpeg's spawn — test argument construction without a real binary
  • Mock AWS SDK with aws-sdk-client-mock — test MediaConvert job submission
  • Test webhook handlers directly — verify COMPLETE/ERROR/PROGRESSING events
  • Integration tests — real FFmpeg with ffprobe metadata assertions (slow, tag @integration)
  • Separate fast from slow — unit tests run in CI; integration tests run on a schedule

Read more