Video Streaming Testing: HLS, DASH, and Media Player Integration Tests

Video Streaming Testing: HLS, DASH, and Media Player Integration Tests

Video streaming applications involve multiple components: transcoding pipelines, manifest generation, CDN delivery, and player integration. Each layer needs testing.

Testing the Transcoding Pipeline

// services/transcoder.js
import { spawn } from 'child_process'
import path from 'path'

export async function transcodeToHLS(inputPath, outputDir) {
  return new Promise((resolve, reject) => {
    const ffmpeg = spawn('ffmpeg', [
      '-i', inputPath,
      '-codec:', 'copy',
      '-start_number', '0',
      '-hls_time', '10',
      '-hls_list_size', '0',
      '-f', 'hls',
      path.join(outputDir, 'output.m3u8')
    ])
    
    let stderr = ''
    ffmpeg.stderr.on('data', data => { stderr += data.toString() })
    
    ffmpeg.on('close', (code) => {
      if (code === 0) resolve({ manifestPath: path.join(outputDir, 'output.m3u8') })
      else reject(new Error(`FFmpeg failed: ${stderr}`))
    })
  })
}
// services/transcoder.test.js
import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import os from 'os'
import { transcodeToHLS } from './transcoder'

let outputDir

beforeEach(() => {
  outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hls-test-'))
})

afterEach(() => {
  fs.rmSync(outputDir, { recursive: true })
})

test('generates HLS manifest from MP4', async () => {
  // Use a small test video (generated with ffmpeg)
  const testVideo = 'tests/fixtures/test-10s.mp4'
  
  const result = await transcodeToHLS(testVideo, outputDir)
  
  expect(fs.existsSync(result.manifestPath)).toBe(true)
  
  const manifest = fs.readFileSync(result.manifestPath, 'utf-8')
  expect(manifest).toContain('#EXTM3U')
  expect(manifest).toContain('#EXT-X-VERSION')
  expect(manifest).toContain('.ts')  // Segment files
})

test('manifest references segments that exist on disk', async () => {
  const testVideo = 'tests/fixtures/test-10s.mp4'
  const result = await transcodeToHLS(testVideo, outputDir)
  
  const manifest = fs.readFileSync(result.manifestPath, 'utf-8')
  const segments = manifest.match(/output\d+\.ts/g) || []
  
  expect(segments.length).toBeGreaterThan(0)
  
  for (const segment of segments) {
    const segPath = path.join(outputDir, segment)
    expect(fs.existsSync(segPath)).toBe(true)
    expect(fs.statSync(segPath).size).toBeGreaterThan(0)
  }
})

HLS Manifest Validation

// test-helpers/hls-validator.js
export function validateM3U8Manifest(content) {
  const errors = []
  
  if (!content.startsWith('#EXTM3U')) {
    errors.push('Missing #EXTM3U header')
  }
  
  if (!content.includes('#EXT-X-VERSION')) {
    errors.push('Missing #EXT-X-VERSION')
  }
  
  // Every EXTINF must be followed by a URI
  const lines = content.split('\n')
  for (let i = 0; i < lines.length; i++) {
    if (lines[i].startsWith('#EXTINF')) {
      if (i + 1 >= lines.length || lines[i + 1].startsWith('#')) {
        errors.push(`EXTINF at line ${i + 1} not followed by segment URI`)
      }
    }
  }
  
  // Check for EXT-X-ENDLIST in VOD content
  if (!content.includes('#EXT-X-ENDLIST')) {
    errors.push('VOD manifest missing #EXT-X-ENDLIST')
  }
  
  return errors
}

// In tests
test('generated manifest is valid HLS', async () => {
  const manifest = fs.readFileSync(manifestPath, 'utf-8')
  const errors = validateM3U8Manifest(manifest)
  
  expect(errors).toHaveLength(0)
})

Testing Adaptive Bitrate (Multi-Quality)

test('generates multi-bitrate HLS with master manifest', async () => {
  const result = await transcodeMultiQuality('tests/fixtures/test-10s.mp4', outputDir)
  
  const masterManifest = fs.readFileSync(result.masterManifestPath, 'utf-8')
  
  // Master playlist should reference renditions
  expect(masterManifest).toContain('#EXT-X-STREAM-INF')
  expect(masterManifest).toContain('BANDWIDTH')
  
  // Should have 360p, 720p, 1080p renditions
  expect(masterManifest).toContain('360p/index.m3u8')
  expect(masterManifest).toContain('720p/index.m3u8')
  expect(masterManifest).toContain('1080p/index.m3u8')
  
  // Each rendition playlist should exist and be valid
  for (const quality of ['360p', '720p', '1080p']) {
    const renditionPath = path.join(outputDir, quality, 'index.m3u8')
    expect(fs.existsSync(renditionPath)).toBe(true)
    
    const content = fs.readFileSync(renditionPath, 'utf-8')
    const errors = validateM3U8Manifest(content)
    expect(errors).toHaveLength(0)
  }
})

Testing Video Segment Delivery

// API test: segment delivery with Range requests
test('serves video segments with range header support', async () => {
  const response = await request(app)
    .get('/videos/output0.ts')
    .set('Range', 'bytes=0-1023')
  
  expect(response.status).toBe(206)  // Partial Content
  expect(response.headers['content-range']).toMatch(/^bytes 0-1023\/\d+$/)
  expect(response.headers['accept-ranges']).toBe('bytes')
  expect(response.body.length).toBe(1024)
})

test('serves full segment without Range header', async () => {
  const response = await request(app).get('/videos/output0.ts')
  
  expect(response.status).toBe(200)
  expect(response.headers['content-type']).toBe('video/MP2T')
})

Browser Playback Testing with Playwright

// e2e/video-player.spec.ts
import { test, expect } from '@playwright/test'

test('video starts playing after clicking play', async ({ page }) => {
  await page.goto('/watch/video-123')
  
  const video = page.locator('video')
  await expect(video).toBeVisible()
  
  // Video should be paused initially
  const isPaused = await video.evaluate((el) => el.paused)
  expect(isPaused).toBe(true)
  
  // Click play
  await page.click('[data-testid="play-button"]')
  
  // Video should start playing
  await page.waitForFunction(() => {
    const v = document.querySelector('video')
    return v && !v.paused && v.currentTime > 0
  }, { timeout: 5000 })
  
  const isPlaying = await video.evaluate((el) => !el.paused)
  expect(isPlaying).toBe(true)
})

test('video player shows buffering indicator during load', async ({ page }) => {
  // Throttle network to trigger buffering
  await page.context().route('**/*.ts', async (route) => {
    await new Promise(r => setTimeout(r, 2000))
    await route.continue()
  })
  
  await page.goto('/watch/video-123')
  await page.click('[data-testid="play-button"]')
  
  // Buffering indicator should appear
  await expect(page.locator('[data-testid="buffering-spinner"]')).toBeVisible()
})

Testing CDN Cache Headers

test('video segments have correct cache headers', async () => {
  const response = await request(app).get('/videos/output0.ts')
  
  // Segments are immutable — cache forever
  expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable')
})

test('manifest has short cache TTL for live streams', async () => {
  const response = await request(app).get('/live/room-1/index.m3u8')
  
  // Live manifests update frequently
  expect(response.headers['cache-control']).toBe('no-cache, no-store')
})

test('master manifest has medium cache TTL', async () => {
  const response = await request(app).get('/videos/master.m3u8')
  
  // Master doesn't change often but can
  const cc = response.headers['cache-control']
  expect(cc).toContain('max-age=')
  
  const maxAge = parseInt(cc.match(/max-age=(\d+)/)?.[1] || '0')
  expect(maxAge).toBeGreaterThanOrEqual(60)
  expect(maxAge).toBeLessThanOrEqual(3600)
})

Summary

Video streaming testing covers:

  1. Transcoding unit tests — validate FFmpeg output, manifest format, segment existence
  2. HLS manifest validation — correct headers, segment references, EXT-X-ENDLIST
  3. Range request support — 206 responses, Content-Range headers
  4. Browser playback — Playwright tests for play/pause, buffering states
  5. CDN cache headers — immutable segments, short TTL for live manifests

Generate a small test video file (2-3 seconds) with FFmpeg for use in unit tests. Keep it in version control as a fixture — it's small and enables reproducible transcoding tests.

Read more